chore: 添加虚拟环境到仓库
- 添加 backend_service/venv 虚拟环境 - 包含所有Python依赖包 - 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import logging
|
||||
import pkgutil
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
def all_names():
|
||||
for _, modname, _ in pkgutil.iter_modules(__path__):
|
||||
yield "posthog.test." + modname
|
||||
|
||||
|
||||
def all():
|
||||
logging.basicConfig(stream=sys.stderr)
|
||||
return unittest.defaultTestLoader.loadTestsFromNames(all_names())
|
||||
@@ -0,0 +1,171 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
|
||||
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
|
||||
cls.client_post_patcher.start()
|
||||
cls.consumer_post_patcher.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.client_post_patcher.stop()
|
||||
cls.consumer_post_patcher.stop()
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_before_send_callback_modifies_event(self):
|
||||
"""Test that before_send callback can modify events."""
|
||||
processed_events = []
|
||||
|
||||
def my_before_send(event):
|
||||
processed_events.append(event.copy())
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["processed_by_before_send"] = True
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
|
||||
)
|
||||
success, msg = client.capture("user1", "test_event", {"original": "value"})
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
|
||||
self.assertEqual(msg["properties"]["original"], "value")
|
||||
self.assertEqual(len(processed_events), 1)
|
||||
self.assertEqual(processed_events[0]["event"], "test_event")
|
||||
|
||||
def test_before_send_callback_drops_event(self):
|
||||
"""Test that before_send callback can drop events by returning None."""
|
||||
|
||||
def drop_test_events(event):
|
||||
if event.get("event") == "test_drop_me":
|
||||
return None
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
|
||||
)
|
||||
|
||||
# Event should be dropped
|
||||
success, msg = client.capture("user1", "test_drop_me")
|
||||
self.assertTrue(success)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
# Event should go through
|
||||
success, msg = client.capture("user1", "keep_me")
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "keep_me")
|
||||
|
||||
def test_before_send_callback_handles_exceptions(self):
|
||||
"""Test that exceptions in before_send don't crash the client."""
|
||||
|
||||
def buggy_before_send(event):
|
||||
raise ValueError("Oops!")
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
|
||||
)
|
||||
success, msg = client.capture("user1", "robust_event")
|
||||
|
||||
# Event should still be sent despite the exception
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "robust_event")
|
||||
|
||||
def test_before_send_callback_works_with_all_event_types(self):
|
||||
"""Test that before_send works with capture, identify, set, etc."""
|
||||
|
||||
def add_marker(event):
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["marked"] = True
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
|
||||
)
|
||||
|
||||
# Test capture
|
||||
success, msg = client.capture("user1", "event")
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test identify
|
||||
success, msg = client.identify("user1", {"trait": "value"})
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test set
|
||||
success, msg = client.set("user1", {"prop": "value"})
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
# Test page
|
||||
success, msg = client.page("user1", "https://example.com")
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(msg["properties"]["marked"])
|
||||
|
||||
def test_before_send_callback_disabled_when_none(self):
|
||||
"""Test that client works normally when before_send is None."""
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
|
||||
success, msg = client.capture("user1", "normal_event")
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["event"], "normal_event")
|
||||
|
||||
def test_before_send_callback_pii_scrubbing_example(self):
|
||||
"""Test a realistic PII scrubbing use case."""
|
||||
|
||||
def scrub_pii(event):
|
||||
properties = event.get("properties", {})
|
||||
|
||||
# Mask email but keep domain
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
if "@" in email:
|
||||
domain = email.split("@")[1]
|
||||
properties["email"] = f"***@{domain}"
|
||||
else:
|
||||
properties["email"] = "***"
|
||||
|
||||
# Remove credit card
|
||||
properties.pop("credit_card", None)
|
||||
|
||||
return event
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
|
||||
)
|
||||
success, msg = client.capture(
|
||||
"user1",
|
||||
"form_submit",
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"credit_card": "1234-5678-9012-3456",
|
||||
"form_name": "contact",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg["properties"]["email"], "***@example.com")
|
||||
self.assertNotIn("credit_card", msg["properties"])
|
||||
self.assertEqual(msg["properties"]["form_name"], "contact")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue
|
||||
|
||||
from posthog.consumer import MAX_MSG_SIZE, Consumer
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestConsumer(unittest.TestCase):
|
||||
def test_next(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, "")
|
||||
q.put(1)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [1])
|
||||
|
||||
def test_next_limit(self):
|
||||
q = Queue()
|
||||
flush_at = 50
|
||||
consumer = Consumer(q, "", flush_at)
|
||||
for i in range(10000):
|
||||
q.put(i)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, list(range(flush_at)))
|
||||
|
||||
def test_dropping_oversize_msg(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, "")
|
||||
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
|
||||
q.put(oversize_msg)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [])
|
||||
self.assertTrue(q.empty())
|
||||
|
||||
def test_upload(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY)
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
q.put(track)
|
||||
success = consumer.upload()
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_flush_interval(self):
|
||||
# Put _n_ items in the queue, pausing a little bit more than
|
||||
# _flush_interval_ after each one.
|
||||
# The consumer should upload _n_ times.
|
||||
q = Queue()
|
||||
flush_interval = 0.3
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, 3):
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 3)
|
||||
|
||||
def test_multiple_uploads_per_interval(self):
|
||||
# Put _flush_at*2_ items in the queue at once, then pause for
|
||||
# _flush_interval_. The consumer should upload 2 times.
|
||||
q = Queue()
|
||||
flush_interval = 0.5
|
||||
flush_at = 10
|
||||
consumer = Consumer(
|
||||
q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval
|
||||
)
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, flush_at * 2):
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
def test_request(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
consumer.request([track])
|
||||
|
||||
def _test_request_retry(self, consumer, expected_exception, exception_count):
|
||||
def mock_post(*args, **kwargs):
|
||||
mock_post.call_count += 1
|
||||
if mock_post.call_count <= exception_count:
|
||||
raise expected_exception
|
||||
|
||||
mock_post.call_count = 0
|
||||
|
||||
with mock.patch(
|
||||
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
|
||||
):
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
# request() should succeed if the number of exceptions raised is
|
||||
# less than the retries paramater.
|
||||
if exception_count <= consumer.retries:
|
||||
consumer.request([track])
|
||||
else:
|
||||
# if exceptions are raised more times than the retries
|
||||
# parameter, we expect the exception to be returned to
|
||||
# the caller.
|
||||
try:
|
||||
consumer.request([track])
|
||||
except type(expected_exception) as exc:
|
||||
self.assertEqual(exc, expected_exception)
|
||||
else:
|
||||
self.fail(
|
||||
"request() should raise an exception if still failing after %d retries"
|
||||
% consumer.retries
|
||||
)
|
||||
|
||||
def test_request_retry(self):
|
||||
# we should retry on general errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, Exception("generic exception"), 2)
|
||||
|
||||
# we should retry on server errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
|
||||
|
||||
# we should retry on HTTP 429 errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
|
||||
|
||||
# we should NOT retry on other client errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
api_error = APIError(400, "Client Errors")
|
||||
try:
|
||||
self._test_request_retry(consumer, api_error, 1)
|
||||
except APIError:
|
||||
pass
|
||||
else:
|
||||
self.fail("request() should not retry on client errors")
|
||||
|
||||
# test for number of exceptions raise > retries value
|
||||
consumer = Consumer(None, TEST_API_KEY, retries=3)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
|
||||
|
||||
def test_pause(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
consumer.pause()
|
||||
self.assertFalse(consumer.running)
|
||||
|
||||
def test_max_batch_size(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
properties = {}
|
||||
for n in range(0, 500):
|
||||
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
"properties": properties,
|
||||
}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
|
||||
def mock_post_fn(_, data, **kwargs):
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(
|
||||
request_size < (5 * 1024 * 1024) * 1.1,
|
||||
"batch size (%d) higher than limit" % request_size,
|
||||
)
|
||||
return res
|
||||
|
||||
with mock.patch(
|
||||
"posthog.request._session.post", side_effect=mock_post_fn
|
||||
) as mock_post:
|
||||
consumer.start()
|
||||
for _ in range(0, n_msgs + 2):
|
||||
q.put(track)
|
||||
q.join()
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
@@ -0,0 +1,63 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_excepthook(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"LOL" in output
|
||||
assert b"DEBUG:posthog:data uploaded successfully" in output
|
||||
assert (
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
|
||||
|
||||
def test_trying_to_use_django_integration(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog, Integrations
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"LOL" in output
|
||||
assert b"DEBUG:posthog:data uploaded successfully" in output
|
||||
assert (
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import unittest
|
||||
|
||||
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
|
||||
|
||||
|
||||
class TestFeatureFlag(unittest.TestCase):
|
||||
def test_feature_flag_from_json(self):
|
||||
# Test with full metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"payload": '{"some": "json"}',
|
||||
"version": 2,
|
||||
"description": "test-description",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
|
||||
def test_feature_flag_from_json_minimal(self):
|
||||
# Test with minimal required fields
|
||||
resp = {"key": "test-flag", "enabled": True}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertEqual(flag.get_value(), True)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_feature_flag_from_json_without_metadata(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_flag_reason_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertEqual(reason.condition_index, 1)
|
||||
self.assertEqual(reason.description, "User is in segment 'beta_users'")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"code": "user_in_segment"}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertIsNone(reason.condition_index) # default value
|
||||
self.assertEqual(reason.description, "")
|
||||
|
||||
# Test with None
|
||||
self.assertIsNone(FlagReason.from_json(None))
|
||||
|
||||
def test_flag_metadata_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertEqual(metadata.payload, {"key": "value"})
|
||||
self.assertEqual(metadata.version, 1)
|
||||
self.assertEqual(metadata.description, "Test flag")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"id": 123}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertIsNone(metadata.payload)
|
||||
self.assertEqual(metadata.version, 0) # default value
|
||||
self.assertEqual(metadata.description, "") # default value
|
||||
|
||||
# Test with None
|
||||
self.assertIsInstance(FlagMetadata.from_json(None), LegacyFlagMetadata)
|
||||
|
||||
def test_feature_flag_from_json_complete(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "control",
|
||||
"reason": {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "control")
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, FlagMetadata)
|
||||
self.assertEqual(flag.metadata.id, 123)
|
||||
self.assertEqual(flag.metadata.payload, {"key": "value"})
|
||||
|
||||
def test_feature_flag_from_json_minimal_data(self):
|
||||
# Test with minimal data
|
||||
resp = {"key": "test-flag", "enabled": False}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertFalse(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
|
||||
def test_feature_flag_from_json_with_reason(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"reason": {"code": "user_in_segment"},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
@@ -0,0 +1,444 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
|
||||
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
def test_from_bool_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, "[1, 2, 3]"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, [1, 2, 3])
|
||||
|
||||
def test_from_false_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", False, '{"some": "value"}'
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", "control", "true"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, True)
|
||||
|
||||
def test_from_none_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", None, '{"some": "value"}'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_boolean_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_variant_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value="control"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=True
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_boolean_flag_details_with_override_false_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=False
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_none_flag_details(self):
|
||||
result = FeatureFlagResult.from_flag_details(None)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_flag_details_with_none_payload(self):
|
||||
flag_details = FeatureFlag(
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload=None
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertIsNone(result.payload)
|
||||
|
||||
|
||||
class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||||
cls.capture_patch.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.capture_patch.stop()
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print("FAIL", e, batch) # noqa: T201
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": "300"},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "variant-1", "rollout_percentage": 50},
|
||||
{"key": "variant-2", "rollout_percentage": 50},
|
||||
]
|
||||
},
|
||||
"payloads": {"variant-1": '{"some": "value"}'},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "distinct_id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, {"some": "value"})
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_payload": {"some": "value"},
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
another_flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(another_flag_result.enabled, True)
|
||||
self.assertEqual(another_flag_result.variant, "variant-2")
|
||||
self.assertEqual(another_flag_result.get_value(), "variant-2")
|
||||
self.assertIsNone(another_flag_result.payload)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"another-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-2",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-2",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id"
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": "variant-1",
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"version": 2,
|
||||
"payload": "[1, 2, 3]",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, [1, 2, 3])
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 2,
|
||||
"$feature_flag_payload": [1, 2, 3],
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"no-person-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
"$feature_flag": "no-person-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/no-person-flag": None,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import unittest
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
posthog = None
|
||||
|
||||
def _assert_enqueue_result(self, result):
|
||||
self.assertEqual(type(result[0]), bool)
|
||||
self.assertEqual(type(result[1]), dict)
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.posthog = Posthog(
|
||||
"testsecret", host="http://localhost:8000", on_error=self.failed
|
||||
)
|
||||
|
||||
def test_no_api_key(self):
|
||||
self.posthog.api_key = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
self.posthog.host = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
res = self.posthog.capture("distinct_id", "python module event")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
res = self.posthog.alias("previousId", "distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
self.posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
self.posthog.flush()
|
||||
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import (
|
||||
DatetimeSerializer,
|
||||
QuotaLimitError,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
)
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]"
|
||||
)
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "t.posthog.com/", batch=[]
|
||||
)
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {"created": today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post(
|
||||
"key",
|
||||
batch=[
|
||||
{
|
||||
"distinct_id": "distinct_id",
|
||||
"event": "python event",
|
||||
"type": "track",
|
||||
}
|
||||
],
|
||||
timeout=0.0001,
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"featureFlags": {"flag1": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
[
|
||||
("https://t.posthog.com", "https://t.posthog.com"),
|
||||
("https://t.posthog.com/", "https://t.posthog.com/"),
|
||||
("t.posthog.com", "t.posthog.com"),
|
||||
("t.posthog.com/", "t.posthog.com/"),
|
||||
("https://us.posthog.com.rg.proxy.com", "https://us.posthog.com.rg.proxy.com"),
|
||||
("app.posthog.com", "app.posthog.com"),
|
||||
("eu.posthog.com", "eu.posthog.com"),
|
||||
("https://app.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://app.posthog.com/", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com/", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com/", "https://us.i.posthog.com"),
|
||||
(None, "https://us.i.posthog.com"),
|
||||
],
|
||||
)
|
||||
def test_routing_to_custom_host(host, expected):
|
||||
assert determine_server_host(host) == expected
|
||||
@@ -0,0 +1,220 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from posthog.scopes import (
|
||||
clear_tags,
|
||||
get_tags,
|
||||
new_context,
|
||||
scoped,
|
||||
tag,
|
||||
identify_context,
|
||||
set_context_session,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
|
||||
|
||||
class TestScopes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Reset any context between tests
|
||||
clear_tags()
|
||||
|
||||
def test_tag_and_get_tags(self):
|
||||
with new_context(fresh=True):
|
||||
tag("key1", "value1")
|
||||
tag("key2", 2)
|
||||
|
||||
tags = get_tags()
|
||||
assert tags["key1"] == "value1"
|
||||
assert tags["key2"] == 2
|
||||
|
||||
def test_clear_tags(self):
|
||||
with new_context(fresh=True):
|
||||
tag("key1", "value1")
|
||||
assert get_tags()["key1"] == "value1"
|
||||
|
||||
clear_tags()
|
||||
assert get_tags() == {}
|
||||
|
||||
def test_new_context_isolation(self):
|
||||
with new_context(fresh=True):
|
||||
# Set tag in outer context
|
||||
tag("outer", "value")
|
||||
|
||||
with new_context(fresh=True):
|
||||
# Inner context should start empty
|
||||
assert get_tags() == {}
|
||||
|
||||
# Set tag in inner context
|
||||
tag("inner", "value")
|
||||
assert get_tags()["inner"] == "value"
|
||||
|
||||
# Outer tag should not be visible
|
||||
self.assertNotIn("outer", get_tags())
|
||||
|
||||
with new_context(fresh=False):
|
||||
# Inner context should inherit outer tag
|
||||
assert get_tags() == {"outer": "value"}
|
||||
|
||||
# After exiting context, inner tag should be gone
|
||||
self.assertNotIn("inner", get_tags())
|
||||
|
||||
# Outer tag should still be there
|
||||
assert get_tags()["outer"] == "value"
|
||||
|
||||
def test_nested_contexts(self):
|
||||
with new_context(fresh=True):
|
||||
tag("level1", "value1")
|
||||
|
||||
with new_context(fresh=True):
|
||||
tag("level2", "value2")
|
||||
|
||||
with new_context(fresh=True):
|
||||
tag("level3", "value3")
|
||||
assert get_tags() == {"level3": "value3"}
|
||||
|
||||
# Back to level 2
|
||||
assert get_tags() == {"level2": "value2"}
|
||||
|
||||
# Back to level 1
|
||||
assert get_tags() == {"level1": "value1"}
|
||||
|
||||
@patch("posthog.capture_exception")
|
||||
def test_scoped_decorator_success(self, mock_capture):
|
||||
@scoped()
|
||||
def successful_function(x, y):
|
||||
tag("x", x)
|
||||
tag("y", y)
|
||||
return x + y
|
||||
|
||||
result = successful_function(1, 2)
|
||||
|
||||
# Function should execute normally
|
||||
assert result == 3
|
||||
|
||||
# No exception should be captured
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_tags() == {}
|
||||
|
||||
@patch("posthog.capture_exception")
|
||||
def test_scoped_decorator_exception(self, mock_capture):
|
||||
test_exception = ValueError("Test exception")
|
||||
|
||||
def check_context_on_capture(exception, **kwargs):
|
||||
# Assert tags are available when capture_exception is called
|
||||
current_tags = get_tags()
|
||||
assert current_tags.get("important_context") == "value"
|
||||
|
||||
mock_capture.side_effect = check_context_on_capture
|
||||
|
||||
@scoped()
|
||||
def failing_function():
|
||||
tag("important_context", "value")
|
||||
raise test_exception
|
||||
|
||||
# Function should raise the exception
|
||||
with self.assertRaises(ValueError):
|
||||
failing_function()
|
||||
|
||||
# Verify capture_exception was called
|
||||
mock_capture.assert_called_once_with(test_exception)
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_tags() == {}
|
||||
|
||||
@patch("posthog.capture_exception")
|
||||
def test_new_context_exception_handling(self, mock_capture):
|
||||
test_exception = RuntimeError("Context exception")
|
||||
|
||||
def check_context_on_capture(exception, **kwargs):
|
||||
# Assert inner context tags are available when capture_exception is called
|
||||
current_tags = get_tags()
|
||||
assert current_tags.get("inner_context") == "inner_value"
|
||||
|
||||
mock_capture.side_effect = check_context_on_capture
|
||||
|
||||
# Set up outer context
|
||||
with new_context():
|
||||
tag("outer_context", "outer_value")
|
||||
|
||||
try:
|
||||
with new_context():
|
||||
tag("inner_context", "inner_value")
|
||||
raise test_exception
|
||||
except RuntimeError:
|
||||
pass # Expected exception
|
||||
|
||||
# Outer context should still be intact
|
||||
assert get_tags()["outer_context"] == "outer_value"
|
||||
|
||||
# Verify capture_exception was called
|
||||
mock_capture.assert_called_once_with(test_exception)
|
||||
|
||||
def test_identify_context(self):
|
||||
with new_context(fresh=True):
|
||||
# Initially no distinct ID
|
||||
assert get_context_distinct_id() is None
|
||||
|
||||
# Set distinct ID
|
||||
identify_context("user123")
|
||||
assert get_context_distinct_id() == "user123"
|
||||
|
||||
def test_set_context_session(self):
|
||||
with new_context(fresh=True):
|
||||
# Initially no session ID
|
||||
assert get_context_session_id() is None
|
||||
|
||||
# Set session ID
|
||||
set_context_session("session456")
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_context_inheritance_fresh_context(self):
|
||||
with new_context(fresh=True):
|
||||
identify_context("user123")
|
||||
set_context_session("session456")
|
||||
|
||||
with new_context(fresh=True):
|
||||
# Fresh context should not inherit
|
||||
assert get_context_distinct_id() is None
|
||||
assert get_context_session_id() is None
|
||||
|
||||
# Original context should still have values
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_context_inheritance_non_fresh_context(self):
|
||||
with new_context(fresh=True):
|
||||
identify_context("user123")
|
||||
set_context_session("session456")
|
||||
|
||||
with new_context(fresh=False):
|
||||
# Non-fresh context should inherit
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
# Override in child context
|
||||
identify_context("user789")
|
||||
set_context_session("session999")
|
||||
assert get_context_distinct_id() == "user789"
|
||||
assert get_context_session_id() == "session999"
|
||||
|
||||
# Original context should still have original values
|
||||
assert get_context_distinct_id() == "user123"
|
||||
assert get_context_session_id() == "session456"
|
||||
|
||||
def test_scoped_decorator_with_context_ids(self):
|
||||
@scoped()
|
||||
def function_with_context():
|
||||
identify_context("user456")
|
||||
set_context_session("session789")
|
||||
return get_context_distinct_id(), get_context_session_id()
|
||||
|
||||
distinct_id, session_id = function_with_context()
|
||||
assert distinct_id == "user456"
|
||||
assert session_id == "session789"
|
||||
|
||||
# Context should be cleared after function execution
|
||||
assert get_context_distinct_id() is None
|
||||
assert get_context_session_id() is None
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog import utils
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
|
||||
def test_size_limited_dict(self, size: int, iterations: int) -> None:
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
|
||||
for i in range(iterations):
|
||||
values[i] = i
|
||||
|
||||
assert values[i] == i
|
||||
assert len(values) == i % size + 1
|
||||
|
||||
if i % size == 0:
|
||||
# old numbers should've been removed
|
||||
self.assertIsNone(values.get(i - 1))
|
||||
self.assertIsNone(values.get(i - 3))
|
||||
self.assertIsNone(values.get(i - 5))
|
||||
self.assertIsNone(values.get(i - 9))
|
||||
@@ -0,0 +1,208 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
LegacyFlagMetadata,
|
||||
normalize_flags_response,
|
||||
to_flags_and_payloads,
|
||||
)
|
||||
|
||||
|
||||
class TestTypes(unittest.TestCase):
|
||||
@parameterized.expand([(True,), (False,)])
|
||||
def test_normalize_decide_response_v4(self, has_errors: bool):
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-flag": FeatureFlag(
|
||||
key="my-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
},
|
||||
"errorsWhileComputingFlags": has_errors,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
|
||||
def test_normalize_decide_response_legacy(self):
|
||||
# Test legacy response format with "featureFlags" and "featureFlagPayloads"
|
||||
resp = {
|
||||
"featureFlags": {"my-flag": "test-variant"},
|
||||
"featureFlagPayloads": {"my-flag": '{"some": "json-payload"}'},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(
|
||||
flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}')
|
||||
)
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
# Verify legacy fields are removed
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_normalize_decide_response_boolean_flag(self):
|
||||
# Test legacy response with boolean flag
|
||||
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
self.assertIn("requestId", result)
|
||||
self.assertIsNone(result["requestId"])
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_to_flags_and_payloads_v4(self):
|
||||
# Test v4 response format
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-variant-flag": FeatureFlag(
|
||||
key="my-variant-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
),
|
||||
"my-boolean-flag": FeatureFlag(
|
||||
key="my-boolean-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1, payload=None, version=2, description="test-description"
|
||||
),
|
||||
),
|
||||
"disabled-flag": FeatureFlag(
|
||||
key="disabled-flag",
|
||||
enabled=False,
|
||||
variant=None,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(payload=None),
|
||||
),
|
||||
},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant")
|
||||
self.assertEqual(result["featureFlags"]["my-boolean-flag"], True)
|
||||
self.assertEqual(result["featureFlags"]["disabled-flag"], False)
|
||||
self.assertEqual(
|
||||
result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}'
|
||||
)
|
||||
self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
|
||||
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
|
||||
|
||||
def test_to_flags_and_payloads_empty(self):
|
||||
# Test empty response
|
||||
resp = {
|
||||
"flags": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
self.assertEqual(result["featureFlags"], {})
|
||||
self.assertEqual(result["featureFlagPayloads"], {})
|
||||
|
||||
def test_to_flags_and_payloads_with_payload(self):
|
||||
resp = {
|
||||
"flags": {
|
||||
"decide-flag": {
|
||||
"key": "decide-flag",
|
||||
"enabled": True,
|
||||
"variant": "decide-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": '{"foo": "bar"}',
|
||||
},
|
||||
}
|
||||
},
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
}
|
||||
|
||||
normalized = normalize_flags_response(resp)
|
||||
result = to_flags_and_payloads(normalized)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
|
||||
self.assertEqual(result["featureFlagPayloads"]["decide-flag"], '{"foo": "bar"}')
|
||||
@@ -0,0 +1,175 @@
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzutc
|
||||
from parameterized import parameterized
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
FAKE_TEST_API_KEY = "random_key"
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@parameterized.expand(
|
||||
[
|
||||
("naive datetime should be naive", True),
|
||||
("timezone-aware datetime should not be naive", False),
|
||||
]
|
||||
)
|
||||
def test_is_naive(self, _name: str, expected_naive: bool):
|
||||
if expected_naive:
|
||||
dt = datetime.now() # naive datetime
|
||||
else:
|
||||
dt = datetime.now(tz=tzutc()) # timezone-aware datetime
|
||||
|
||||
assert utils.is_naive(dt) is expected_naive
|
||||
|
||||
def test_timezone_utils(self):
|
||||
now = datetime.now()
|
||||
utcnow = datetime.now(tz=tzutc())
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
assert utils.is_naive(fixed) is False
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
assert utcnow == shouldnt_be_edited
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
"decimal": Decimal("0.142857"),
|
||||
"unicode": six.u("woo"),
|
||||
"date": datetime.now(),
|
||||
"long": 200000000,
|
||||
"integer": 1,
|
||||
"float": 2.0,
|
||||
"bool": True,
|
||||
"str": "woo",
|
||||
"none": None,
|
||||
}
|
||||
|
||||
complicated = {
|
||||
"exception": Exception("This should show up"),
|
||||
"timedelta": timedelta(microseconds=20),
|
||||
"list": [1, 2, 3],
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
assert combined.keys() == pre_clean_keys
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
assert (
|
||||
utils.clean(UUID("12345678123456781234567812345678"))
|
||||
== "12345678-1234-5678-1234-567812345678"
|
||||
)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
assert dict_with_dates == utils.clean(dict_with_dates)
|
||||
|
||||
def test_bytes(self):
|
||||
item = bytes(10)
|
||||
utils.clean(item)
|
||||
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
|
||||
assert cleaned == {"fn": None, "number": 4}
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
("http://posthog.io/", "http://posthog.io"),
|
||||
("http://posthog.io", "http://posthog.io"),
|
||||
("https://example.com/path/", "https://example.com/path"),
|
||||
("https://example.com/path", "https://example.com/path"),
|
||||
]
|
||||
)
|
||||
def test_remove_slash(self, input_url, expected_url):
|
||||
assert expected_url == utils.remove_trailing_slash(input_url)
|
||||
|
||||
def test_clean_pydantic(self):
|
||||
class ModelV2(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
baz: Optional[str] = None
|
||||
|
||||
class ModelV1(BaseModelV1):
|
||||
foo: int
|
||||
bar: str
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
foo: ModelV2
|
||||
|
||||
assert utils.clean(ModelV2(foo="1", bar=2)) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"baz": None,
|
||||
}
|
||||
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
||||
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
|
||||
"foo": {"foo": "1", "bar": 2, "baz": "3"}
|
||||
}
|
||||
|
||||
def test_clean_pydantic_like_class(self) -> None:
|
||||
class Dummy:
|
||||
def model_dump(self, required_param: str) -> dict:
|
||||
return {}
|
||||
|
||||
# previously python 2 code would cause an error while cleaning,
|
||||
# and this entire object would be None, and we would log an error
|
||||
# let's allow ourselves to clean `Dummy` as None,
|
||||
# without blatting the `test` key
|
||||
assert utils.clean({"test": Dummy()}) == {"test": None}
|
||||
|
||||
def test_clean_dataclass(self):
|
||||
@dataclass
|
||||
class InnerDataClass:
|
||||
inner_foo: str
|
||||
inner_bar: int
|
||||
inner_uuid: UUID
|
||||
inner_date: datetime
|
||||
inner_optional: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class TestDataClass:
|
||||
foo: str
|
||||
bar: int
|
||||
nested: InnerDataClass
|
||||
|
||||
assert utils.clean(
|
||||
TestDataClass(
|
||||
foo="1",
|
||||
bar=2,
|
||||
nested=InnerDataClass(
|
||||
inner_foo="3",
|
||||
inner_bar=4,
|
||||
inner_uuid=UUID("12345678123456781234567812345678"),
|
||||
inner_date=datetime(2025, 1, 1),
|
||||
),
|
||||
)
|
||||
) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"nested": {
|
||||
"inner_foo": "3",
|
||||
"inner_bar": 4,
|
||||
"inner_uuid": "12345678-1234-5678-1234-567812345678",
|
||||
"inner_date": datetime(2025, 1, 1),
|
||||
"inner_optional": None,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user