Skip to content

Commit b5e71bb

Browse files
authored
Merge branch 'master' into err/scopes-and-tags
2 parents 27efc43 + 23e1d8e commit b5e71bb

File tree

8 files changed

+151
-87
lines changed

8 files changed

+151
-87
lines changed

.pre-commit-config.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
repos:
22
- repo: https://github.com/psf/black
3-
rev: stable
3+
rev: 25.1.0
44
hooks:
55
- id: black
66
- repo: https://github.com/pycqa/isort
7-
rev: 5.7.0
7+
rev: 6.0.1
88
hooks:
99
- id: isort
10+
- repo: https://github.com/pycqa/flake8
11+
rev: 7.2.0
12+
hooks:
13+
- id: flake8

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Add exception context management:
77
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
88
- Automatic deduplication of exceptions to ensure each exception is only captured once
99

10+
## 4.2.1 - 2025-6-05
11+
12+
1. fix: feature flag request use geoip_disable (#235)
13+
2. chore: pin actions versions (#210)
14+
3. fix: opinionated setup and clean fn fix (#240)
15+
1016
## 4.2.0 - 2025-05-22
1117

1218
Add support for google gemini

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,22 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
1414
2. Run `source env/bin/activate` (activates the virtual environment)
1515
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
1616
* or `uv pip install -e ".[test]"`
17-
4. Run `make test`
17+
4. you have to run `pre-commit install` to have auto linting pre commit
18+
5. Run `make test`
1819
1. To run a specific test do `pytest -k test_no_api_key`
1920

21+
## PostHog recommends `uv` so...
22+
23+
```bash
24+
uv python install 3.9.19
25+
uv python pin 3.9.19
26+
uv venv env
27+
source env/bin/activate
28+
uv pip install --editable ".[dev,test]"
29+
pre-commit install
30+
make test
31+
```
32+
2033
### Running Locally
2134

2235
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.

mypy-baseline.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
22
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
33
posthog/utils.py:0: error: Statement is unreachable [unreachable]
4-
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
54
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
65
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
76
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
@@ -38,4 +37,4 @@ posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
3837
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
3938
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
4039
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
41-
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
40+
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import unittest
2+
3+
from parameterized import parameterized
4+
5+
from posthog import utils
6+
7+
8+
class TestSizeLimitedDict(unittest.TestCase):
9+
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
10+
def test_size_limited_dict(self, size: int, iterations: int) -> None:
11+
values = utils.SizeLimitedDict(size, lambda _: -1)
12+
13+
for i in range(iterations):
14+
values[i] = i
15+
16+
assert values[i] == i
17+
assert len(values) == i % size + 1
18+
19+
if i % size == 0:
20+
# old numbers should've been removed
21+
self.assertIsNone(values.get(i - 1))
22+
self.assertIsNone(values.get(i - 3))
23+
self.assertIsNone(values.get(i - 5))
24+
self.assertIsNone(values.get(i - 9))

posthog/test/test_utils.py

Lines changed: 72 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import six
99
from dateutil.tz import tzutc
10+
from parameterized import parameterized
1011
from pydantic import BaseModel
1112
from pydantic.v1 import BaseModel as BaseModelV1
1213

@@ -17,17 +18,29 @@
1718

1819

1920
class TestUtils(unittest.TestCase):
21+
@parameterized.expand(
22+
[
23+
("naive datetime should be naive", True),
24+
("timezone-aware datetime should not be naive", False),
25+
]
26+
)
27+
def test_is_naive(self, _name: str, expected_naive: bool):
28+
if expected_naive:
29+
dt = datetime.now() # naive datetime
30+
else:
31+
dt = datetime.now(tz=tzutc()) # timezone-aware datetime
32+
33+
assert utils.is_naive(dt) is expected_naive
34+
2035
def test_timezone_utils(self):
2136
now = datetime.now()
2237
utcnow = datetime.now(tz=tzutc())
23-
self.assertTrue(utils.is_naive(now))
24-
self.assertFalse(utils.is_naive(utcnow))
2538

2639
fixed = utils.guess_timezone(now)
27-
self.assertFalse(utils.is_naive(fixed))
40+
assert utils.is_naive(fixed) is False
2841

2942
shouldnt_be_edited = utils.guess_timezone(utcnow)
30-
self.assertEqual(utcnow, shouldnt_be_edited)
43+
assert utcnow == shouldnt_be_edited
3144

3245
def test_clean(self):
3346
simple = {
@@ -54,39 +67,37 @@ def test_clean(self):
5467
pre_clean_keys = combined.keys()
5568

5669
utils.clean(combined)
57-
self.assertEqual(combined.keys(), pre_clean_keys)
70+
assert combined.keys() == pre_clean_keys
5871

5972
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
60-
self.assertEqual(
61-
utils.clean(UUID("12345678123456781234567812345678")),
62-
"12345678-1234-5678-1234-567812345678",
63-
)
73+
assert utils.clean(UUID("12345678123456781234567812345678")) == "12345678-1234-5678-1234-567812345678"
6474

6575
def test_clean_with_dates(self):
6676
dict_with_dates = {
6777
"birthdate": date(1980, 1, 1),
6878
"registration": datetime.now(tz=tzutc()),
6979
}
70-
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
80+
assert dict_with_dates == utils.clean(dict_with_dates)
7181

7282
def test_bytes(self):
73-
if six.PY3:
74-
item = bytes(10)
75-
else:
76-
item = bytearray(10)
77-
83+
item = bytes(10)
7884
utils.clean(item)
85+
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
7986

8087
def test_clean_fn(self):
8188
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
82-
self.assertEqual(cleaned["number"], 4)
83-
# TODO: fixme, different behavior on python 2 and 3
84-
if "fn" in cleaned:
85-
self.assertEqual(cleaned["fn"], None)
86-
87-
def test_remove_slash(self):
88-
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
89-
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
89+
assert cleaned == {"fn": None, "number": 4}
90+
91+
@parameterized.expand(
92+
[
93+
("http://posthog.io/", "http://posthog.io"),
94+
("http://posthog.io", "http://posthog.io"),
95+
("https://example.com/path/", "https://example.com/path"),
96+
("https://example.com/path", "https://example.com/path"),
97+
]
98+
)
99+
def test_remove_slash(self, input_url, expected_url):
100+
assert expected_url == utils.remove_trailing_slash(input_url)
90101

91102
def test_clean_pydantic(self):
92103
class ModelV2(BaseModel):
@@ -101,19 +112,26 @@ class ModelV1(BaseModelV1):
101112
class NestedModel(BaseModel):
102113
foo: ModelV2
103114

104-
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
105-
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
106-
self.assertEqual(
107-
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))),
108-
{"foo": {"foo": "1", "bar": 2, "baz": "3"}},
109-
)
115+
assert utils.clean(ModelV2(foo="1", bar=2)) == {
116+
"foo": "1",
117+
"bar": 2,
118+
"baz": None,
119+
}
120+
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
121+
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
122+
"foo": {"foo": "1", "bar": 2, "baz": "3"}
123+
}
110124

