From 58c63efac9a8a025f29b455238d3144ecb0dbd7a Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 10 Dec 2024 16:22:18 -0800 Subject: [PATCH] initial commit --- CHANGELOG.md | 4 +++ README.md | 2 +- posthog/__init__.py | 44 ++++++++++++++++++++++++- posthog/client.py | 65 +++++++++++++++++++++++++++++++++++++ posthog/factory.py | 20 ++++++++++++ posthog/test/test_client.py | 58 +++++++++++++++++++++++++++++++++ posthog/version.py | 2 +- 7 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 posthog/factory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 741010d3..a8dcfa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.7.5 - 2024-12-11 + +1. Modify the SDK to use a Singleton factory pattern; warn on non-singleton instantiation. + ## 3.7.4 - 2024-11-25 1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`. diff --git a/README.md b/README.md index b1656fa1..74945c56 100644 --- a/README.md +++ b/README.md @@ -43,4 +43,4 @@ Updated are released using GitHub Actions: after bumping `version.py` in `master ## Questions? -### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) +### [Check out our community page.](https://posthog.com/posts) diff --git a/posthog/__init__.py b/posthog/__init__.py index fa56e8b8..45d2fd25 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -1,13 +1,48 @@ +""" +PostHog Python SDK - Main module for interacting with PostHog analytics. + +This module provides the main interface for sending analytics data to PostHog. +It includes functions for tracking events, identifying users, managing feature flags, +and handling group analytics. + +Basic usage: + import posthog + + # Configure the client + posthog.api_key = 'your_api_key' + + # Track an event + posthog.capture('distinct_id', 'event_name') +""" import datetime # noqa: F401 from typing import Callable, Dict, List, Optional, Tuple # noqa: F401 from posthog.client import Client from posthog.exception_capture import Integrations # noqa: F401 from posthog.version import VERSION +from posthog.factory import PostHogFactory __version__ = VERSION -"""Settings.""" +"""Settings. +These settings control the behavior of the PostHog client: + +api_key: Your PostHog API key +host: PostHog server URL (defaults to Cloud instance) +debug: Enable debug logging +send: Enable/disable sending events to PostHog +sync_mode: Run in synchronous mode instead of async +disabled: Completely disable the client +personal_api_key: Personal API key for feature flag evaluation +project_api_key: Project API key for direct feature flag access +poll_interval: Interval in seconds between feature flag updates +disable_geoip: Disable IP geolocation (recommended for server-side) +feature_flags_request_timeout_seconds: Timeout for feature flag requests +super_properties: Properties to be added to every event +enable_exception_autocapture: (Alpha) Enable automatic exception capturing +exception_autocapture_integrations: List of exception capture integrations +project_root: Root directory for exception source mapping +""" api_key = None # type: Optional[str] host = None # type: Optional[str] on_error = None # type: Optional[Callable] @@ -521,5 +556,12 @@ def _proxy(method, *args, **kwargs): return fn(*args, **kwargs) +# For backwards compatibility with older versions of the SDK. +# This class is deprecated and will be removed in a future version. class Posthog(Client): pass + +# The recommended way to create and manage PostHog client instances. +# These factory methods ensure proper singleton management and configuration. +create_posthog_client = PostHogFactory.create # Create a new PostHog client instance +get_posthog_client = PostHogFactory.get_instance # Get the existing client instance diff --git a/posthog/client.py b/posthog/client.py index b12764c8..27da9760 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -30,6 +30,23 @@ class Client(object): """Create a new PostHog client.""" + _instance = None + _enforce_singleton = True # Can be disabled for testing + + def __new__(cls, *args, **kwargs): + if cls._enforce_singleton: + if not cls._instance: + cls._instance = super(Client, cls).__new__(cls) + # Move initialization flag to __new__ since it needs to exist + # before __init__ is called + cls._instance._initialized = False + return cls._instance + # For non-singleton case (tests), still need to set _initialized + instance = super(Client, cls).__new__(cls) + instance._initialized = False + return instance + + log = logging.getLogger("posthog") @@ -60,6 +77,11 @@ def __init__( exception_autocapture_integrations=None, project_root=None, ): + if self._initialized: + self._warn_multiple_initialization() + return + + self._initialized = True self.queue = queue.Queue(max_queue_size) # api_key: This should be the Team API Key (token), public @@ -925,6 +947,49 @@ def _add_local_person_and_group_properties(self, distinct_id, groups, person_pro return all_person_properties, all_group_properties + def _warn_multiple_initialization(self): + self.log.warning( + "Warning: Attempting to create multiple PostHog client instances. " + "PostHog client should be used as a singleton. " + "The existing instance will be reused instead of creating a new one. " + "Consider using PostHog.get_instance() to access the client." + ) + + + @classmethod + def get_instance(cls): + """ + Get the singleton instance of the PostHog client. + + This method returns the existing PostHog client instance that was previously + initialized. It ensures only one client instance exists throughout your application. + + Returns: + Client: The singleton PostHog client instance + + Raises: + RuntimeError: If no PostHog client has been initialized yet + + Example: + ```python + # First, initialize the client + posthog.create_posthog_client('api_key', host='https://app.posthog.com') + + # Later, get the same instance + client = posthog.get_posthog_client() + client.capture('user_id', 'event_name') + ``` + + Note: + Make sure to initialize a client with `create_posthog_client()` or + `Client(api_key, ...)` before calling this method. + """ + if not cls._instance: + raise RuntimeError( + "PostHog client has not been initialized. " + "Please create an instance with Client(api_key, ...) first." + ) + return cls._instance def require(name, field, data_type): """Require that the named `field` has the right `data_type`""" diff --git a/posthog/factory.py b/posthog/factory.py new file mode 100644 index 00000000..7cb2ce53 --- /dev/null +++ b/posthog/factory.py @@ -0,0 +1,20 @@ +from posthog.client import Client + +class PostHogFactory: + @staticmethod + def create( + api_key=None, + host=None, + **kwargs + ): + """ + Create a new PostHog client instance or return the existing one. + """ + return Client(api_key=api_key, host=host, **kwargs) + + @staticmethod + def get_instance(): + """ + Get the existing PostHog client instance. + """ + return Client.get_instance() diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 6bdb7388..5484825c 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -33,6 +33,7 @@ def set_fail(self, e, batch): def setUp(self): self.failed = False self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail) + Client._enforce_singleton = False # Disable singleton for tests def test_requires_api_key(self): self.assertRaises(AssertionError, Client) @@ -159,6 +160,7 @@ def test_basic_capture_exception_with_correct_host_generation(self): with mock.patch.object(Client, "capture", return_value=None) as patch_capture: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com") + print(client.host) exception = Exception("test exception") client.capture_exception(exception, "distinct_id") @@ -187,6 +189,7 @@ def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(s with mock.patch.object(Client, "capture", return_value=None) as patch_capture: client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com") + print(client.host) exception = Exception("test exception") client.capture_exception(exception, "distinct_id") @@ -1073,3 +1076,58 @@ def test_default_properties_get_added_properly(self, patch_decide): group_properties={}, disable_geoip=False, ) + + def test_singleton_behavior(self): + # Reset singleton state + Client._instance = None + Client._enforce_singleton = True + + # Create first instance + client1 = Client(FAKE_TEST_API_KEY, host="https://host1.com") + + # Create second instance with different params + client2 = Client(FAKE_TEST_API_KEY, host="https://host2.com") + + # Both should reference the same instance + self.assertIs(client1, client2) + + # Host should be from first initialization + self.assertEqual(client1.host, "https://host1.com") + self.assertEqual(client2.host, "https://host1.com") + + def test_singleton_disabled_for_testing(self): + # Reset singleton state + Client._instance = None + Client._enforce_singleton = False + + # Create instances with different params + client1 = Client(FAKE_TEST_API_KEY, host="https://host1.com") + client2 = Client(FAKE_TEST_API_KEY, host="https://host2.com") + + # Should be different instances + self.assertIsNot(client1, client2) + + # Each should maintain their own host + self.assertEqual(client1.host, "https://host1.com") + self.assertEqual(client2.host, "https://host2.com") + + def test_singleton_warning_on_multiple_initialization(self): + # Reset singleton state + Client._instance = None + Client._enforce_singleton = True + + # Create first instance + client1 = Client(FAKE_TEST_API_KEY) + + # Second initialization should log warning + with self.assertLogs("posthog", level="WARNING") as logs: + client2 = Client(FAKE_TEST_API_KEY) + self.assertEqual( + logs.output[0], + "WARNING:posthog:Warning: Attempting to create multiple PostHog client instances. " + "PostHog client should be used as a singleton. " + "The existing instance will be reused instead of creating a new one. " + "Consider using PostHog.get_instance() to access the client." + ) + + diff --git a/posthog/version.py b/posthog/version.py index 14899e15..fd1751ee 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "3.7.4" +VERSION = "3.7.5" if __name__ == "__main__": print(VERSION, end="") # noqa: T201