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/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/posthog/client.py b/posthog/client.py index bf967449..49eb878d 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 @@ -283,7 +281,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() @@ -297,8 +295,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 @@ -1250,7 +1250,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: @@ -1376,7 +1376,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 @@ -2805,10 +2805,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/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 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 diff --git a/posthog/request.py b/posthog/request.py index 76f0a9fe..25984926 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -6,10 +6,10 @@ 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 # 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: Optional[str] = None, gzip: bool = False, timeout: int = 15, session: Optional[requests.Session] = None, @@ -230,9 +230,9 @@ 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 = 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} if gzip: @@ -241,7 +241,7 @@ def post( 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")) + gz.write(cast(str, data).encode("utf-8")) data = buf.getvalue() res = (session or _get_session()).post( 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) 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): ...