From e7e3668c18856c131e4fb33bfb92eee0176a86af Mon Sep 17 00:00:00 2001 From: Kshitij Mishra Date: Tue, 2 Jun 2026 15:19:04 -0400 Subject: [PATCH 1/6] fix(consumer): remove legacy Python 2 imports and type-guard retry status math --- posthog/consumer.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/posthog/consumer.py b/posthog/consumer.py index 6a9becbe..956e53a2 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -6,10 +6,7 @@ from posthog.request import APIError, DatetimeSerializer, batch_post -try: - from queue import Empty -except ImportError: - from Queue import Empty +from queue import Empty MAX_MSG_SIZE = 900 * 1024 # 900KiB per event @@ -133,9 +130,11 @@ def is_retryable(exc): # retry on server errors and client errors # with 408 (request timeout) or 429 (rate limited), # don't retry on other client errors - if exc.status == "N/A": - return False - return not ((400 <= exc.status < 500) and exc.status not in (408, 429)) + if isinstance(exc.status, int): + return not ( + (400 <= exc.status < 500) and exc.status not in (408, 429) + ) + return False else: # retry on all other errors (eg. network) return True From 4025388d065844599671268de20737bffd71b02d Mon Sep 17 00:00:00 2001 From: Kshitij Mishra Date: Tue, 2 Jun 2026 15:30:42 -0400 Subject: [PATCH 2/6] fix(request): resolve type-shifting assignment, secure path concatenation, and strip dead ignore --- posthog/request.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/posthog/request.py b/posthog/request.py index 76f0a9fe..3fe21e7c 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -9,7 +9,7 @@ from typing import Any, List, Optional, Tuple, Union import requests -from requests.adapters import HTTPAdapter # type: ignore[import-untyped] +from requests.adapters import HTTPAdapter from urllib3.connection import HTTPConnection from urllib3.util.retry import Retry @@ -219,7 +219,7 @@ def determine_server_host(host: Optional[str]) -> str: def post( api_key: str, host: Optional[str] = None, - path=None, + path: str = "", gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, @@ -235,6 +235,7 @@ def post( data = json.dumps(body, cls=DatetimeSerializer) log.debug("making request: %s to url: %s", data, url) headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} + payload: str | bytes = data if gzip: headers["Content-Encoding"] = "gzip" buf = BytesIO() @@ -242,10 +243,10 @@ def post( # 'data' was produced by json.dumps(), # whose default encoding is utf-8. gz.write(data.encode("utf-8")) - data = buf.getvalue() + payload = buf.getvalue() res = (session or _get_session()).post( - url, data=data, headers=headers, timeout=timeout + url, data=payload, headers=headers, timeout=timeout ) if res.status_code == 200: From 01810bcadeda6f8bbcf27ba065acf2749b154525 Mon Sep 17 00:00:00 2001 From: Kshitij Mishra Date: Tue, 2 Jun 2026 15:48:43 -0400 Subject: [PATCH 3/6] fix(client, flags): handle optional before_send type variance and drop dead code signatures --- posthog/client.py | 23 ++++++++++------------- posthog/feature_flags.py | 2 +- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index d2cccc26..eba8702a 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -90,10 +90,8 @@ ) from posthog.version import VERSION -try: - import queue -except ImportError: - import Queue as queue + +from queue import Queue, Full MAX_DICT_SIZE = 50_000 @@ -279,7 +277,7 @@ def __init__( Initialization """ self._max_queue_size = max_queue_size - self.queue = queue.Queue(max_queue_size) + self.queue: Queue = Queue(max_queue_size) # api_key: This should be the Team API Key (token), public self.api_key = (project_api_key or "").strip() @@ -293,8 +291,10 @@ def __init__( self.host = determine_server_host(host) self.gzip = gzip self.timeout = timeout - self._feature_flags = None # private variable to store flags - self.feature_flags_by_key = None + self._feature_flags: Optional[list[Any]] = ( + None # private variable to store flags + ) + self.feature_flags_by_key: Optional[dict[str, Any]] = None self.group_type_mapping: Optional[dict[str, str]] = None self.cohorts: Optional[dict[str, Any]] = None self.poll_interval = poll_interval @@ -1245,7 +1245,7 @@ def _reinit_after_fork(self): as they'll be handled by the parent process's consumers. """ if self.consumers: - self.queue = queue.Queue(self._max_queue_size) + self.queue = Queue(self._max_queue_size) new_consumers = [] for old in self.consumers: @@ -1366,7 +1366,7 @@ def _enqueue(self, msg, disable_geoip): self.queue.put(msg, block=False) self.log.debug("enqueued %s.", msg["event"]) return sent_uuid - except queue.Full: + except Full: self.log.warning("analytics-python queue is full") return None @@ -2795,10 +2795,7 @@ def _initialize_flag_cache(self, cache_url): if not cache_url: return None - try: - from urllib.parse import parse_qs, urlparse - except ImportError: - from urlparse import parse_qs, urlparse + from urllib.parse import parse_qs, urlparse try: parsed = urlparse(cache_url) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 845f1d04..3b4f43f0 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -511,7 +511,7 @@ def compare(lhs, rhs, operator): parsed_value = None try: - parsed_value = float(value) # type: ignore + parsed_value = float(value) except Exception: pass From 30b00c95432d77d731fb4d69f400c3793c28409b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 15:02:13 +0200 Subject: [PATCH 4/6] fix: type external imports for mypy --- mypy-baseline.txt | 20 -------------------- mypy.ini | 1 + sdk_compliance_adapter/adapter.py | 2 +- typings/dateutil/__init__.pyi | 0 typings/dateutil/parser.pyi | 4 ++++ typings/requests/__init__.pyi | 29 +++++++++++++++++++++++++++++ typings/requests/adapters.pyi | 5 +++++ typings/requests/exceptions.pyi | 2 ++ 8 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 typings/dateutil/__init__.pyi create mode 100644 typings/dateutil/parser.pyi create mode 100644 typings/requests/__init__.pyi create mode 100644 typings/requests/adapters.pyi create mode 100644 typings/requests/exceptions.pyi diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 005f3f6b..e69de29b 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -1,20 +0,0 @@ -posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped] -posthog/request.py:0: note: Hint: "python3 -m pip install types-requests" -posthog/request.py:0: note: (or run "mypy --install-types" to install all missing stub packages) -posthog/request.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports -posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment] -posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef] -posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator] -posthog/consumer.py:0: note: Right operand is of type "int | str" -posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator] -posthog/consumer.py:0: note: Left operand is of type "int | str" -posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore] -posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef] -posthog/client.py:0: error: Need type annotation for "queue" [var-annotated] -posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment] -posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment] -posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined] -posthog/client.py:0: error: Statement is unreachable [unreachable] -posthog/client.py:0: error: Statement is unreachable [unreachable] -posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef] -posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef] diff --git a/mypy.ini b/mypy.ini index bd8447d9..bcfb1540 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,6 +2,7 @@ python_version = 3.11 plugins = pydantic.mypy +mypy_path = typings strict_optional = True no_implicit_optional = True warn_unused_ignores = True diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index cb3e168d..c075443d 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -287,7 +287,7 @@ def capture(): kwargs = {"distinct_id": distinct_id, "properties": properties} if timestamp: # Parse ISO8601 timestamp - from dateutil.parser import parse # type: ignore[import-untyped] + from dateutil.parser import parse kwargs["timestamp"] = parse(timestamp) diff --git a/typings/dateutil/__init__.pyi b/typings/dateutil/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/typings/dateutil/parser.pyi b/typings/dateutil/parser.pyi new file mode 100644 index 00000000..698baa96 --- /dev/null +++ b/typings/dateutil/parser.pyi @@ -0,0 +1,4 @@ +from datetime import datetime +from typing import Any + +def parse(timestr: str, *args: Any, **kwargs: Any) -> datetime: ... diff --git a/typings/requests/__init__.pyi b/typings/requests/__init__.pyi new file mode 100644 index 00000000..91034b4f --- /dev/null +++ b/typings/requests/__init__.pyi @@ -0,0 +1,29 @@ +from typing import Any + +from . import adapters as adapters, exceptions as exceptions + +class Response: + status_code: int + ok: bool + text: str + headers: dict[str, str] + def json(self) -> Any: ... + +class Session: + def mount(self, prefix: str, adapter: adapters.HTTPAdapter) -> None: ... + def close(self) -> None: ... + def post( + self, + url: str, + *, + data: str | bytes, + headers: dict[str, str], + timeout: int, + ) -> Response: ... + def get( + self, + url: str, + *, + headers: dict[str, str], + timeout: int | None = ..., + ) -> Response: ... diff --git a/typings/requests/adapters.pyi b/typings/requests/adapters.pyi new file mode 100644 index 00000000..6d10f910 --- /dev/null +++ b/typings/requests/adapters.pyi @@ -0,0 +1,5 @@ +from typing import Any + +class HTTPAdapter: + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + def init_poolmanager(self, *args: Any, **kwargs: Any) -> None: ... diff --git a/typings/requests/exceptions.pyi b/typings/requests/exceptions.pyi new file mode 100644 index 00000000..73ffa5cc --- /dev/null +++ b/typings/requests/exceptions.pyi @@ -0,0 +1,2 @@ +class Timeout(Exception): ... +class ConnectionError(Exception): ... From dce6a1ccd5d48bf896817b2097a1db27c04eeaa4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 15:13:37 +0200 Subject: [PATCH 5/6] fix: keep request payload behavior unchanged --- posthog/request.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/posthog/request.py b/posthog/request.py index 3fe21e7c..0d611728 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -6,7 +6,7 @@ from datetime import date, datetime, timezone from gzip import GzipFile from io import BytesIO -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union, cast import requests from requests.adapters import HTTPAdapter @@ -232,21 +232,20 @@ def post( trimmed_host = remove_trailing_slash(normalize_host(host)) url = trimmed_host + path body["api_key"] = api_key - data = json.dumps(body, cls=DatetimeSerializer) + data: str | bytes = json.dumps(body, cls=DatetimeSerializer) log.debug("making request: %s to url: %s", data, url) headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} - payload: str | bytes = data if gzip: headers["Content-Encoding"] = "gzip" buf = BytesIO() with GzipFile(fileobj=buf, mode="w") as gz: # 'data' was produced by json.dumps(), # whose default encoding is utf-8. - gz.write(data.encode("utf-8")) - payload = buf.getvalue() + gz.write(cast(str, data).encode("utf-8")) + data = buf.getvalue() res = (session or _get_session()).post( - url, data=payload, headers=headers, timeout=timeout + url, data=data, headers=headers, timeout=timeout ) if res.status_code == 200: From 10e97a66cc9032e6086441db0672373fa220b3d8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 15:22:43 +0200 Subject: [PATCH 6/6] test: preserve request payload behavior --- .sampo/changesets/steadfast-lady-sampsa.md | 5 +++ posthog/request.py | 4 +- posthog/test/test_request.py | 52 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .sampo/changesets/steadfast-lady-sampsa.md diff --git a/.sampo/changesets/steadfast-lady-sampsa.md b/.sampo/changesets/steadfast-lady-sampsa.md new file mode 100644 index 00000000..cccefa7e --- /dev/null +++ b/.sampo/changesets/steadfast-lady-sampsa.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Improve mypy coverage for core SDK modules without changing runtime behavior. diff --git a/posthog/request.py b/posthog/request.py index 0d611728..25984926 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -219,7 +219,7 @@ def determine_server_host(host: Optional[str]) -> str: def post( api_key: str, host: Optional[str] = None, - path: str = "", + path: Optional[str] = None, gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, @@ -230,7 +230,7 @@ def post( body = kwargs body["sentAt"] = datetime.now(tz=timezone.utc).isoformat() trimmed_host = remove_trailing_slash(normalize_host(host)) - url = trimmed_host + path + url = trimmed_host + cast(str, path) body["api_key"] = api_key data: str | bytes = json.dumps(body, cls=DatetimeSerializer) log.debug("making request: %s to url: %s", data, url) diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index 3529d907..21d67c98 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -79,6 +79,58 @@ def test_invalid_host(self): Exception, batch_post, "testsecret", "t.posthog.com/", batch=[] ) + def test_post_without_path_preserves_type_error(self): + mock_session = mock.MagicMock() + + with self.assertRaises(TypeError): + request_module.post( + TEST_API_KEY, + host="https://test.posthog.com", + session=mock_session, + ) + + mock_session.post.assert_not_called() + + def test_post_sends_string_payload_without_gzip(self): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_session = mock.MagicMock() + mock_session.post.return_value = mock_response + + request_module.post( + TEST_API_KEY, + host="https://test.posthog.com", + path="/batch/", + session=mock_session, + batch=[], + ) + + mock_session.post.assert_called_once() + url = mock_session.post.call_args.args[0] + data = mock_session.post.call_args.kwargs["data"] + self.assertEqual(url, "https://test.posthog.com/batch/") + self.assertIsInstance(data, str) + + def test_post_sends_bytes_payload_with_gzip(self): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_session = mock.MagicMock() + mock_session.post.return_value = mock_response + + request_module.post( + TEST_API_KEY, + host="https://test.posthog.com", + path="/batch/", + gzip=True, + session=mock_session, + batch=[], + ) + + data = mock_session.post.call_args.kwargs["data"] + headers = mock_session.post.call_args.kwargs["headers"] + self.assertIsInstance(data, bytes) + self.assertEqual(headers["Content-Encoding"], "gzip") + def test_datetime_serialization(self): data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)} result = json.dumps(data, cls=DatetimeSerializer)