125+
def test_clean_pydantic_like_class(self) -> None:
111126
class Dummy:
112-
def model_dump(self, required_param):
113-
pass
127+
def model_dump(self, required_param: str) -> dict:
128+
return {}
114129

115-
# Skips a class with a defined non-Pydantic `model_dump` method.
116-
self.assertEqual(utils.clean({"test": Dummy()}), {})
130+
# previously python 2 code would cause an error while cleaning,
131+
# and this entire object would be None, and we would log an error
132+
# let's allow ourselves to clean `Dummy` as None,
133+
# without blatting the `test` key
134+
assert utils.clean({"test": Dummy()}) == {"test": None}
117135

118136
def test_clean_dataclass(self):
119137
@dataclass
@@ -130,47 +148,25 @@ class TestDataClass:
130148
bar: int
131149
nested: InnerDataClass
132150

133-
self.assertEqual(
134-
utils.clean(
135-
TestDataClass(
136-
foo="1",
137-
bar=2,
138-
nested=InnerDataClass(
139-
inner_foo="3",
140-
inner_bar=4,
141-
inner_uuid=UUID("12345678123456781234567812345678"),
142-
inner_date=datetime(2025, 1, 1),
143-
),
144-
)
145-
),
146-
{
147-
"foo": "1",
148-
"bar": 2,
149-
"nested": {
150-
"inner_foo": "3",
151-
"inner_bar": 4,
152-
"inner_uuid": "12345678-1234-5678-1234-567812345678",
153-
"inner_date": datetime(2025, 1, 1),
154-
"inner_optional": None,
155-
},
151+
assert utils.clean(
152+
TestDataClass(
153+
foo="1",
154+
bar=2,
155+
nested=InnerDataClass(
156+
inner_foo="3",
157+
inner_bar=4,
158+
inner_uuid=UUID("12345678123456781234567812345678"),
159+
inner_date=datetime(2025, 1, 1),
160+
),
161+
)
162+
) == {
163+
"foo": "1",
164+
"bar": 2,
165+
"nested": {
166+
"inner_foo": "3",
167+
"inner_bar": 4,
168+
"inner_uuid": "12345678-1234-5678-1234-567812345678",
169+
"inner_date": datetime(2025, 1, 1),
170+
"inner_optional": None,
156171
},
157-
)
158-
159-
160-
class TestSizeLimitedDict(unittest.TestCase):
161-
def test_size_limited_dict(self):
162-
size = 10
163-
values = utils.SizeLimitedDict(size, lambda _: -1)
164-
165-
for i in range(100):
166-
values[i] = i
167-
168-
self.assertEqual(values[i], i)
169-
self.assertEqual(len(values), i % size + 1)
170-
171-
if i % size == 0:
172-
# old numbers should've been removed
173-
self.assertIsNone(values.get(i - 1))
174-
self.assertIsNone(values.get(i - 3))
175-
self.assertIsNone(values.get(i - 5))
176-
self.assertIsNone(values.get(i - 9))
172+
}

posthog/utils.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import asdict, is_dataclass
66
from datetime import date, datetime, timezone
77
from decimal import Decimal
8+
from typing import Any, Optional
89
from uuid import UUID
910

1011
import six
@@ -99,14 +100,35 @@ def _clean_dataclass(dataclass_):
99100
return data
100101

101102

102-
def _coerce_unicode(cmplx):
103+
def _coerce_unicode(cmplx: Any) -> Optional[str]:
104+
"""
105+
In theory, this method is only called
106+
after many isinstance checks are carried out in `utils.clean`.
107+
When we supported Python 2 it was safe to call `decode` on a `str`
108+
but in Python 3 that will throw.
109+
So, we check if the input is bytes and only call `decode` in that case.
110+
111+
Previously we would always call `decode` on the input
112+
That would throw an error.
113+
Then we would call `decode` on the stringified error
114+
That would throw an error.
115+
And then we would return `None`
116+
117+
To avoid a breaking change, we can maintain the behavior
118+
that anything which did not have `decode` in Python 2
119+
returns None.
120+
"""
121+
item = None
103122
try:
104-
item = cmplx.decode("utf-8", "strict")
105-
except AttributeError as exception:
106-
item = ":".join(exception)
107-
item.decode("utf-8", "strict") # type: ignore
123+
if isinstance(cmplx, bytes):
124+
item = cmplx.decode("utf-8", "strict")
125+
elif isinstance(cmplx, str):
126+
item = cmplx
127+
except Exception as exception:
128+
item = ":".join(map(str, exception.args))
108129
log.warning("Error decoding: %s", item)
109130
return None
131+
110132
return item
111133

112134

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "4.2.0"
1+
VERSION = "4.2.1"
22

33
if __name__ == "__main__":
44
print(VERSION, end="") # noqa: T201

0 commit comments

Comments
 (0)