From bf417e25ffaf858b4feb4fa0972498353d538bab Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Wed, 2 Apr 2025 09:08:46 +0200 Subject: [PATCH 01/14] Add draft implementation of usage analytics --- tests/test_usage_analytics.py | 86 ++++++++++++++++++++++++ usage_analytics/__init__.py | 0 usage_analytics/analytics_definition.py | 12 ++++ usage_analytics/analytics_func.py | 72 ++++++++++++++++++++ usage_analytics/analytics_http_client.py | 25 +++++++ 5 files changed, 195 insertions(+) create mode 100644 tests/test_usage_analytics.py create mode 100644 usage_analytics/__init__.py create mode 100644 usage_analytics/analytics_definition.py create mode 100644 usage_analytics/analytics_func.py create mode 100644 usage_analytics/analytics_http_client.py diff --git a/tests/test_usage_analytics.py b/tests/test_usage_analytics.py new file mode 100644 index 0000000..3b0dda7 --- /dev/null +++ b/tests/test_usage_analytics.py @@ -0,0 +1,86 @@ +import unittest +from tabpfn_common_utils.usage_analytics import get_calling_class + + +class TestGetCallingClass(unittest.TestCase): + def test_standalone_function(self): + """Test that standalone functions return 'StandaloneFunction'""" + result = get_calling_class() + self.assertEqual(result, "TestGetCallingClass") + + def test_direct_class_call(self): + """Test direct class method call returns the correct class name""" + + class DirectCaller: + def call_method(self): + return get_calling_class() + + caller = DirectCaller() + result = caller.call_method() + self.assertEqual(result, "DirectCaller") + + def test_nested_class_call(self): + """Test nested class method calls return the outermost caller""" + + class InnerCaller: + def call_method(self): + return get_calling_class() + + class OuterCaller: + def __init__(self): + self.inner = InnerCaller() + + def call_method(self): + return self.inner.call_method() + + caller = OuterCaller() + result = caller.call_method() + self.assertEqual(result, "OuterCaller") + + def test_deeply_nested_call(self): + """Test deeply nested class method calls return the outermost caller""" + + class Level3: + def call_method(self): + return get_calling_class() + + class Level2: + def __init__(self): + self.level3 = Level3() + + def call_method(self): + return self.level3.call_method() + + class Level1: + def __init__(self): + self.level2 = Level2() + + def call_method(self): + return self.level2.call_method() + + caller = Level1() + result = caller.call_method() + self.assertEqual(result, "Level1") + + def test_with_parameter(self): + """Test that the recursive parameter works correctly""" + + class InnerCaller: + def call_method(self): + # With recursive=False, should return InnerCaller + return get_calling_class(recursive=False) + + class OuterCaller: + def __init__(self): + self.inner = InnerCaller() + + def call_method(self): + return self.inner.call_method() + + caller = OuterCaller() + result = caller.call_method() + self.assertEqual(result, "InnerCaller") + + +if __name__ == "__main__": + unittest.main() diff --git a/usage_analytics/__init__.py b/usage_analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/usage_analytics/analytics_definition.py b/usage_analytics/analytics_definition.py new file mode 100644 index 0000000..d7bafb3 --- /dev/null +++ b/usage_analytics/analytics_definition.py @@ -0,0 +1,12 @@ +from tabpfn_common_utils.usage_analytics.analytics_func import ( + get_calling_class, + get_python_version, + get_unique_call_id, +) + + +ANALYTICS_TO_TRACK = [ + ("X-Unique-Call-Id", get_unique_call_id), + ("X-Calling-Class", get_calling_class), + ("X-Python-Version", get_python_version), +] diff --git a/usage_analytics/analytics_func.py b/usage_analytics/analytics_func.py new file mode 100644 index 0000000..13c8718 --- /dev/null +++ b/usage_analytics/analytics_func.py @@ -0,0 +1,72 @@ +def get_calling_class(recursive=True): + """ + Traverses the call stack to find and return the class name of the first + caller that has a 'self' variable in its frame (i.e., a method call). + If no such frame is found, returns 'StandaloneFunction'. + """ + + import inspect + + # Skip the current frame and the immediate caller (i.e., start from index 1) + stack = inspect.stack()[1:] + + outermost_caller = "StandaloneFunction" # Default return value + + for frame_record in stack: + frame = frame_record.frame + # Look for 'self' in the frame's local variables + self_obj = frame.f_locals.get("self", None) + if self_obj is not None: + # Return the class name of the 'self' instance + outermost_caller = type(self_obj).__name__ + if not recursive: + break + + # If no class context was found, assume it's a standalone function + return outermost_caller + + +def get_python_version(): + import platform + + return platform.python_version() + + +def get_unique_call_id(): + import uuid + + return str(uuid.uuid4()) + + +# Example usage in a class method: +class ExampleCaller: + def call_api(self): + # This function would call the API and use get_calling_class to report the caller + caller_class = get_calling_class() + print(f"Called from class: {caller_class}") + + +class NestedExampleCaller: + def __init__(self): + self.example_caller = ExampleCaller() + + def call_api(self): + self.example_caller.call_api() + + +def standalone_function(): + # This function doesn't belong to any class, so it should return "StandaloneFunction" + caller_class = get_calling_class() + print(f"Called from: {caller_class}") + + +if __name__ == "__main__": + # Test the get_calling_class function + example = ExampleCaller() + example.call_api() # Expected output: Called from class: ExampleCaller + nested_example = NestedExampleCaller() + nested_example.call_api() # Expected output: Called from class: NestedExampleCaller + standalone_function() # Expected output: Called from: StandaloneFunction + + # Test the get_python_ver function + print(f"Python version: {get_python_version()}") diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py new file mode 100644 index 0000000..c59ec61 --- /dev/null +++ b/usage_analytics/analytics_http_client.py @@ -0,0 +1,25 @@ +import httpx +from tabpfn_common_utils.usage_analytics.analytics_definition import ANALYTICS_TO_TRACK + + +class AnalyticsHttpClient(httpx.Client): + """ + Custom HTTP client that automatically adds the calling class to all requests. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def request(self, method, url, *args, **kwargs): + headers = kwargs.get("headers", {}) + if headers is None: + headers = {} + + # Add the analytics headers + for header_name, get_value_func in ANALYTICS_TO_TRACK: + headers[header_name] = get_value_func() + + kwargs["headers"] = headers + + # Call the original request method + return super().request(method, url, *args, **kwargs) From d76c1b85dc4c58d36d4c429cad80ca1b8fdf6647 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Wed, 2 Apr 2025 15:30:56 +0200 Subject: [PATCH 02/14] Add module name to analytics --- usage_analytics/__init__.py | 7 +++++++ usage_analytics/analytics_definition.py | 5 +++-- usage_analytics/analytics_http_client.py | 10 +++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/usage_analytics/__init__.py b/usage_analytics/__init__.py index e69de29..72b9d6a 100644 --- a/usage_analytics/__init__.py +++ b/usage_analytics/__init__.py @@ -0,0 +1,7 @@ +from .analytics_http_client import AnalyticsHttpClient +from .analytics_definition import ANALYTICS_TO_TRACK + +__all__ = [ + "AnalyticsHttpClient", + "ANALYTICS_TO_TRACK", +] diff --git a/usage_analytics/analytics_definition.py b/usage_analytics/analytics_definition.py index d7bafb3..0340751 100644 --- a/usage_analytics/analytics_definition.py +++ b/usage_analytics/analytics_definition.py @@ -1,4 +1,4 @@ -from tabpfn_common_utils.usage_analytics.analytics_func import ( +from .analytics_func import ( get_calling_class, get_python_version, get_unique_call_id, @@ -7,6 +7,7 @@ ANALYTICS_TO_TRACK = [ ("X-Unique-Call-Id", get_unique_call_id), - ("X-Calling-Class", get_calling_class), ("X-Python-Version", get_python_version), + ("X-Calling-Class", get_calling_class), + ("X-Module-Name", None), # Value provided by AnalyticsHttpClient ] diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py index c59ec61..9c5be20 100644 --- a/usage_analytics/analytics_http_client.py +++ b/usage_analytics/analytics_http_client.py @@ -1,5 +1,5 @@ import httpx -from tabpfn_common_utils.usage_analytics.analytics_definition import ANALYTICS_TO_TRACK +from .analytics_definition import ANALYTICS_TO_TRACK class AnalyticsHttpClient(httpx.Client): @@ -7,8 +7,9 @@ class AnalyticsHttpClient(httpx.Client): Custom HTTP client that automatically adds the calling class to all requests. """ - def __init__(self, *args, **kwargs): + def __init__(self, module_name: str, *args, **kwargs): super().__init__(*args, **kwargs) + self.module_name = module_name def request(self, method, url, *args, **kwargs): headers = kwargs.get("headers", {}) @@ -17,7 +18,10 @@ def request(self, method, url, *args, **kwargs): # Add the analytics headers for header_name, get_value_func in ANALYTICS_TO_TRACK: - headers[header_name] = get_value_func() + if header_name == "X-Module-Name": + headers[header_name] = self.module_name + elif get_value_func is None: + headers[header_name] = get_value_func() kwargs["headers"] = headers From d276816275e30e90eea5f67b7da36e30021b9431 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Wed, 2 Apr 2025 15:33:45 +0200 Subject: [PATCH 03/14] Add fix --- usage_analytics/analytics_http_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py index 9c5be20..45a35e5 100644 --- a/usage_analytics/analytics_http_client.py +++ b/usage_analytics/analytics_http_client.py @@ -20,9 +20,10 @@ def request(self, method, url, *args, **kwargs): for header_name, get_value_func in ANALYTICS_TO_TRACK: if header_name == "X-Module-Name": headers[header_name] = self.module_name - elif get_value_func is None: + elif get_value_func is not None: headers[header_name] = get_value_func() + print(f"headers: {headers}") kwargs["headers"] = headers # Call the original request method From d39f064e0b0b42e514df9b66e43f35f1c73308bb Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Wed, 2 Apr 2025 15:52:44 +0200 Subject: [PATCH 04/14] Also override httpx client stream --- usage_analytics/analytics_http_client.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py index 45a35e5..30d90b7 100644 --- a/usage_analytics/analytics_http_client.py +++ b/usage_analytics/analytics_http_client.py @@ -16,15 +16,24 @@ def request(self, method, url, *args, **kwargs): if headers is None: headers = {} - # Add the analytics headers + kwargs["headers"] = self._add_analytics_headers(headers) + + # Call the original request method + return super().request(method, url, *args, **kwargs) + + def stream(self, method, url, *args, **kwargs): + headers = kwargs.get("headers", {}) + if headers is None: + headers = {} + + kwargs["headers"] = self._add_analytics_headers(headers) + + return super().stream(method, url, *args, **kwargs) + + def _add_analytics_headers(self, headers: dict): for header_name, get_value_func in ANALYTICS_TO_TRACK: if header_name == "X-Module-Name": headers[header_name] = self.module_name elif get_value_func is not None: headers[header_name] = get_value_func() - - print(f"headers: {headers}") - kwargs["headers"] = headers - - # Call the original request method - return super().request(method, url, *args, **kwargs) + return headers From c766be1b183efcea3c54eedf9dc71d7b911b3fce Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Sat, 12 Apr 2025 15:54:48 +0200 Subject: [PATCH 05/14] Fix test --- tests/test_usage_analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_usage_analytics.py b/tests/test_usage_analytics.py index 3b0dda7..9fbd7de 100644 --- a/tests/test_usage_analytics.py +++ b/tests/test_usage_analytics.py @@ -1,5 +1,5 @@ import unittest -from tabpfn_common_utils.usage_analytics import get_calling_class +from usage_analytics import get_calling_class class TestGetCallingClass(unittest.TestCase): From aab876ff6652dccce8af2a9a1afb4292876d2c67 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Sat, 12 Apr 2025 16:08:35 +0200 Subject: [PATCH 06/14] Fix requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a708362..f3b73c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ numpy>=2.0.0 pandas>=2.2.3 scikit-learn>=1.6.1 -typing_extensions>=4.12.2 \ No newline at end of file +typing_extensions>=4.12.2 +httpx>=0.28.1 \ No newline at end of file From f3fab3ca3157ecf7d92f732857419509519040ac Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Sat, 12 Apr 2025 16:16:16 +0200 Subject: [PATCH 07/14] Update test --- tests/test_analytics_http_client.py | 69 +++++++++++++++++++++++ tests/test_usage_analytics.py | 86 ----------------------------- 2 files changed, 69 insertions(+), 86 deletions(-) create mode 100644 tests/test_analytics_http_client.py delete mode 100644 tests/test_usage_analytics.py diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py new file mode 100644 index 0000000..e39816f --- /dev/null +++ b/tests/test_analytics_http_client.py @@ -0,0 +1,69 @@ +import unittest +from unittest.mock import patch, MagicMock +import httpx +from usage_analytics import AnalyticsHttpClient, ANALYTICS_TO_TRACK + + +class TestAnalyticsHttpClient(unittest.TestCase): + def setUp(self): + self.module_name = "test_module" + self.client = AnalyticsHttpClient(module_name=self.module_name) + + def test_init(self): + """Test that the client initializes with the correct module name.""" + self.assertEqual(self.client.module_name, self.module_name) + self.assertIsInstance(self.client, httpx.Client) + + @patch('usage_analytics.analytics_http_client.super') + def test_request_adds_headers(self, mock_super): + """Test that request method adds analytics headers.""" + mock_request = MagicMock() + mock_super.return_value.request = mock_request + + # Call request method + self.client.request("GET", "https://example.com", headers={"Existing": "Header"}) + + # Check that super().request was called with modified headers + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "GET") + self.assertEqual(args[1], "https://example.com") + + # Verify headers were added + headers = kwargs.get("headers", {}) + self.assertEqual(headers.get("Existing"), "Header") + self.assertEqual(headers.get("X-Module-Name"), self.module_name) + self.assertIn("X-Unique-Call-Id", headers) + self.assertIn("X-Python-Version", headers) + self.assertIn("X-Calling-Class", headers) + + @patch('usage_analytics.analytics_http_client.super') + def test_stream_adds_headers(self, mock_super): + """Test that stream method adds analytics headers.""" + mock_stream = MagicMock() + mock_super.return_value.stream = mock_stream + + # Call stream method + self.client.stream("GET", "https://example.com", headers={"Existing": "Header"}) + + # Check that super().stream was called with modified headers + args, kwargs = mock_stream.call_args + self.assertEqual(args[0], "GET") + self.assertEqual(args[1], "https://example.com") + + # Verify headers were added + headers = kwargs.get("headers", {}) + self.assertEqual(headers.get("Existing"), "Header") + + # Verify all analytics headers from ANALYTICS_TO_TRACK were added + for header_name, _ in ANALYTICS_TO_TRACK: + self.assertIn(header_name, headers) + + # Verify specific header values + self.assertEqual(headers.get("X-Module-Name"), self.module_name) + self.assertIsNotNone(headers.get("X-Unique-Call-Id")) + self.assertIsNotNone(headers.get("X-Python-Version")) + self.assertIsNotNone(headers.get("X-Calling-Class")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_usage_analytics.py b/tests/test_usage_analytics.py deleted file mode 100644 index 9fbd7de..0000000 --- a/tests/test_usage_analytics.py +++ /dev/null @@ -1,86 +0,0 @@ -import unittest -from usage_analytics import get_calling_class - - -class TestGetCallingClass(unittest.TestCase): - def test_standalone_function(self): - """Test that standalone functions return 'StandaloneFunction'""" - result = get_calling_class() - self.assertEqual(result, "TestGetCallingClass") - - def test_direct_class_call(self): - """Test direct class method call returns the correct class name""" - - class DirectCaller: - def call_method(self): - return get_calling_class() - - caller = DirectCaller() - result = caller.call_method() - self.assertEqual(result, "DirectCaller") - - def test_nested_class_call(self): - """Test nested class method calls return the outermost caller""" - - class InnerCaller: - def call_method(self): - return get_calling_class() - - class OuterCaller: - def __init__(self): - self.inner = InnerCaller() - - def call_method(self): - return self.inner.call_method() - - caller = OuterCaller() - result = caller.call_method() - self.assertEqual(result, "OuterCaller") - - def test_deeply_nested_call(self): - """Test deeply nested class method calls return the outermost caller""" - - class Level3: - def call_method(self): - return get_calling_class() - - class Level2: - def __init__(self): - self.level3 = Level3() - - def call_method(self): - return self.level3.call_method() - - class Level1: - def __init__(self): - self.level2 = Level2() - - def call_method(self): - return self.level2.call_method() - - caller = Level1() - result = caller.call_method() - self.assertEqual(result, "Level1") - - def test_with_parameter(self): - """Test that the recursive parameter works correctly""" - - class InnerCaller: - def call_method(self): - # With recursive=False, should return InnerCaller - return get_calling_class(recursive=False) - - class OuterCaller: - def __init__(self): - self.inner = InnerCaller() - - def call_method(self): - return self.inner.call_method() - - caller = OuterCaller() - result = caller.call_method() - self.assertEqual(result, "InnerCaller") - - -if __name__ == "__main__": - unittest.main() From 6acb92dcdb2e9d3fe57108a63263e58888a98d8e Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Sat, 12 Apr 2025 16:17:58 +0200 Subject: [PATCH 08/14] Fix formatting --- tests/test_analytics_http_client.py | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index e39816f..1798463 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -8,26 +8,28 @@ class TestAnalyticsHttpClient(unittest.TestCase): def setUp(self): self.module_name = "test_module" self.client = AnalyticsHttpClient(module_name=self.module_name) - + def test_init(self): """Test that the client initializes with the correct module name.""" self.assertEqual(self.client.module_name, self.module_name) self.assertIsInstance(self.client, httpx.Client) - - @patch('usage_analytics.analytics_http_client.super') + + @patch("usage_analytics.analytics_http_client.super") def test_request_adds_headers(self, mock_super): """Test that request method adds analytics headers.""" mock_request = MagicMock() mock_super.return_value.request = mock_request - + # Call request method - self.client.request("GET", "https://example.com", headers={"Existing": "Header"}) - + self.client.request( + "GET", "https://example.com", headers={"Existing": "Header"} + ) + # Check that super().request was called with modified headers args, kwargs = mock_request.call_args self.assertEqual(args[0], "GET") self.assertEqual(args[1], "https://example.com") - + # Verify headers were added headers = kwargs.get("headers", {}) self.assertEqual(headers.get("Existing"), "Header") @@ -35,29 +37,29 @@ def test_request_adds_headers(self, mock_super): self.assertIn("X-Unique-Call-Id", headers) self.assertIn("X-Python-Version", headers) self.assertIn("X-Calling-Class", headers) - - @patch('usage_analytics.analytics_http_client.super') + + @patch("usage_analytics.analytics_http_client.super") def test_stream_adds_headers(self, mock_super): """Test that stream method adds analytics headers.""" mock_stream = MagicMock() mock_super.return_value.stream = mock_stream - + # Call stream method self.client.stream("GET", "https://example.com", headers={"Existing": "Header"}) - + # Check that super().stream was called with modified headers args, kwargs = mock_stream.call_args self.assertEqual(args[0], "GET") self.assertEqual(args[1], "https://example.com") - + # Verify headers were added headers = kwargs.get("headers", {}) self.assertEqual(headers.get("Existing"), "Header") - + # Verify all analytics headers from ANALYTICS_TO_TRACK were added for header_name, _ in ANALYTICS_TO_TRACK: self.assertIn(header_name, headers) - + # Verify specific header values self.assertEqual(headers.get("X-Module-Name"), self.module_name) self.assertIsNotNone(headers.get("X-Unique-Call-Id")) From b42678cbdcdd5c03ca95bb7d8e0403590c6f4d84 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 15:10:04 +0200 Subject: [PATCH 09/14] Add fix according to comments --- usage_analytics/__init__.py | 4 +- usage_analytics/analytics_config.py | 23 ++++++++++ usage_analytics/analytics_definition.py | 13 ------ usage_analytics/analytics_func.py | 52 ++++------------------- usage_analytics/analytics_http_client.py | 54 ++++++++++++++++++------ 5 files changed, 74 insertions(+), 72 deletions(-) create mode 100644 usage_analytics/analytics_config.py delete mode 100644 usage_analytics/analytics_definition.py diff --git a/usage_analytics/__init__.py b/usage_analytics/__init__.py index 72b9d6a..8b07b3e 100644 --- a/usage_analytics/__init__.py +++ b/usage_analytics/__init__.py @@ -1,7 +1,7 @@ from .analytics_http_client import AnalyticsHttpClient -from .analytics_definition import ANALYTICS_TO_TRACK +from .analytics_config import ANALYTICS_HEADER_CONFIG __all__ = [ "AnalyticsHttpClient", - "ANALYTICS_TO_TRACK", + "ANALYTICS_HEADER_CONFIG", ] diff --git a/usage_analytics/analytics_config.py b/usage_analytics/analytics_config.py new file mode 100644 index 0000000..35e9dd0 --- /dev/null +++ b/usage_analytics/analytics_config.py @@ -0,0 +1,23 @@ +from enum import Enum +from typing import Callable, Optional, Tuple + +from .analytics_func import ( + get_calling_class, + get_python_version, + get_unique_call_id, +) + + +class ANALYTICS_KEYS(str, Enum): + UniqueCallID = "PL-Unique-Call-Id" + PythonVersion = "PL-Python-Version" + CallingClass = "PL-Calling-Class" + ModuleName = "PL-Module-Name" + + +ANALYTICS_HEADER_CONFIG: Tuple[Tuple[str, Optional[Callable]], ...] = ( + (ANALYTICS_KEYS.UniqueCallID, get_unique_call_id), + (ANALYTICS_KEYS.PythonVersion, get_python_version), + (ANALYTICS_KEYS.CallingClass, get_calling_class), + (ANALYTICS_KEYS.ModuleName, None), # Value provided by AnalyticsHttpClient +) diff --git a/usage_analytics/analytics_definition.py b/usage_analytics/analytics_definition.py deleted file mode 100644 index 0340751..0000000 --- a/usage_analytics/analytics_definition.py +++ /dev/null @@ -1,13 +0,0 @@ -from .analytics_func import ( - get_calling_class, - get_python_version, - get_unique_call_id, -) - - -ANALYTICS_TO_TRACK = [ - ("X-Unique-Call-Id", get_unique_call_id), - ("X-Python-Version", get_python_version), - ("X-Calling-Class", get_calling_class), - ("X-Module-Name", None), # Value provided by AnalyticsHttpClient -] diff --git a/usage_analytics/analytics_func.py b/usage_analytics/analytics_func.py index 13c8718..5aae484 100644 --- a/usage_analytics/analytics_func.py +++ b/usage_analytics/analytics_func.py @@ -1,12 +1,15 @@ -def get_calling_class(recursive=True): +import inspect +import platform +import uuid + + +def get_calling_class(recursive=True) -> str: """ Traverses the call stack to find and return the class name of the first caller that has a 'self' variable in its frame (i.e., a method call). If no such frame is found, returns 'StandaloneFunction'. """ - import inspect - # Skip the current frame and the immediate caller (i.e., start from index 1) stack = inspect.stack()[1:] @@ -22,51 +25,12 @@ def get_calling_class(recursive=True): if not recursive: break - # If no class context was found, assume it's a standalone function return outermost_caller -def get_python_version(): - import platform - +def get_python_version() -> str: return platform.python_version() -def get_unique_call_id(): - import uuid - +def get_unique_call_id() -> str: return str(uuid.uuid4()) - - -# Example usage in a class method: -class ExampleCaller: - def call_api(self): - # This function would call the API and use get_calling_class to report the caller - caller_class = get_calling_class() - print(f"Called from class: {caller_class}") - - -class NestedExampleCaller: - def __init__(self): - self.example_caller = ExampleCaller() - - def call_api(self): - self.example_caller.call_api() - - -def standalone_function(): - # This function doesn't belong to any class, so it should return "StandaloneFunction" - caller_class = get_calling_class() - print(f"Called from: {caller_class}") - - -if __name__ == "__main__": - # Test the get_calling_class function - example = ExampleCaller() - example.call_api() # Expected output: Called from class: ExampleCaller - nested_example = NestedExampleCaller() - nested_example.call_api() # Expected output: Called from class: NestedExampleCaller - standalone_function() # Expected output: Called from: StandaloneFunction - - # Test the get_python_ver function - print(f"Python version: {get_python_version()}") diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py index 30d90b7..0e5f25d 100644 --- a/usage_analytics/analytics_http_client.py +++ b/usage_analytics/analytics_http_client.py @@ -1,39 +1,67 @@ import httpx -from .analytics_definition import ANALYTICS_TO_TRACK +from typing import Callable + +from .analytics_config import ANALYTICS_HEADER_CONFIG, ANALYTICS_KEYS class AnalyticsHttpClient(httpx.Client): """ - Custom HTTP client that automatically adds the calling class to all requests. + Custom HTTP client that extends httpx.Client to automatically add analytics headers to all requests. """ - def __init__(self, module_name: str, *args, **kwargs): + def __init__( + self, + module_name: str, + analytics_config: tuple[tuple[str, Callable], ...] = ANALYTICS_HEADER_CONFIG, + *args, + **kwargs, + ): + """ + Initialize the AnalyticsHttpClient with analytics headers. + + Parameters + ---------- + module_name : str + The name of the module using this client, added to analytics headers. + analytics_config : tuple[tuple[str, Callable], ...], optional + Configuration for analytics headers, defaults to ANALYTICS_HEADER_CONFIG. + Each entry is a tuple of (header_name, get_value_func) where get_value_func + is a callable that returns the value for the header. + """ + super().__init__(*args, **kwargs) - self.module_name = module_name + self._module_name = module_name + self._analytics_config = analytics_config + + def set_module_name(self, module_name: str) -> None: + self._module_name = module_name def request(self, method, url, *args, **kwargs): headers = kwargs.get("headers", {}) if headers is None: headers = {} - kwargs["headers"] = self._add_analytics_headers(headers) + new_kwargs = {**kwargs, "headers": self._add_analytics_headers(headers)} - # Call the original request method - return super().request(method, url, *args, **kwargs) + return super().request(method, url, *args, **new_kwargs) def stream(self, method, url, *args, **kwargs): headers = kwargs.get("headers", {}) if headers is None: headers = {} - kwargs["headers"] = self._add_analytics_headers(headers) + new_kwargs = {**kwargs, "headers": self._add_analytics_headers(headers)} - return super().stream(method, url, *args, **kwargs) + return super().stream(method, url, *args, **new_kwargs) - def _add_analytics_headers(self, headers: dict): - for header_name, get_value_func in ANALYTICS_TO_TRACK: - if header_name == "X-Module-Name": - headers[header_name] = self.module_name + def _add_analytics_headers(self, headers: dict) -> dict: + for header_name, get_value_func in self._analytics_config: + if header_name == ANALYTICS_KEYS.ModuleName: + headers[header_name] = self._module_name elif get_value_func is not None: headers[header_name] = get_value_func() + else: + raise ValueError( + f"Invalid analytics config: header_name={header_name}, get_value_func={get_value_func}" + ) return headers From 2948f4146e4d955bfb5723b3dde4fe2b955e4e18 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 15:25:29 +0200 Subject: [PATCH 10/14] Fix test --- tests/test_analytics_http_client.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index 1798463..847f952 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -1,13 +1,15 @@ import unittest from unittest.mock import patch, MagicMock +import http import httpx -from usage_analytics import AnalyticsHttpClient, ANALYTICS_TO_TRACK +from usage_analytics import AnalyticsHttpClient, ANALYTICS_HEADER_CONFIG class TestAnalyticsHttpClient(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.module_name = "test_module" self.client = AnalyticsHttpClient(module_name=self.module_name) + self.local_host = "http://localhost:8000" def test_init(self): """Test that the client initializes with the correct module name.""" @@ -22,13 +24,13 @@ def test_request_adds_headers(self, mock_super): # Call request method self.client.request( - "GET", "https://example.com", headers={"Existing": "Header"} + http.RequestMethod.GET, self.local_host, headers={"Existing": "Header"} ) # Check that super().request was called with modified headers args, kwargs = mock_request.call_args - self.assertEqual(args[0], "GET") - self.assertEqual(args[1], "https://example.com") + self.assertEqual(args[0], http.RequestMethod.GET) + self.assertEqual(args[1], self.local_host) # Verify headers were added headers = kwargs.get("headers", {}) @@ -45,19 +47,21 @@ def test_stream_adds_headers(self, mock_super): mock_super.return_value.stream = mock_stream # Call stream method - self.client.stream("GET", "https://example.com", headers={"Existing": "Header"}) + self.client.stream( + http.RequestMethod.GET, self.local_host, headers={"Existing": "Header"} + ) # Check that super().stream was called with modified headers args, kwargs = mock_stream.call_args - self.assertEqual(args[0], "GET") - self.assertEqual(args[1], "https://example.com") + self.assertEqual(args[0], http.RequestMethod.GET) + self.assertEqual(args[1], self.local_host) # Verify headers were added headers = kwargs.get("headers", {}) self.assertEqual(headers.get("Existing"), "Header") # Verify all analytics headers from ANALYTICS_TO_TRACK were added - for header_name, _ in ANALYTICS_TO_TRACK: + for header_name, _ in ANALYTICS_HEADER_CONFIG: self.assertIn(header_name, headers) # Verify specific header values From 6c1dc0476daa287dfe379b6ed20191b219729e0e Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 15:25:58 +0200 Subject: [PATCH 11/14] Add annotation --- tests/test_analytics_http_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index 847f952..e0bf5c1 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -17,7 +17,7 @@ def test_init(self): self.assertIsInstance(self.client, httpx.Client) @patch("usage_analytics.analytics_http_client.super") - def test_request_adds_headers(self, mock_super): + def test_request_adds_headers(self, mock_super: MagicMock): """Test that request method adds analytics headers.""" mock_request = MagicMock() mock_super.return_value.request = mock_request @@ -41,7 +41,7 @@ def test_request_adds_headers(self, mock_super): self.assertIn("X-Calling-Class", headers) @patch("usage_analytics.analytics_http_client.super") - def test_stream_adds_headers(self, mock_super): + def test_stream_adds_headers(self, mock_super: MagicMock): """Test that stream method adds analytics headers.""" mock_stream = MagicMock() mock_super.return_value.stream = mock_stream From b47253952fe9c10c6ff8350331ad923674b9d1b4 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 15:39:05 +0200 Subject: [PATCH 12/14] Add more test --- tests/test_analytics_http_client.py | 98 +++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index e0bf5c1..9f6bc0d 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -23,33 +23,25 @@ def test_request_adds_headers(self, mock_super: MagicMock): mock_super.return_value.request = mock_request # Call request method - self.client.request( - http.RequestMethod.GET, self.local_host, headers={"Existing": "Header"} - ) + self.client.request("GET", self.local_host) # Check that super().request was called with modified headers args, kwargs = mock_request.call_args - self.assertEqual(args[0], http.RequestMethod.GET) + self.assertEqual(args[0], "GET") self.assertEqual(args[1], self.local_host) # Verify headers were added headers = kwargs.get("headers", {}) - self.assertEqual(headers.get("Existing"), "Header") - self.assertEqual(headers.get("X-Module-Name"), self.module_name) - self.assertIn("X-Unique-Call-Id", headers) - self.assertIn("X-Python-Version", headers) - self.assertIn("X-Calling-Class", headers) + self._validate_headers(headers) @patch("usage_analytics.analytics_http_client.super") - def test_stream_adds_headers(self, mock_super: MagicMock): + def test_stream_adds_headers(self, mock_super): """Test that stream method adds analytics headers.""" mock_stream = MagicMock() mock_super.return_value.stream = mock_stream # Call stream method - self.client.stream( - http.RequestMethod.GET, self.local_host, headers={"Existing": "Header"} - ) + self.client.stream("GET", self.local_host) # Check that super().stream was called with modified headers args, kwargs = mock_stream.call_args @@ -58,17 +50,83 @@ def test_stream_adds_headers(self, mock_super: MagicMock): # Verify headers were added headers = kwargs.get("headers", {}) - self.assertEqual(headers.get("Existing"), "Header") + self._validate_headers(headers) - # Verify all analytics headers from ANALYTICS_TO_TRACK were added + def _validate_headers(self, headers: dict) -> None: for header_name, _ in ANALYTICS_HEADER_CONFIG: self.assertIn(header_name, headers) + self.assertIsNotNone(headers.get(header_name)) + + @patch("usage_analytics.analytics_func.get_unique_call_id") + def test_unique_call_id_header(self, mock_get_unique_call_id): + """Test that the unique call ID is correctly added to headers.""" + mock_unique_id = "test-unique-id-123" + mock_get_unique_call_id.return_value = mock_unique_id + + with patch("usage_analytics.analytics_http_client.super") as mock_super: + mock_request = MagicMock() + mock_super.return_value.request = mock_request + + self.client.request("GET", self.local_host) + + _, kwargs = mock_request.call_args + headers = kwargs.get("headers", {}) + + self.assertEqual(headers.get("PL-Unique-Call-Id"), mock_unique_id) + mock_get_unique_call_id.assert_called_once() + + @patch("usage_analytics.analytics_func.get_python_version") + def test_python_version_header(self, mock_get_python_version): + """Test that the Python version is correctly added to headers.""" + mock_python_version = "3.9.0" + mock_get_python_version.return_value = mock_python_version + + with patch("usage_analytics.analytics_http_client.super") as mock_super: + mock_request = MagicMock() + mock_super.return_value.request = mock_request + + self.client.request("GET", self.local_host) + + _, kwargs = mock_request.call_args + headers = kwargs.get("headers", {}) + + self.assertEqual(headers.get("PL-Python-Version"), mock_python_version) + mock_get_python_version.assert_called_once() + + @patch("usage_analytics.analytics_func.get_calling_class") + def test_calling_class_header(self, mock_get_calling_class): + """Test that the calling class is correctly added to headers.""" + mock_calling_class = "TestClass" + mock_get_calling_class.return_value = mock_calling_class + + with patch("usage_analytics.analytics_http_client.super") as mock_super: + mock_request = MagicMock() + mock_super.return_value.request = mock_request + + self.client.request("GET", self.local_host) + + _, kwargs = mock_request.call_args + headers = kwargs.get("headers", {}) + + self.assertEqual(headers.get("PL-Calling-Class"), mock_calling_class) + mock_get_calling_class.assert_called_once() + + def test_set_module_name(self): + """Test that the module name can be updated.""" + new_module_name = "new_test_module" + self.client.set_module_name(new_module_name) + + with patch("usage_analytics.analytics_http_client.super") as mock_super: + mock_request = MagicMock() + mock_super.return_value.request = mock_request + + self.client.request("GET", self.local_host) + + _, kwargs = mock_request.call_args + headers = kwargs.get("headers", {}) - # Verify specific header values - self.assertEqual(headers.get("X-Module-Name"), self.module_name) - self.assertIsNotNone(headers.get("X-Unique-Call-Id")) - self.assertIsNotNone(headers.get("X-Python-Version")) - self.assertIsNotNone(headers.get("X-Calling-Class")) + self.assertEqual(headers.get("PL-Module-Name"), new_module_name) + self.assertEqual(self.client.module_name, new_module_name) if __name__ == "__main__": From da6392656d00f3136b56e5574686d82357f0b0d0 Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 15:45:39 +0200 Subject: [PATCH 13/14] Add fix --- requirements.txt | 2 +- tests/test_analytics_http_client.py | 9 ++++----- usage_analytics/analytics_http_client.py | 4 ++++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index f3b73c1..971b115 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ numpy>=2.0.0 pandas>=2.2.3 scikit-learn>=1.6.1 typing_extensions>=4.12.2 -httpx>=0.28.1 \ No newline at end of file +httpx~=0.28.1 \ No newline at end of file diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index 9f6bc0d..4db5ef2 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -1,6 +1,5 @@ import unittest from unittest.mock import patch, MagicMock -import http import httpx from usage_analytics import AnalyticsHttpClient, ANALYTICS_HEADER_CONFIG @@ -45,7 +44,7 @@ def test_stream_adds_headers(self, mock_super): # Check that super().stream was called with modified headers args, kwargs = mock_stream.call_args - self.assertEqual(args[0], http.RequestMethod.GET) + self.assertEqual(args[0], "GET") self.assertEqual(args[1], self.local_host) # Verify headers were added @@ -58,7 +57,7 @@ def _validate_headers(self, headers: dict) -> None: self.assertIsNotNone(headers.get(header_name)) @patch("usage_analytics.analytics_func.get_unique_call_id") - def test_unique_call_id_header(self, mock_get_unique_call_id): + def test_unique_call_id_header(self, mock_get_unique_call_id: MagicMock): """Test that the unique call ID is correctly added to headers.""" mock_unique_id = "test-unique-id-123" mock_get_unique_call_id.return_value = mock_unique_id @@ -76,7 +75,7 @@ def test_unique_call_id_header(self, mock_get_unique_call_id): mock_get_unique_call_id.assert_called_once() @patch("usage_analytics.analytics_func.get_python_version") - def test_python_version_header(self, mock_get_python_version): + def test_python_version_header(self, mock_get_python_version: MagicMock): """Test that the Python version is correctly added to headers.""" mock_python_version = "3.9.0" mock_get_python_version.return_value = mock_python_version @@ -94,7 +93,7 @@ def test_python_version_header(self, mock_get_python_version): mock_get_python_version.assert_called_once() @patch("usage_analytics.analytics_func.get_calling_class") - def test_calling_class_header(self, mock_get_calling_class): + def test_calling_class_header(self, mock_get_calling_class: MagicMock): """Test that the calling class is correctly added to headers.""" mock_calling_class = "TestClass" mock_get_calling_class.return_value = mock_calling_class diff --git a/usage_analytics/analytics_http_client.py b/usage_analytics/analytics_http_client.py index 0e5f25d..98b99e2 100644 --- a/usage_analytics/analytics_http_client.py +++ b/usage_analytics/analytics_http_client.py @@ -33,6 +33,10 @@ def __init__( self._module_name = module_name self._analytics_config = analytics_config + @property + def module_name(self) -> str: + return self._module_name + def set_module_name(self, module_name: str) -> None: self._module_name = module_name From c2f352aeb393877dec9cdb73c9f4c9fe9f3e466f Mon Sep 17 00:00:00 2001 From: "Liam, SB Hoo" Date: Thu, 17 Apr 2025 16:25:27 +0200 Subject: [PATCH 14/14] Fix test --- tests/test_analytics_http_client.py | 54 ----------------------------- 1 file changed, 54 deletions(-) diff --git a/tests/test_analytics_http_client.py b/tests/test_analytics_http_client.py index 4db5ef2..2b3aac7 100644 --- a/tests/test_analytics_http_client.py +++ b/tests/test_analytics_http_client.py @@ -56,60 +56,6 @@ def _validate_headers(self, headers: dict) -> None: self.assertIn(header_name, headers) self.assertIsNotNone(headers.get(header_name)) - @patch("usage_analytics.analytics_func.get_unique_call_id") - def test_unique_call_id_header(self, mock_get_unique_call_id: MagicMock): - """Test that the unique call ID is correctly added to headers.""" - mock_unique_id = "test-unique-id-123" - mock_get_unique_call_id.return_value = mock_unique_id - - with patch("usage_analytics.analytics_http_client.super") as mock_super: - mock_request = MagicMock() - mock_super.return_value.request = mock_request - - self.client.request("GET", self.local_host) - - _, kwargs = mock_request.call_args - headers = kwargs.get("headers", {}) - - self.assertEqual(headers.get("PL-Unique-Call-Id"), mock_unique_id) - mock_get_unique_call_id.assert_called_once() - - @patch("usage_analytics.analytics_func.get_python_version") - def test_python_version_header(self, mock_get_python_version: MagicMock): - """Test that the Python version is correctly added to headers.""" - mock_python_version = "3.9.0" - mock_get_python_version.return_value = mock_python_version - - with patch("usage_analytics.analytics_http_client.super") as mock_super: - mock_request = MagicMock() - mock_super.return_value.request = mock_request - - self.client.request("GET", self.local_host) - - _, kwargs = mock_request.call_args - headers = kwargs.get("headers", {}) - - self.assertEqual(headers.get("PL-Python-Version"), mock_python_version) - mock_get_python_version.assert_called_once() - - @patch("usage_analytics.analytics_func.get_calling_class") - def test_calling_class_header(self, mock_get_calling_class: MagicMock): - """Test that the calling class is correctly added to headers.""" - mock_calling_class = "TestClass" - mock_get_calling_class.return_value = mock_calling_class - - with patch("usage_analytics.analytics_http_client.super") as mock_super: - mock_request = MagicMock() - mock_super.return_value.request = mock_request - - self.client.request("GET", self.local_host) - - _, kwargs = mock_request.call_args - headers = kwargs.get("headers", {}) - - self.assertEqual(headers.get("PL-Calling-Class"), mock_calling_class) - mock_get_calling_class.assert_called_once() - def test_set_module_name(self): """Test that the module name can be updated.""" new_module_name = "new_test_module"