From cd461ac31be59551b0f749af65bd6232d2525921 Mon Sep 17 00:00:00 2001 From: James O'Claire Date: Mon, 25 May 2026 09:30:47 +0800 Subject: [PATCH 1/3] Added distinct_id to new_page_view and new_event along with async fucntions --- README.md | 2 + change-log.md | 1 + umami/README.md | 2 + umami/tests/test_revenue.py | 177 ++++++++++----- umami/umami/impl/__init__.py | 404 +++++++++++++++++++---------------- 5 files changed, 349 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 1c6d7c4..afe6e5a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ event_resp = umami.new_event( hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', custom_data={'client': 'umami-tester-v1'}, + distinct_id='user-123', referrer='https://some_url') # Create a new page view in the pages section of the dashboards. @@ -104,6 +105,7 @@ page_view_resp = umami.new_page_view( page_title='Umami-Test', # Defaults to event_name if omitted. hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', + distinct_id='user-123', referrer='https://some_url') # Track revenue for a transaction diff --git a/change-log.md b/change-log.md index 1c1322f..17a1dd1 100644 --- a/change-log.md +++ b/change-log.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `distinct_id` support in `new_event` and `new_page_view` payloads, sent to Umami as payload field `id` in both sync and async variants ### Changed diff --git a/umami/README.md b/umami/README.md index 766ca93..5f82145 100644 --- a/umami/README.md +++ b/umami/README.md @@ -96,6 +96,7 @@ event_resp = umami.new_event( hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', custom_data={'client': 'umami-tester-v1'}, + distinct_id='user-123', referrer='https://some_url') # Create a new page view in the pages section of the dashboards. @@ -104,6 +105,7 @@ page_view_resp = umami.new_page_view( page_title='Umami-Test', # Defaults to event_name if omitted. hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', + distinct_id='user-123', referrer='https://some_url') # Track revenue for a transaction diff --git a/umami/tests/test_revenue.py b/umami/tests/test_revenue.py index 24acd7a..6d2cf10 100644 --- a/umami/tests/test_revenue.py +++ b/umami/tests/test_revenue.py @@ -9,9 +9,9 @@ @pytest.fixture(autouse=True) def _setup_umami(): """Set up default umami state for all tests.""" - umami.set_url_base('https://example.com') - umami.set_hostname('test.com') - umami.set_website_id('test-website-id') + umami.set_url_base("https://example.com") + umami.set_hostname("test.com") + umami.set_website_id("test-website-id") umami.enable() yield @@ -19,7 +19,33 @@ def _setup_umami(): class TestNewRevenueEvent: """Tests for the sync new_revenue_event function.""" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") + def test_new_event_includes_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_event(event_name="signup", distinct_id="user-123") + + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "user-123" + + @patch("umami.impl.httpx.post") + def test_new_page_view_includes_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_page_view( + page_title="Account", url="/account", distinct_id="user-456" + ) + + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "user-456" + + @patch("umami.impl.httpx.post") def test_default_revenue_event(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -29,51 +55,53 @@ def test_default_revenue_event(self, mock_post): umami.new_revenue_event(revenue=19.99) call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs['json']['payload'] - assert payload['name'] == 'revenue' - assert payload['data']['revenue'] == 19.99 - assert payload['data']['currency'] == 'USD' + payload = call_kwargs.kwargs["json"]["payload"] + assert payload["name"] == "revenue" + assert payload["data"]["revenue"] == 19.99 + assert payload["data"]["currency"] == "USD" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_custom_currency(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event(revenue=49.00, currency='EUR') + umami.new_revenue_event(revenue=49.00, currency="EUR") - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['data']['currency'] == 'EUR' + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["data"]["currency"] == "EUR" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_custom_event_name(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event(revenue=10.00, event_name='checkout-cart') + umami.new_revenue_event(revenue=10.00, event_name="checkout-cart") - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['name'] == 'checkout-cart' + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["name"] == "checkout-cart" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_additional_custom_data_preserved(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event(revenue=25.00, custom_data={'product': 'widget', 'quantity': 2}) + umami.new_revenue_event( + revenue=25.00, custom_data={"product": "widget", "quantity": 2} + ) - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['data']['product'] == 'widget' - assert payload['data']['quantity'] == 2 - assert payload['data']['revenue'] == 25.00 - assert payload['data']['currency'] == 'USD' + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["data"]["product"] == "widget" + assert payload["data"]["quantity"] == 2 + assert payload["data"]["revenue"] == 25.00 + assert payload["data"]["currency"] == "USD" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_revenue_currency_override_custom_data(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -82,15 +110,18 @@ def test_revenue_currency_override_custom_data(self, mock_post): umami.new_revenue_event( revenue=30.00, - currency='GBP', - custom_data={'revenue': 'should_be_overridden', 'currency': 'should_be_overridden'}, + currency="GBP", + custom_data={ + "revenue": "should_be_overridden", + "currency": "should_be_overridden", + }, ) - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['data']['revenue'] == 30.00 - assert payload['data']['currency'] == 'GBP' + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["data"]["revenue"] == 30.00 + assert payload["data"]["currency"] == "GBP" - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_zero_revenue_allowed(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -99,20 +130,20 @@ def test_zero_revenue_allowed(self, mock_post): umami.new_revenue_event(revenue=0) - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['data']['revenue'] == 0 + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["data"]["revenue"] == 0 def test_negative_revenue_raises(self): - with pytest.raises(ValidationError, match='must be >= 0'): + with pytest.raises(ValidationError, match="must be >= 0"): umami.new_revenue_event(revenue=-5.00) def test_non_numeric_revenue_raises(self): - with pytest.raises(ValidationError, match='must be a number'): - umami.new_revenue_event(revenue='not_a_number') # type: ignore + with pytest.raises(ValidationError, match="must be a number"): + umami.new_revenue_event(revenue="not_a_number") # type: ignore def test_empty_currency_raises(self): - with pytest.raises(ValidationError, match='non-empty string'): - umami.new_revenue_event(revenue=10.00, currency='') + with pytest.raises(ValidationError, match="non-empty string"): + umami.new_revenue_event(revenue=10.00, currency="") def test_tracking_disabled_returns_early(self): umami.disable() @@ -120,7 +151,7 @@ def test_tracking_disabled_returns_early(self): # Should return without making any HTTP call assert result is None - @patch('umami.impl.httpx.post') + @patch("umami.impl.httpx.post") def test_integer_revenue(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -129,13 +160,49 @@ def test_integer_revenue(self, mock_post): umami.new_revenue_event(revenue=100) - payload = mock_post.call_args.kwargs['json']['payload'] - assert payload['data']['revenue'] == 100 + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["data"]["revenue"] == 100 class TestNewRevenueEventAsync: """Tests for the async new_revenue_event_async function.""" + @pytest.mark.asyncio + async def test_new_event_async_includes_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): + await umami.new_event_async(event_name="signup", distinct_id="user-123") + + payload = mock_client.post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "user-123" + + @pytest.mark.asyncio + async def test_new_page_view_async_includes_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): + await umami.new_page_view_async( + page_title="Account", url="/account", distinct_id="user-456" + ) + + payload = mock_client.post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "user-456" + @pytest.mark.asyncio async def test_default_revenue_event_async(self): mock_resp = MagicMock() @@ -147,14 +214,14 @@ async def test_default_revenue_event_async(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): result = await umami.new_revenue_event_async(revenue=19.99) call_kwargs = mock_client.post.call_args - payload = call_kwargs.kwargs['json']['payload'] - assert payload['name'] == 'revenue' - assert payload['data']['revenue'] == 19.99 - assert payload['data']['currency'] == 'USD' + payload = call_kwargs.kwargs["json"]["payload"] + assert payload["name"] == "revenue" + assert payload["data"]["revenue"] == 19.99 + assert payload["data"]["currency"] == "USD" assert result == {} @pytest.mark.asyncio @@ -168,19 +235,19 @@ async def test_custom_params_async(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): await umami.new_revenue_event_async( revenue=49.00, - currency='EUR', - event_name='checkout-cart', - url='/checkout', + currency="EUR", + event_name="checkout-cart", + url="/checkout", ) - payload = mock_client.post.call_args.kwargs['json']['payload'] - assert payload['name'] == 'checkout-cart' - assert payload['data']['revenue'] == 49.00 - assert payload['data']['currency'] == 'EUR' - assert payload['url'] == '/checkout' + payload = mock_client.post.call_args.kwargs["json"]["payload"] + assert payload["name"] == "checkout-cart" + assert payload["data"]["revenue"] == 49.00 + assert payload["data"]["currency"] == "EUR" + assert payload["url"] == "/checkout" @pytest.mark.asyncio async def test_tracking_disabled_async(self): @@ -190,5 +257,5 @@ async def test_tracking_disabled_async(self): @pytest.mark.asyncio async def test_negative_revenue_raises_async(self): - with pytest.raises(ValidationError, match='must be >= 0'): + with pytest.raises(ValidationError, match="must be >= 0"): await umami.new_revenue_event_async(revenue=-5.00) diff --git a/umami/umami/impl/__init__.py b/umami/umami/impl/__init__.py index 0193f24..8a93b33 100644 --- a/umami/umami/impl/__init__.py +++ b/umami/umami/impl/__init__.py @@ -10,9 +10,9 @@ try: from importlib.metadata import version - __version__ = version('umami-analytics') + __version__ = version("umami-analytics") except Exception: - __version__ = '0.0.0' # Fallback for development environments + __version__ = "0.0.0" # Fallback for development environments url_base: Optional[str] = None @@ -24,13 +24,13 @@ # You can also set DISABLE_BOT_CHECK=true in your Umami environment to disable the bot check entirely: # https://github.com/umami-software/umami/blob/7a3443cd06772f3cde37bdbb0bf38eabf4515561/pages/api/collect.js#L13 event_user_agent = ( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' - 'Chrome/142.0.0.0 Safari/537.36' + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/142.0.0.0 Safari/537.36" ) user_agent = ( - f'Umami-Client v{__version__} / ' - f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' - f'{sys.platform.capitalize()}' + f"Umami-Client v{__version__} / " + f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / " + f"{sys.platform.capitalize()}" ) @@ -42,15 +42,17 @@ def set_url_base(url: str) -> None: url: The base URL of your instance without /api. """ if not url or not url.strip(): - raise ValidationError('URL must not be empty.') + raise ValidationError("URL must not be empty.") # noinspection HttpUrlsUsage - if not url.startswith('http://') and not url.startswith('https://'): + if not url.startswith("http://") and not url.startswith("https://"): # noinspection HttpUrlsUsage - raise ValidationError('The url must start with the HTTP scheme (http:// or https://).') + raise ValidationError( + "The url must start with the HTTP scheme (http:// or https://)." + ) - if url.endswith('/'): - url = url.rstrip('/') + if url.endswith("/"): + url = url.rstrip("/") global url_base url_base = url.strip() @@ -95,14 +97,16 @@ async def login_async(username: str, password: str) -> models.LoginResponse: validate_state(url=True) validate_login(username, password) - url = f'{url_base}{urls.login}' - headers = {'User-Agent': user_agent} + url = f"{url_base}{urls.login}" + headers = {"User-Agent": user_agent} api_data = { - 'username': username, - 'password': password, + "username": username, + "password": password, } async with httpx.AsyncClient() as client: - resp = await client.post(url, json=api_data, headers=headers, follow_redirects=True) + resp = await client.post( + url, json=api_data, headers=headers, follow_redirects=True + ) resp.raise_for_status() model = models.LoginResponse(**resp.json()) @@ -125,11 +129,11 @@ def login(username: str, password: str) -> models.LoginResponse: validate_state(url=True) validate_login(username, password) - url = f'{url_base}{urls.login}' - headers = {'User-Agent': user_agent} + url = f"{url_base}{urls.login}" + headers = {"User-Agent": user_agent} api_data = { - 'username': username, - 'password': password, + "username": username, + "password": password, } resp = httpx.post(url, json=api_data, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -147,10 +151,10 @@ async def websites_async() -> list[models.Website]: global auth_token validate_state(url=True, user=True) - url = f'{url_base}{urls.websites}' + url = f"{url_base}{urls.websites}" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } async with httpx.AsyncClient() as client: # type: ignore @@ -169,10 +173,10 @@ def websites() -> list[models.Website]: global auth_token validate_state(url=True, user=True) - url = f'{url_base}{urls.websites}' + url = f"{url_base}{urls.websites}" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } resp = httpx.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -208,14 +212,15 @@ def disable() -> None: async def new_event_async( event_name: str, hostname: Optional[str] = None, - url: str = '/', + url: str = "/", website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ip_address: Optional[str] = None, + distinct_id: Optional[str] = None, ) -> dict: """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -234,6 +239,7 @@ async def new_event_async( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. Returns: The data returned from the Umami API. """ # noqa @@ -249,31 +255,36 @@ async def new_event_async( if not tracking_enabled: return {} - api_url = f'{url_base}{urls.events}' + api_url = f"{url_base}{urls.events}" headers = { - 'User-Agent': event_user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": event_user_agent, + "Authorization": f"Bearer {auth_token}", } payload = { - 'hostname': hostname, - 'language': language, - 'referrer': referrer, - 'screen': screen, - 'title': title, - 'url': url, - 'website': website_id, - 'name': event_name, - 'data': custom_data, + "hostname": hostname, + "language": language, + "referrer": referrer, + "screen": screen, + "title": title, + "url": url, + "website": website_id, + "name": event_name, + "data": custom_data, } if ip_address and ip_address.strip(): - payload['ip'] = ip_address + payload["ip"] = ip_address - event_data = {'payload': payload, 'type': 'event'} + if distinct_id and distinct_id.strip(): + payload["id"] = distinct_id + + event_data = {"payload": payload, "type": "event"} async with httpx.AsyncClient() as client: - resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) + resp = await client.post( + api_url, json=event_data, headers=headers, follow_redirects=True + ) resp.raise_for_status() return resp.json() @@ -282,14 +293,15 @@ async def new_event_async( def new_event( event_name: str, hostname: Optional[str] = None, - url: str = '/event-api-endpoint', + url: str = "/event-api-endpoint", website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ip_address: Optional[str] = None, + distinct_id: Optional[str] = None, ): """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -308,6 +320,7 @@ def new_event( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id @@ -321,28 +334,31 @@ def new_event( if not tracking_enabled: return - api_url = f'{url_base}{urls.events}' + api_url = f"{url_base}{urls.events}" headers = { - 'User-Agent': event_user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": event_user_agent, + "Authorization": f"Bearer {auth_token}", } payload = { - 'hostname': hostname, - 'language': language, - 'referrer': referrer, - 'screen': screen, - 'title': title, - 'url': url, - 'website': website_id, - 'name': event_name, - 'data': custom_data, + "hostname": hostname, + "language": language, + "referrer": referrer, + "screen": screen, + "title": title, + "url": url, + "website": website_id, + "name": event_name, + "data": custom_data, } if ip_address and ip_address.strip(): - payload['ip'] = ip_address + payload["ip"] = ip_address + + if distinct_id and distinct_id.strip(): + payload["id"] = distinct_id - event_data = {'payload': payload, 'type': 'event'} + event_data = {"payload": payload, "type": "event"} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -350,16 +366,16 @@ def new_event( async def new_revenue_event_async( revenue: float, - currency: str = 'USD', - event_name: str = 'revenue', + currency: str = "USD", + event_name: str = "revenue", hostname: Optional[str] = None, - url: str = '/', + url: str = "/", website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ip_address: Optional[str] = None, ) -> dict: """ @@ -383,15 +399,15 @@ async def new_revenue_event_async( Returns: The data returned from the Umami API. """ # noqa if not isinstance(revenue, (int, float)): - raise ValidationError('Revenue must be a number (int or float).') + raise ValidationError("Revenue must be a number (int or float).") if revenue < 0: - raise ValidationError('Revenue must be >= 0.') + raise ValidationError("Revenue must be >= 0.") if not currency or not currency.strip(): - raise ValidationError('Currency must be a non-empty string.') + raise ValidationError("Currency must be a non-empty string.") merged_data = dict(custom_data or {}) - merged_data['revenue'] = revenue - merged_data['currency'] = currency + merged_data["revenue"] = revenue + merged_data["currency"] = currency return await new_event_async( event_name=event_name, @@ -409,16 +425,16 @@ async def new_revenue_event_async( def new_revenue_event( revenue: float, - currency: str = 'USD', - event_name: str = 'revenue', + currency: str = "USD", + event_name: str = "revenue", hostname: Optional[str] = None, - url: str = '/event-api-endpoint', + url: str = "/event-api-endpoint", website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ip_address: Optional[str] = None, ): """ @@ -440,15 +456,15 @@ def new_revenue_event( ip_address: OPTIONAL: The true IP address of the user. """ # noqa if not isinstance(revenue, (int, float)): - raise ValidationError('Revenue must be a number (int or float).') + raise ValidationError("Revenue must be a number (int or float).") if revenue < 0: - raise ValidationError('Revenue must be >= 0.') + raise ValidationError("Revenue must be >= 0.") if not currency or not currency.strip(): - raise ValidationError('Currency must be a non-empty string.') + raise ValidationError("Currency must be a non-empty string.") merged_data = dict(custom_data or {}) - merged_data['revenue'] = revenue - merged_data['currency'] = currency + merged_data["revenue"] = revenue + merged_data["currency"] = currency return new_event( event_name=event_name, @@ -469,11 +485,12 @@ async def new_page_view_async( url: str, hostname: Optional[str] = None, website_id: Optional[str] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ua: str = event_user_agent, ip_address: Optional[str] = None, + distinct_id: Optional[str] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -490,40 +507,48 @@ async def new_page_view_async( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname - validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) + validate_event_data( + event_name="NOT NEEDED", hostname=hostname, website_id=website_id + ) # Early return if tracking is disabled if not tracking_enabled: return - api_url = f'{url_base}{urls.events}' + api_url = f"{url_base}{urls.events}" headers = { - 'User-Agent': ua, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": ua, + "Authorization": f"Bearer {auth_token}", } payload = { - 'hostname': hostname, - 'language': language, - 'referrer': referrer, - 'screen': screen, - 'title': page_title, - 'url': url, - 'website': website_id, + "hostname": hostname, + "language": language, + "referrer": referrer, + "screen": screen, + "title": page_title, + "url": url, + "website": website_id, } if ip_address and ip_address.strip(): - payload['ip'] = ip_address + payload["ip"] = ip_address + + if distinct_id and distinct_id.strip(): + payload["id"] = distinct_id - event_data = {'payload': payload, 'type': 'event'} + event_data = {"payload": payload, "type": "event"} async with httpx.AsyncClient() as client: - resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) + resp = await client.post( + api_url, json=event_data, headers=headers, follow_redirects=True + ) resp.raise_for_status() @@ -532,11 +557,12 @@ def new_page_view( url: str, hostname: Optional[str] = None, website_id: Optional[str] = None, - referrer: str = '', - language: str = 'en-US', - screen: str = '1920x1080', + referrer: str = "", + language: str = "en-US", + screen: str = "1920x1080", ua: str = event_user_agent, ip_address: Optional[str] = None, + distinct_id: Optional[str] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -553,52 +579,64 @@ def new_page_view( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname - validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) + validate_event_data( + event_name="NOT NEEDED", hostname=hostname, website_id=website_id + ) # Early return if tracking is disabled if not tracking_enabled: return - api_url = f'{url_base}{urls.events}' + api_url = f"{url_base}{urls.events}" headers = { - 'User-Agent': ua, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": ua, + "Authorization": f"Bearer {auth_token}", } payload = { - 'hostname': hostname, - 'language': language, - 'referrer': referrer, - 'screen': screen, - 'title': page_title, - 'url': url, - 'website': website_id, + "hostname": hostname, + "language": language, + "referrer": referrer, + "screen": screen, + "title": page_title, + "url": url, + "website": website_id, } if ip_address and ip_address.strip(): - payload['ip'] = ip_address + payload["ip"] = ip_address + + if distinct_id and distinct_id.strip(): + payload["id"] = distinct_id - event_data = {'payload': payload, 'type': 'event'} + event_data = {"payload": payload, "type": "event"} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() -def validate_event_data(event_name: str, hostname: Optional[str], website_id: Optional[str]): +def validate_event_data( + event_name: str, hostname: Optional[str], website_id: Optional[str] +): """ Internal use only. """ if not hostname: - raise Exception('The hostname must be set, either as a parameter here or via set_hostname().') + raise Exception( + "The hostname must be set, either as a parameter here or via set_hostname()." + ) if not website_id: - raise Exception('The website_id must be set, either as a parameter here or via set_website_id().') + raise Exception( + "The website_id must be set, either as a parameter here or via set_website_id()." + ) if not event_name and not event_name.strip(): - raise Exception('The event_name is required.') + raise Exception("The event_name is required.") async def verify_token_async(check_server: bool = True) -> bool: @@ -620,16 +658,16 @@ async def verify_token_async(check_server: bool = True) -> bool: if not check_server: return True - url = f'{url_base}{urls.verify}' + url = f"{url_base}{urls.verify}" headers = { - 'User-Agent': event_user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": event_user_agent, + "Authorization": f"Bearer {auth_token}", } async with httpx.AsyncClient() as client: resp = await client.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return 'username' in resp.json() + return "username" in resp.json() except Exception: return False @@ -653,15 +691,15 @@ def verify_token(check_server: bool = True) -> bool: if not check_server: return True - url = f'{url_base}{urls.verify}' + url = f"{url_base}{urls.verify}" headers = { - 'User-Agent': event_user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": event_user_agent, + "Authorization": f"Bearer {auth_token}", } resp = httpx.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return 'username' in resp.json() + return "username" in resp.json() except Exception: return False @@ -677,9 +715,9 @@ async def heartbeat_async() -> bool: global auth_token validate_state(url=True, user=False) - url = f'{url_base}{urls.heartbeat}' + url = f"{url_base}{urls.heartbeat}" headers = { - 'User-Agent': user_agent, + "User-Agent": user_agent, } async with httpx.AsyncClient() as client: resp = await client.post(url, headers=headers, follow_redirects=True) @@ -701,9 +739,9 @@ def heartbeat() -> bool: global auth_token validate_state(url=True, user=False) - url = f'{url_base}{urls.heartbeat}' + url = f"{url_base}{urls.heartbeat}" headers = { - 'User-Agent': user_agent, + "User-Agent": user_agent, } resp = httpx.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -718,9 +756,9 @@ def validate_login(email: str, password: str) -> None: Internal helper function, not need to use this. """ if not email: - raise ValidationError('Email cannot be empty') + raise ValidationError("Email cannot be empty") if not password: - raise ValidationError('Password cannot be empty') + raise ValidationError("Password cannot be empty") async def active_users_async(website_id: Optional[str] = None) -> int: @@ -737,17 +775,17 @@ async def active_users_async(website_id: Optional[str] = None) -> int: website_id = website_id or default_website_id - url = f'{url_base}{urls.websites}/{website_id}/active' + url = f"{url_base}{urls.websites}/{website_id}/active" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } async with httpx.AsyncClient() as client: resp = await client.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return int(resp.json().get('x', 0)) + return int(resp.json().get("x", 0)) def active_users(website_id: Optional[str] = None) -> int: @@ -764,16 +802,16 @@ def active_users(website_id: Optional[str] = None) -> int: website_id = website_id or default_website_id - url = f'{url_base}{urls.websites}/{website_id}/active' + url = f"{url_base}{urls.websites}/{website_id}/active" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } resp = httpx.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return int(resp.json().get('x', 0)) + return int(resp.json().get("x", 0)) async def website_stats_async( @@ -819,34 +857,36 @@ async def website_stats_async( website_id = website_id or default_website_id - api_url = f'{url_base}{urls.websites}/{website_id}/stats' + api_url = f"{url_base}{urls.websites}/{website_id}/stats" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } params = { - 'start_at': int(start_at.timestamp() * 1000), - 'end_at': int(end_at.timestamp() * 1000), + "start_at": int(start_at.timestamp() * 1000), + "end_at": int(end_at.timestamp() * 1000), } optional_params: dict[str, Any] = { - 'url': url, - 'referrer': referrer, - 'title': title, - 'query': query, - 'event': event, - 'host': host, - 'os': os, - 'browser': browser, - 'device': device, - 'country': country, - 'region': region, - 'city': city, + "url": url, + "referrer": referrer, + "title": title, + "query": query, + "event": event, + "host": host, + "os": os, + "browser": browser, + "device": device, + "country": country, + "region": region, + "city": city, } params.update({k: v for k, v in optional_params.items() if v is not None}) async with httpx.AsyncClient() as client: - resp = await client.get(api_url, headers=headers, params=params, follow_redirects=True) + resp = await client.get( + api_url, headers=headers, params=params, follow_redirects=True + ) resp.raise_for_status() return models.WebsiteStats(**resp.json()) @@ -895,29 +935,29 @@ def website_stats( website_id = website_id or default_website_id - api_url = f'{url_base}{urls.websites}/{website_id}/stats' + api_url = f"{url_base}{urls.websites}/{website_id}/stats" headers = { - 'User-Agent': user_agent, - 'Authorization': f'Bearer {auth_token}', + "User-Agent": user_agent, + "Authorization": f"Bearer {auth_token}", } params = { - 'startAt': int(start_at.timestamp() * 1000), - 'endAt': int(end_at.timestamp() * 1000), + "startAt": int(start_at.timestamp() * 1000), + "endAt": int(end_at.timestamp() * 1000), } optional_params: dict[str, Any] = { - 'url': url, - 'referrer': referrer, - 'title': title, - 'query': query, - 'event': event, - 'host': host, - 'os': os, - 'browser': browser, - 'device': device, - 'country': country, - 'region': region, - 'city': city, + "url": url, + "referrer": referrer, + "title": title, + "query": query, + "event": event, + "host": host, + "os": os, + "browser": browser, + "device": device, + "country": country, + "region": region, + "city": city, } params.update({k: v for k, v in optional_params.items() if v is not None}) @@ -932,7 +972,7 @@ def validate_state(url: bool = False, user: bool = False): Internal helper function, not need to use this. """ if url and not url_base: - raise OperationNotAllowedError('URL Base must be set to proceed.') + raise OperationNotAllowedError("URL Base must be set to proceed.") if user and not auth_token: - raise OperationNotAllowedError('You must login before proceeding.') + raise OperationNotAllowedError("You must login before proceeding.") From 58a536ef140e5cac85fe8db09a3c496826da2d0c Mon Sep 17 00:00:00 2001 From: James O'Claire Date: Mon, 25 May 2026 11:01:58 +0800 Subject: [PATCH 2/3] Handle edge case that id is an integer as this is common, throw validation error if not --- umami/tests/test_revenue.py | 35 ++++++++++++++++++++++++++ umami/umami/impl/__init__.py | 49 +++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/umami/tests/test_revenue.py b/umami/tests/test_revenue.py index 6d2cf10..ab9daff 100644 --- a/umami/tests/test_revenue.py +++ b/umami/tests/test_revenue.py @@ -31,6 +31,22 @@ def test_new_event_includes_distinct_id(self, mock_post): payload = mock_post.call_args.kwargs["json"]["payload"] assert payload["id"] == "user-123" + @patch("umami.impl.httpx.post") + def test_new_event_normalizes_integer_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_event(event_name="signup", distinct_id=12345) + + payload = mock_post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "12345" + + def test_new_event_rejects_invalid_distinct_id_type(self): + with pytest.raises(ValidationError, match="string or integer"): + umami.new_event(event_name="signup", distinct_id=["bad-type"]) # type: ignore[arg-type] + @patch("umami.impl.httpx.post") def test_new_page_view_includes_distinct_id(self, mock_post): mock_resp = MagicMock() @@ -203,6 +219,25 @@ async def test_new_page_view_async_includes_distinct_id(self): payload = mock_client.post.call_args.kwargs["json"]["payload"] assert payload["id"] == "user-456" + @pytest.mark.asyncio + async def test_new_page_view_async_normalizes_integer_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): + await umami.new_page_view_async( + page_title="Account", url="/account", distinct_id=67890 + ) + + payload = mock_client.post.call_args.kwargs["json"]["payload"] + assert payload["id"] == "67890" + @pytest.mark.asyncio async def test_default_revenue_event_async(self): mock_resp = MagicMock() diff --git a/umami/umami/impl/__init__.py b/umami/umami/impl/__init__.py index 8a93b33..653f5b2 100644 --- a/umami/umami/impl/__init__.py +++ b/umami/umami/impl/__init__.py @@ -1,6 +1,6 @@ import sys from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union import httpx @@ -34,6 +34,17 @@ ) +def normalize_distinct_id(distinct_id: Optional[Union[str, int]]) -> Optional[str]: + if distinct_id is None: + return None + + if isinstance(distinct_id, bool) or not isinstance(distinct_id, (str, int)): + raise ValidationError("distinct_id must be a string or integer.") + + normalized_distinct_id = str(distinct_id).strip() + return normalized_distinct_id or None + + def set_url_base(url: str) -> None: """ Each Umami instance lives somewhere. This is where yours lives. @@ -220,7 +231,7 @@ async def new_event_async( language: str = "en-US", screen: str = "1920x1080", ip_address: Optional[str] = None, - distinct_id: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ) -> dict: """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -239,7 +250,7 @@ async def new_event_async( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. Returns: The data returned from the Umami API. """ # noqa @@ -248,6 +259,7 @@ async def new_event_async( hostname = hostname or default_hostname title = title or event_name custom_data = custom_data or {} + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name, hostname, website_id) @@ -276,8 +288,8 @@ async def new_event_async( if ip_address and ip_address.strip(): payload["ip"] = ip_address - if distinct_id and distinct_id.strip(): - payload["id"] = distinct_id + if normalized_distinct_id: + payload["id"] = normalized_distinct_id event_data = {"payload": payload, "type": "event"} @@ -301,7 +313,7 @@ def new_event( language: str = "en-US", screen: str = "1920x1080", ip_address: Optional[str] = None, - distinct_id: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -320,13 +332,14 @@ def new_event( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname title = title or event_name custom_data = custom_data or {} + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name, hostname, website_id) @@ -355,8 +368,8 @@ def new_event( if ip_address and ip_address.strip(): payload["ip"] = ip_address - if distinct_id and distinct_id.strip(): - payload["id"] = distinct_id + if normalized_distinct_id: + payload["id"] = normalized_distinct_id event_data = {"payload": payload, "type": "event"} @@ -490,7 +503,7 @@ async def new_page_view_async( screen: str = "1920x1080", ua: str = event_user_agent, ip_address: Optional[str] = None, - distinct_id: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -507,11 +520,12 @@ async def new_page_view_async( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data( event_name="NOT NEEDED", hostname=hostname, website_id=website_id @@ -540,8 +554,8 @@ async def new_page_view_async( if ip_address and ip_address.strip(): payload["ip"] = ip_address - if distinct_id and distinct_id.strip(): - payload["id"] = distinct_id + if normalized_distinct_id: + payload["id"] = normalized_distinct_id event_data = {"payload": payload, "type": "event"} @@ -562,7 +576,7 @@ def new_page_view( screen: str = "1920x1080", ua: str = event_user_agent, ip_address: Optional[str] = None, - distinct_id: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -579,11 +593,12 @@ def new_page_view( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. - distinct_id: OPTIONAL: The Umami distinct ID for the user. Sent to the API as payload field id. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data( event_name="NOT NEEDED", hostname=hostname, website_id=website_id @@ -612,8 +627,8 @@ def new_page_view( if ip_address and ip_address.strip(): payload["ip"] = ip_address - if distinct_id and distinct_id.strip(): - payload["id"] = distinct_id + if normalized_distinct_id: + payload["id"] = normalized_distinct_id event_data = {"payload": payload, "type": "event"} From bf6d3bdd6f9787e6cac770720b3579e022f61142 Mon Sep 17 00:00:00 2001 From: James O'Claire Date: Mon, 25 May 2026 11:06:45 +0800 Subject: [PATCH 3/3] Rerun ruff, fix double to single quotes --- umami/tests/test_revenue.py | 172 ++++++++------- umami/umami/impl/__init__.py | 394 +++++++++++++++++------------------ 2 files changed, 269 insertions(+), 297 deletions(-) diff --git a/umami/tests/test_revenue.py b/umami/tests/test_revenue.py index ab9daff..03320f7 100644 --- a/umami/tests/test_revenue.py +++ b/umami/tests/test_revenue.py @@ -9,9 +9,9 @@ @pytest.fixture(autouse=True) def _setup_umami(): """Set up default umami state for all tests.""" - umami.set_url_base("https://example.com") - umami.set_hostname("test.com") - umami.set_website_id("test-website-id") + umami.set_url_base('https://example.com') + umami.set_hostname('test.com') + umami.set_website_id('test-website-id') umami.enable() yield @@ -19,49 +19,47 @@ def _setup_umami(): class TestNewRevenueEvent: """Tests for the sync new_revenue_event function.""" - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_new_event_includes_distinct_id(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_event(event_name="signup", distinct_id="user-123") + umami.new_event(event_name='signup', distinct_id='user-123') - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "user-123" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-123' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_new_event_normalizes_integer_distinct_id(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_event(event_name="signup", distinct_id=12345) + umami.new_event(event_name='signup', distinct_id=12345) - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "12345" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == '12345' def test_new_event_rejects_invalid_distinct_id_type(self): - with pytest.raises(ValidationError, match="string or integer"): - umami.new_event(event_name="signup", distinct_id=["bad-type"]) # type: ignore[arg-type] + with pytest.raises(ValidationError, match='string or integer'): + umami.new_event(event_name='signup', distinct_id=['bad-type']) # type: ignore[arg-type] - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_new_page_view_includes_distinct_id(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_page_view( - page_title="Account", url="/account", distinct_id="user-456" - ) + umami.new_page_view(page_title='Account', url='/account', distinct_id='user-456') - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "user-456" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-456' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_default_revenue_event(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -71,53 +69,51 @@ def test_default_revenue_event(self, mock_post): umami.new_revenue_event(revenue=19.99) call_kwargs = mock_post.call_args - payload = call_kwargs.kwargs["json"]["payload"] - assert payload["name"] == "revenue" - assert payload["data"]["revenue"] == 19.99 - assert payload["data"]["currency"] == "USD" + payload = call_kwargs.kwargs['json']['payload'] + assert payload['name'] == 'revenue' + assert payload['data']['revenue'] == 19.99 + assert payload['data']['currency'] == 'USD' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_custom_currency(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event(revenue=49.00, currency="EUR") + umami.new_revenue_event(revenue=49.00, currency='EUR') - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["data"]["currency"] == "EUR" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['data']['currency'] == 'EUR' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_custom_event_name(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event(revenue=10.00, event_name="checkout-cart") + umami.new_revenue_event(revenue=10.00, event_name='checkout-cart') - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["name"] == "checkout-cart" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['name'] == 'checkout-cart' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_additional_custom_data_preserved(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} mock_resp.raise_for_status = MagicMock() mock_post.return_value = mock_resp - umami.new_revenue_event( - revenue=25.00, custom_data={"product": "widget", "quantity": 2} - ) + umami.new_revenue_event(revenue=25.00, custom_data={'product': 'widget', 'quantity': 2}) - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["data"]["product"] == "widget" - assert payload["data"]["quantity"] == 2 - assert payload["data"]["revenue"] == 25.00 - assert payload["data"]["currency"] == "USD" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['data']['product'] == 'widget' + assert payload['data']['quantity'] == 2 + assert payload['data']['revenue'] == 25.00 + assert payload['data']['currency'] == 'USD' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_revenue_currency_override_custom_data(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -126,18 +122,18 @@ def test_revenue_currency_override_custom_data(self, mock_post): umami.new_revenue_event( revenue=30.00, - currency="GBP", + currency='GBP', custom_data={ - "revenue": "should_be_overridden", - "currency": "should_be_overridden", + 'revenue': 'should_be_overridden', + 'currency': 'should_be_overridden', }, ) - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["data"]["revenue"] == 30.00 - assert payload["data"]["currency"] == "GBP" + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['data']['revenue'] == 30.00 + assert payload['data']['currency'] == 'GBP' - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_zero_revenue_allowed(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -146,20 +142,20 @@ def test_zero_revenue_allowed(self, mock_post): umami.new_revenue_event(revenue=0) - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["data"]["revenue"] == 0 + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['data']['revenue'] == 0 def test_negative_revenue_raises(self): - with pytest.raises(ValidationError, match="must be >= 0"): + with pytest.raises(ValidationError, match='must be >= 0'): umami.new_revenue_event(revenue=-5.00) def test_non_numeric_revenue_raises(self): - with pytest.raises(ValidationError, match="must be a number"): - umami.new_revenue_event(revenue="not_a_number") # type: ignore + with pytest.raises(ValidationError, match='must be a number'): + umami.new_revenue_event(revenue='not_a_number') # type: ignore def test_empty_currency_raises(self): - with pytest.raises(ValidationError, match="non-empty string"): - umami.new_revenue_event(revenue=10.00, currency="") + with pytest.raises(ValidationError, match='non-empty string'): + umami.new_revenue_event(revenue=10.00, currency='') def test_tracking_disabled_returns_early(self): umami.disable() @@ -167,7 +163,7 @@ def test_tracking_disabled_returns_early(self): # Should return without making any HTTP call assert result is None - @patch("umami.impl.httpx.post") + @patch('umami.impl.httpx.post') def test_integer_revenue(self, mock_post): mock_resp = MagicMock() mock_resp.json.return_value = {} @@ -176,8 +172,8 @@ def test_integer_revenue(self, mock_post): umami.new_revenue_event(revenue=100) - payload = mock_post.call_args.kwargs["json"]["payload"] - assert payload["data"]["revenue"] == 100 + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['data']['revenue'] == 100 class TestNewRevenueEventAsync: @@ -194,11 +190,11 @@ async def test_new_event_async_includes_distinct_id(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): - await umami.new_event_async(event_name="signup", distinct_id="user-123") + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_event_async(event_name='signup', distinct_id='user-123') - payload = mock_client.post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "user-123" + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-123' @pytest.mark.asyncio async def test_new_page_view_async_includes_distinct_id(self): @@ -211,13 +207,11 @@ async def test_new_page_view_async_includes_distinct_id(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): - await umami.new_page_view_async( - page_title="Account", url="/account", distinct_id="user-456" - ) + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_page_view_async(page_title='Account', url='/account', distinct_id='user-456') - payload = mock_client.post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "user-456" + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-456' @pytest.mark.asyncio async def test_new_page_view_async_normalizes_integer_distinct_id(self): @@ -230,13 +224,11 @@ async def test_new_page_view_async_normalizes_integer_distinct_id(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): - await umami.new_page_view_async( - page_title="Account", url="/account", distinct_id=67890 - ) + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_page_view_async(page_title='Account', url='/account', distinct_id=67890) - payload = mock_client.post.call_args.kwargs["json"]["payload"] - assert payload["id"] == "67890" + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == '67890' @pytest.mark.asyncio async def test_default_revenue_event_async(self): @@ -249,14 +241,14 @@ async def test_default_revenue_event_async(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): result = await umami.new_revenue_event_async(revenue=19.99) call_kwargs = mock_client.post.call_args - payload = call_kwargs.kwargs["json"]["payload"] - assert payload["name"] == "revenue" - assert payload["data"]["revenue"] == 19.99 - assert payload["data"]["currency"] == "USD" + payload = call_kwargs.kwargs['json']['payload'] + assert payload['name'] == 'revenue' + assert payload['data']['revenue'] == 19.99 + assert payload['data']['currency'] == 'USD' assert result == {} @pytest.mark.asyncio @@ -270,19 +262,19 @@ async def test_custom_params_async(self): mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.post = AsyncMock(return_value=mock_resp) - with patch("umami.impl.httpx.AsyncClient", return_value=mock_client): + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): await umami.new_revenue_event_async( revenue=49.00, - currency="EUR", - event_name="checkout-cart", - url="/checkout", + currency='EUR', + event_name='checkout-cart', + url='/checkout', ) - payload = mock_client.post.call_args.kwargs["json"]["payload"] - assert payload["name"] == "checkout-cart" - assert payload["data"]["revenue"] == 49.00 - assert payload["data"]["currency"] == "EUR" - assert payload["url"] == "/checkout" + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['name'] == 'checkout-cart' + assert payload['data']['revenue'] == 49.00 + assert payload['data']['currency'] == 'EUR' + assert payload['url'] == '/checkout' @pytest.mark.asyncio async def test_tracking_disabled_async(self): @@ -292,5 +284,5 @@ async def test_tracking_disabled_async(self): @pytest.mark.asyncio async def test_negative_revenue_raises_async(self): - with pytest.raises(ValidationError, match="must be >= 0"): + with pytest.raises(ValidationError, match='must be >= 0'): await umami.new_revenue_event_async(revenue=-5.00) diff --git a/umami/umami/impl/__init__.py b/umami/umami/impl/__init__.py index 653f5b2..9baf93b 100644 --- a/umami/umami/impl/__init__.py +++ b/umami/umami/impl/__init__.py @@ -10,9 +10,9 @@ try: from importlib.metadata import version - __version__ = version("umami-analytics") + __version__ = version('umami-analytics') except Exception: - __version__ = "0.0.0" # Fallback for development environments + __version__ = '0.0.0' # Fallback for development environments url_base: Optional[str] = None @@ -24,13 +24,13 @@ # You can also set DISABLE_BOT_CHECK=true in your Umami environment to disable the bot check entirely: # https://github.com/umami-software/umami/blob/7a3443cd06772f3cde37bdbb0bf38eabf4515561/pages/api/collect.js#L13 event_user_agent = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/142.0.0.0 Safari/537.36" + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/142.0.0.0 Safari/537.36' ) user_agent = ( - f"Umami-Client v{__version__} / " - f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / " - f"{sys.platform.capitalize()}" + f'Umami-Client v{__version__} / ' + f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' + f'{sys.platform.capitalize()}' ) @@ -39,7 +39,7 @@ def normalize_distinct_id(distinct_id: Optional[Union[str, int]]) -> Optional[st return None if isinstance(distinct_id, bool) or not isinstance(distinct_id, (str, int)): - raise ValidationError("distinct_id must be a string or integer.") + raise ValidationError('distinct_id must be a string or integer.') normalized_distinct_id = str(distinct_id).strip() return normalized_distinct_id or None @@ -53,17 +53,15 @@ def set_url_base(url: str) -> None: url: The base URL of your instance without /api. """ if not url or not url.strip(): - raise ValidationError("URL must not be empty.") + raise ValidationError('URL must not be empty.') # noinspection HttpUrlsUsage - if not url.startswith("http://") and not url.startswith("https://"): + if not url.startswith('http://') and not url.startswith('https://'): # noinspection HttpUrlsUsage - raise ValidationError( - "The url must start with the HTTP scheme (http:// or https://)." - ) + raise ValidationError('The url must start with the HTTP scheme (http:// or https://).') - if url.endswith("/"): - url = url.rstrip("/") + if url.endswith('/'): + url = url.rstrip('/') global url_base url_base = url.strip() @@ -108,16 +106,14 @@ async def login_async(username: str, password: str) -> models.LoginResponse: validate_state(url=True) validate_login(username, password) - url = f"{url_base}{urls.login}" - headers = {"User-Agent": user_agent} + url = f'{url_base}{urls.login}' + headers = {'User-Agent': user_agent} api_data = { - "username": username, - "password": password, + 'username': username, + 'password': password, } async with httpx.AsyncClient() as client: - resp = await client.post( - url, json=api_data, headers=headers, follow_redirects=True - ) + resp = await client.post(url, json=api_data, headers=headers, follow_redirects=True) resp.raise_for_status() model = models.LoginResponse(**resp.json()) @@ -140,11 +136,11 @@ def login(username: str, password: str) -> models.LoginResponse: validate_state(url=True) validate_login(username, password) - url = f"{url_base}{urls.login}" - headers = {"User-Agent": user_agent} + url = f'{url_base}{urls.login}' + headers = {'User-Agent': user_agent} api_data = { - "username": username, - "password": password, + 'username': username, + 'password': password, } resp = httpx.post(url, json=api_data, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -162,10 +158,10 @@ async def websites_async() -> list[models.Website]: global auth_token validate_state(url=True, user=True) - url = f"{url_base}{urls.websites}" + url = f'{url_base}{urls.websites}' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } async with httpx.AsyncClient() as client: # type: ignore @@ -184,10 +180,10 @@ def websites() -> list[models.Website]: global auth_token validate_state(url=True, user=True) - url = f"{url_base}{urls.websites}" + url = f'{url_base}{urls.websites}' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } resp = httpx.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -223,13 +219,13 @@ def disable() -> None: async def new_event_async( event_name: str, hostname: Optional[str] = None, - url: str = "/", + url: str = '/', website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ip_address: Optional[str] = None, distinct_id: Optional[Union[str, int]] = None, ) -> dict: @@ -267,36 +263,34 @@ async def new_event_async( if not tracking_enabled: return {} - api_url = f"{url_base}{urls.events}" + api_url = f'{url_base}{urls.events}' headers = { - "User-Agent": event_user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': event_user_agent, + 'Authorization': f'Bearer {auth_token}', } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": title, - "url": url, - "website": website_id, - "name": event_name, - "data": custom_data, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': title, + 'url': url, + 'website': website_id, + 'name': event_name, + 'data': custom_data, } if ip_address and ip_address.strip(): - payload["ip"] = ip_address + payload['ip'] = ip_address if normalized_distinct_id: - payload["id"] = normalized_distinct_id + payload['id'] = normalized_distinct_id - event_data = {"payload": payload, "type": "event"} + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: - resp = await client.post( - api_url, json=event_data, headers=headers, follow_redirects=True - ) + resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() return resp.json() @@ -305,13 +299,13 @@ async def new_event_async( def new_event( event_name: str, hostname: Optional[str] = None, - url: str = "/event-api-endpoint", + url: str = '/event-api-endpoint', website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ip_address: Optional[str] = None, distinct_id: Optional[Union[str, int]] = None, ): @@ -347,31 +341,31 @@ def new_event( if not tracking_enabled: return - api_url = f"{url_base}{urls.events}" + api_url = f'{url_base}{urls.events}' headers = { - "User-Agent": event_user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': event_user_agent, + 'Authorization': f'Bearer {auth_token}', } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": title, - "url": url, - "website": website_id, - "name": event_name, - "data": custom_data, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': title, + 'url': url, + 'website': website_id, + 'name': event_name, + 'data': custom_data, } if ip_address and ip_address.strip(): - payload["ip"] = ip_address + payload['ip'] = ip_address if normalized_distinct_id: - payload["id"] = normalized_distinct_id + payload['id'] = normalized_distinct_id - event_data = {"payload": payload, "type": "event"} + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -379,16 +373,16 @@ def new_event( async def new_revenue_event_async( revenue: float, - currency: str = "USD", - event_name: str = "revenue", + currency: str = 'USD', + event_name: str = 'revenue', hostname: Optional[str] = None, - url: str = "/", + url: str = '/', website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ip_address: Optional[str] = None, ) -> dict: """ @@ -412,15 +406,15 @@ async def new_revenue_event_async( Returns: The data returned from the Umami API. """ # noqa if not isinstance(revenue, (int, float)): - raise ValidationError("Revenue must be a number (int or float).") + raise ValidationError('Revenue must be a number (int or float).') if revenue < 0: - raise ValidationError("Revenue must be >= 0.") + raise ValidationError('Revenue must be >= 0.') if not currency or not currency.strip(): - raise ValidationError("Currency must be a non-empty string.") + raise ValidationError('Currency must be a non-empty string.') merged_data = dict(custom_data or {}) - merged_data["revenue"] = revenue - merged_data["currency"] = currency + merged_data['revenue'] = revenue + merged_data['currency'] = currency return await new_event_async( event_name=event_name, @@ -438,16 +432,16 @@ async def new_revenue_event_async( def new_revenue_event( revenue: float, - currency: str = "USD", - event_name: str = "revenue", + currency: str = 'USD', + event_name: str = 'revenue', hostname: Optional[str] = None, - url: str = "/event-api-endpoint", + url: str = '/event-api-endpoint', website_id: Optional[str] = None, title: Optional[str] = None, custom_data: Optional[Dict[str, Any]] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ip_address: Optional[str] = None, ): """ @@ -469,15 +463,15 @@ def new_revenue_event( ip_address: OPTIONAL: The true IP address of the user. """ # noqa if not isinstance(revenue, (int, float)): - raise ValidationError("Revenue must be a number (int or float).") + raise ValidationError('Revenue must be a number (int or float).') if revenue < 0: - raise ValidationError("Revenue must be >= 0.") + raise ValidationError('Revenue must be >= 0.') if not currency or not currency.strip(): - raise ValidationError("Currency must be a non-empty string.") + raise ValidationError('Currency must be a non-empty string.') merged_data = dict(custom_data or {}) - merged_data["revenue"] = revenue - merged_data["currency"] = currency + merged_data['revenue'] = revenue + merged_data['currency'] = currency return new_event( event_name=event_name, @@ -498,9 +492,9 @@ async def new_page_view_async( url: str, hostname: Optional[str] = None, website_id: Optional[str] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ua: str = event_user_agent, ip_address: Optional[str] = None, distinct_id: Optional[Union[str, int]] = None, @@ -527,42 +521,38 @@ async def new_page_view_async( hostname = hostname or default_hostname normalized_distinct_id = normalize_distinct_id(distinct_id) - validate_event_data( - event_name="NOT NEEDED", hostname=hostname, website_id=website_id - ) + validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) # Early return if tracking is disabled if not tracking_enabled: return - api_url = f"{url_base}{urls.events}" + api_url = f'{url_base}{urls.events}' headers = { - "User-Agent": ua, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': ua, + 'Authorization': f'Bearer {auth_token}', } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": page_title, - "url": url, - "website": website_id, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': page_title, + 'url': url, + 'website': website_id, } if ip_address and ip_address.strip(): - payload["ip"] = ip_address + payload['ip'] = ip_address if normalized_distinct_id: - payload["id"] = normalized_distinct_id + payload['id'] = normalized_distinct_id - event_data = {"payload": payload, "type": "event"} + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: - resp = await client.post( - api_url, json=event_data, headers=headers, follow_redirects=True - ) + resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -571,9 +561,9 @@ def new_page_view( url: str, hostname: Optional[str] = None, website_id: Optional[str] = None, - referrer: str = "", - language: str = "en-US", - screen: str = "1920x1080", + referrer: str = '', + language: str = 'en-US', + screen: str = '1920x1080', ua: str = event_user_agent, ip_address: Optional[str] = None, distinct_id: Optional[Union[str, int]] = None, @@ -600,58 +590,50 @@ def new_page_view( hostname = hostname or default_hostname normalized_distinct_id = normalize_distinct_id(distinct_id) - validate_event_data( - event_name="NOT NEEDED", hostname=hostname, website_id=website_id - ) + validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) # Early return if tracking is disabled if not tracking_enabled: return - api_url = f"{url_base}{urls.events}" + api_url = f'{url_base}{urls.events}' headers = { - "User-Agent": ua, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': ua, + 'Authorization': f'Bearer {auth_token}', } payload = { - "hostname": hostname, - "language": language, - "referrer": referrer, - "screen": screen, - "title": page_title, - "url": url, - "website": website_id, + 'hostname': hostname, + 'language': language, + 'referrer': referrer, + 'screen': screen, + 'title': page_title, + 'url': url, + 'website': website_id, } if ip_address and ip_address.strip(): - payload["ip"] = ip_address + payload['ip'] = ip_address if normalized_distinct_id: - payload["id"] = normalized_distinct_id + payload['id'] = normalized_distinct_id - event_data = {"payload": payload, "type": "event"} + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) resp.raise_for_status() -def validate_event_data( - event_name: str, hostname: Optional[str], website_id: Optional[str] -): +def validate_event_data(event_name: str, hostname: Optional[str], website_id: Optional[str]): """ Internal use only. """ if not hostname: - raise Exception( - "The hostname must be set, either as a parameter here or via set_hostname()." - ) + raise Exception('The hostname must be set, either as a parameter here or via set_hostname().') if not website_id: - raise Exception( - "The website_id must be set, either as a parameter here or via set_website_id()." - ) + raise Exception('The website_id must be set, either as a parameter here or via set_website_id().') if not event_name and not event_name.strip(): - raise Exception("The event_name is required.") + raise Exception('The event_name is required.') async def verify_token_async(check_server: bool = True) -> bool: @@ -673,16 +655,16 @@ async def verify_token_async(check_server: bool = True) -> bool: if not check_server: return True - url = f"{url_base}{urls.verify}" + url = f'{url_base}{urls.verify}' headers = { - "User-Agent": event_user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': event_user_agent, + 'Authorization': f'Bearer {auth_token}', } async with httpx.AsyncClient() as client: resp = await client.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return "username" in resp.json() + return 'username' in resp.json() except Exception: return False @@ -706,15 +688,15 @@ def verify_token(check_server: bool = True) -> bool: if not check_server: return True - url = f"{url_base}{urls.verify}" + url = f'{url_base}{urls.verify}' headers = { - "User-Agent": event_user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': event_user_agent, + 'Authorization': f'Bearer {auth_token}', } resp = httpx.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return "username" in resp.json() + return 'username' in resp.json() except Exception: return False @@ -730,9 +712,9 @@ async def heartbeat_async() -> bool: global auth_token validate_state(url=True, user=False) - url = f"{url_base}{urls.heartbeat}" + url = f'{url_base}{urls.heartbeat}' headers = { - "User-Agent": user_agent, + 'User-Agent': user_agent, } async with httpx.AsyncClient() as client: resp = await client.post(url, headers=headers, follow_redirects=True) @@ -754,9 +736,9 @@ def heartbeat() -> bool: global auth_token validate_state(url=True, user=False) - url = f"{url_base}{urls.heartbeat}" + url = f'{url_base}{urls.heartbeat}' headers = { - "User-Agent": user_agent, + 'User-Agent': user_agent, } resp = httpx.post(url, headers=headers, follow_redirects=True) resp.raise_for_status() @@ -771,9 +753,9 @@ def validate_login(email: str, password: str) -> None: Internal helper function, not need to use this. """ if not email: - raise ValidationError("Email cannot be empty") + raise ValidationError('Email cannot be empty') if not password: - raise ValidationError("Password cannot be empty") + raise ValidationError('Password cannot be empty') async def active_users_async(website_id: Optional[str] = None) -> int: @@ -790,17 +772,17 @@ async def active_users_async(website_id: Optional[str] = None) -> int: website_id = website_id or default_website_id - url = f"{url_base}{urls.websites}/{website_id}/active" + url = f'{url_base}{urls.websites}/{website_id}/active' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } async with httpx.AsyncClient() as client: resp = await client.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return int(resp.json().get("x", 0)) + return int(resp.json().get('x', 0)) def active_users(website_id: Optional[str] = None) -> int: @@ -817,16 +799,16 @@ def active_users(website_id: Optional[str] = None) -> int: website_id = website_id or default_website_id - url = f"{url_base}{urls.websites}/{website_id}/active" + url = f'{url_base}{urls.websites}/{website_id}/active' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } resp = httpx.get(url, headers=headers, follow_redirects=True) resp.raise_for_status() - return int(resp.json().get("x", 0)) + return int(resp.json().get('x', 0)) async def website_stats_async( @@ -872,36 +854,34 @@ async def website_stats_async( website_id = website_id or default_website_id - api_url = f"{url_base}{urls.websites}/{website_id}/stats" + api_url = f'{url_base}{urls.websites}/{website_id}/stats' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } params = { - "start_at": int(start_at.timestamp() * 1000), - "end_at": int(end_at.timestamp() * 1000), + 'start_at': int(start_at.timestamp() * 1000), + 'end_at': int(end_at.timestamp() * 1000), } optional_params: dict[str, Any] = { - "url": url, - "referrer": referrer, - "title": title, - "query": query, - "event": event, - "host": host, - "os": os, - "browser": browser, - "device": device, - "country": country, - "region": region, - "city": city, + 'url': url, + 'referrer': referrer, + 'title': title, + 'query': query, + 'event': event, + 'host': host, + 'os': os, + 'browser': browser, + 'device': device, + 'country': country, + 'region': region, + 'city': city, } params.update({k: v for k, v in optional_params.items() if v is not None}) async with httpx.AsyncClient() as client: - resp = await client.get( - api_url, headers=headers, params=params, follow_redirects=True - ) + resp = await client.get(api_url, headers=headers, params=params, follow_redirects=True) resp.raise_for_status() return models.WebsiteStats(**resp.json()) @@ -950,29 +930,29 @@ def website_stats( website_id = website_id or default_website_id - api_url = f"{url_base}{urls.websites}/{website_id}/stats" + api_url = f'{url_base}{urls.websites}/{website_id}/stats' headers = { - "User-Agent": user_agent, - "Authorization": f"Bearer {auth_token}", + 'User-Agent': user_agent, + 'Authorization': f'Bearer {auth_token}', } params = { - "startAt": int(start_at.timestamp() * 1000), - "endAt": int(end_at.timestamp() * 1000), + 'startAt': int(start_at.timestamp() * 1000), + 'endAt': int(end_at.timestamp() * 1000), } optional_params: dict[str, Any] = { - "url": url, - "referrer": referrer, - "title": title, - "query": query, - "event": event, - "host": host, - "os": os, - "browser": browser, - "device": device, - "country": country, - "region": region, - "city": city, + 'url': url, + 'referrer': referrer, + 'title': title, + 'query': query, + 'event': event, + 'host': host, + 'os': os, + 'browser': browser, + 'device': device, + 'country': country, + 'region': region, + 'city': city, } params.update({k: v for k, v in optional_params.items() if v is not None}) @@ -987,7 +967,7 @@ def validate_state(url: bool = False, user: bool = False): Internal helper function, not need to use this. """ if url and not url_base: - raise OperationNotAllowedError("URL Base must be set to proceed.") + raise OperationNotAllowedError('URL Base must be set to proceed.') if user and not auth_token: - raise OperationNotAllowedError("You must login before proceeding.") + raise OperationNotAllowedError('You must login before proceeding.')