Compare commits

...
11 Commits
13 changed files with 876 additions and 109 deletions
+43 -1
View File
@@ -1,3 +1,45 @@
## 3.5.0 - 2024-02-29
1. - Adds a new `feature_flags_request_timeout_seconds` timeout parameter for feature flags which defaults to 3 seconds, updated from the default 10s for all other API calls.
## 3.4.2 - 2024-02-20
1. Add `historical_migration` option for bulk migration to PostHog Cloud.
## 3.4.1 - 2024-02-09
1. Use new hosts for event capture as well
## 3.4.0 - 2024-02-05
1. Point given hosts to new ingestion hosts
## 3.3.4 - 2024-01-30
1. Update type hints for module variables to work with newer versions of mypy
## 3.3.3 - 2024-01-26
1. Remove new relative date operators, combine into regular date operators
## 3.3.2 - 2024-01-19
1. Return success/failure with all capture calls from module functions
## 3.3.1 - 2024-01-10
1. Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
## 3.3.0 - 2024-01-09
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
## 3.2.0 - 2024-01-09
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
2. Add support for relative date operators for local evaluation.
## 3.1.0 - 2023-12-04
1. Increase maximum event size and batch size
@@ -89,7 +131,7 @@ Changes:
Breaking changes:
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
**Note: These are removed in 2.0.2**
3. Feature flag remote evaluation doesn't require a personal API key.
+26 -19
View File
@@ -1,5 +1,5 @@
import datetime # noqa: F401
from typing import Callable, Dict, Optional # noqa: F401
from typing import Callable, Dict, Optional, Tuple # noqa: F401
from posthog.client import Client
from posthog.version import VERSION
@@ -7,19 +7,20 @@ from posthog.version import VERSION
__version__ = VERSION
"""Settings."""
api_key = None # type: str
host = None # type: str
on_error = None # type: Callable
api_key = None # type: Optional[str]
host = None # type: Optional[str]
on_error = None # type: Optional[Callable]
debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: str
project_api_key = None # type: str
personal_api_key = None # type: Optional[str]
project_api_key = None # type: Optional[str]
poll_interval = 30 # type: int
disable_geoip = True # type: bool
feature_flags_request_timeout_seconds = 3 # type: int
default_client = None
default_client = None # type: Optional[Client]
def capture(
@@ -33,7 +34,7 @@ def capture(
send_feature_flags=False,
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
@@ -54,7 +55,7 @@ def capture(
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
```
"""
_proxy(
return _proxy(
"capture",
distinct_id=distinct_id,
event=event,
@@ -76,7 +77,7 @@ def identify(
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
@@ -92,7 +93,7 @@ def identify(
})
```
"""
_proxy(
return _proxy(
"identify",
distinct_id=distinct_id,
properties=properties,
@@ -111,7 +112,7 @@ def set(
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a user record.
This will overwrite previous people property values, just like `identify`.
@@ -127,7 +128,7 @@ def set(
})
```
"""
_proxy(
return _proxy(
"set",
distinct_id=distinct_id,
properties=properties,
@@ -146,7 +147,7 @@ def set_once(
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a user record, only if they do not yet exist.
This will not overwrite previous people property values, unlike `identify`.
@@ -162,7 +163,7 @@ def set_once(
})
```
"""
_proxy(
return _proxy(
"set_once",
distinct_id=distinct_id,
properties=properties,
@@ -182,7 +183,7 @@ def group_identify(
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a group
@@ -198,7 +199,7 @@ def group_identify(
})
```
"""
_proxy(
return _proxy(
"group_identify",
group_type=group_type,
group_key=group_key,
@@ -218,7 +219,7 @@ def alias(
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> None
# type: (...) -> Tuple[bool, dict]
"""
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
@@ -235,7 +236,7 @@ def alias(
posthog.alias('anonymous session id', 'distinct id')
```
"""
_proxy(
return _proxy(
"alias",
previous_id=previous_id,
distinct_id=distinct_id,
@@ -405,6 +406,11 @@ def feature_flag_definitions():
return _proxy("feature_flag_definitions")
def load_feature_flags():
"""Load feature flag definitions from PostHog."""
return _proxy("load_feature_flags")
def page(*args, **kwargs):
"""Send a page call."""
_proxy("page", *args, **kwargs)
@@ -447,6 +453,7 @@ def _proxy(method, *args, **kwargs):
poll_interval=poll_interval,
disabled=disabled,
disable_geoip=disable_geoip,
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
)
# always set incase user changes it
+79 -25
View File
@@ -10,7 +10,7 @@ from six import string_types
from posthog.consumer import Consumer
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
from posthog.poller import Poller
from posthog.request import APIError, batch_post, decide, get
from posthog.request import APIError, batch_post, decide, determine_server_host, get
from posthog.utils import SizeLimitedDict, clean, guess_timezone
from posthog.version import VERSION
@@ -49,6 +49,8 @@ class Client(object):
project_api_key=None,
disabled=False,
disable_geoip=True,
historical_migration=False,
feature_flags_request_timeout_seconds=3,
):
self.queue = queue.Queue(max_queue_size)
@@ -61,7 +63,7 @@ class Client(object):
self.debug = debug
self.send = send
self.sync_mode = sync_mode
self.host = host
self.host = determine_server_host(host)
self.gzip = gzip
self.timeout = timeout
self.feature_flags = None
@@ -69,10 +71,12 @@ class Client(object):
self.group_type_mapping = None
self.cohorts = None
self.poll_interval = poll_interval
self.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds
self.poller = None
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
self.disabled = disabled
self.disable_geoip = disable_geoip
self.historical_migration = historical_migration
# personal_api_key: This should be a generated Personal API Key, private
self.personal_api_key = personal_api_key
@@ -100,13 +104,14 @@ class Client(object):
consumer = Consumer(
self.queue,
self.api_key,
host=host,
host=self.host,
on_error=on_error,
flush_at=flush_at,
flush_interval=flush_interval,
gzip=gzip,
retries=max_retries,
timeout=timeout,
historical_migration=historical_migration,
)
self.consumers.append(consumer)
@@ -137,16 +142,6 @@ class Client(object):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlags"]
def _get_active_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
feature_variants = self.get_feature_variants(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return {
k: v for (k, v) in feature_variants.items() if v is not False
} # explicitly test for false to account for values that may seem falsy (ex: 0)
def get_feature_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
@@ -171,7 +166,7 @@ class Client(object):
"group_properties": group_properties,
"disable_geoip": disable_geoip,
}
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
return resp_data
@@ -206,15 +201,29 @@ class Client(object):
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
extra_properties = {}
feature_variants = {}
if send_feature_flags:
try:
feature_variants = self._get_active_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
else:
for feature, variant in feature_variants.items():
msg["properties"]["$feature/{}".format(feature)] = variant
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
elif self.feature_flags:
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
feature_variants = self.get_all_flags(
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
)
for feature, variant in feature_variants.items():
extra_properties[f"$feature/{feature}"] = variant
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
if active_feature_flags:
extra_properties["$active_feature_flags"] = active_feature_flags
if extra_properties:
msg["properties"] = {**extra_properties, **msg["properties"]}
return self._enqueue(msg, disable_geoip)
@@ -370,7 +379,14 @@ class Client(object):
if self.sync_mode:
self.log.debug("enqueued with blocking %s.", msg["event"])
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=[msg])
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=[msg],
historical_migration=self.historical_migration,
)
return True, msg
@@ -460,7 +476,16 @@ class Client(object):
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
self.poller.start()
def _compute_flag_locally(self, feature_flag, distinct_id, *, groups={}, person_properties={}, group_properties={}):
def _compute_flag_locally(
self,
feature_flag,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
warn_on_unknown_groups=True,
):
if feature_flag.get("ensure_experience_continuity", False):
raise InconclusiveMatchError("Flag has experience continuity enabled")
@@ -482,9 +507,14 @@ class Client(object):
if group_name not in groups:
# Group flags are never enabled in `groups` aren't passed in
# don't failover to `/decide/`, since response will be the same
self.log.warning(
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
)
if warn_on_unknown_groups:
self.log.warning(
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
)
else:
self.log.debug(
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
)
return False
focused_group_properties = group_properties[group_name]
@@ -538,6 +568,10 @@ class Client(object):
if self.disabled:
return None
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
response = None
@@ -685,6 +719,10 @@ class Client(object):
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
@@ -705,7 +743,9 @@ class Client(object):
return response
def _get_all_flags_and_payloads_locally(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
def _get_all_flags_and_payloads_locally(
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, warn_on_unknown_groups=False
):
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
@@ -725,6 +765,7 @@ class Client(object):
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
warn_on_unknown_groups=warn_on_unknown_groups,
)
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
if matched_payload:
@@ -743,6 +784,19 @@ class Client(object):
def feature_flag_definitions(self):
return self.feature_flags
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
all_person_properties = {"distinct_id": distinct_id, **(person_properties or {})}
all_group_properties = {}
if groups:
for group_name in groups:
all_group_properties[group_name] = {
"$group_key": groups[group_name],
**(group_properties.get(group_name) or {}),
}
return all_person_properties, all_group_properties
def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
+10 -1
View File
@@ -36,6 +36,7 @@ class Consumer(Thread):
gzip=False,
retries=10,
timeout=15,
historical_migration=False,
):
"""Create a consumer thread."""
Thread.__init__(self)
@@ -55,6 +56,7 @@ class Consumer(Thread):
self.running = True
self.retries = retries
self.timeout = timeout
self.historical_migration = historical_migration
def run(self):
"""Runs the consumer."""
@@ -134,6 +136,13 @@ class Consumer(Thread):
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
def send_request():
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=batch)
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
send_request()
+79 -21
View File
@@ -2,8 +2,10 @@ import datetime
import hashlib
import logging
import re
from typing import Optional
from dateutil import parser
from dateutil.relativedelta import relativedelta
from posthog.utils import convert_to_datetime_aware, is_valid_regex
@@ -117,15 +119,17 @@ def match_property(property, property_values) -> bool:
override_value = property_values[key]
if operator == "exact":
if isinstance(value, list):
return override_value in value
return value == override_value
if operator in ("exact", "is_not"):
if operator == "is_not":
if isinstance(value, list):
return override_value not in value
return value != override_value
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).lower() in [str(val).lower() for val in value]
return str(value).lower() == str(override_value).lower()
if operator == "exact":
return compute_exact_match(value, override_value)
else:
return not compute_exact_match(value, override_value)
if operator == "is_set":
return key in property_values
@@ -142,23 +146,46 @@ def match_property(property, property_values) -> bool:
if operator == "not_regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
if operator == "gt":
return type(override_value) is type(value) and override_value > value
if operator in ("gt", "gte", "lt", "lte"):
# :TRICKY: We adjust comparison based on the override value passed in,
# to make sure we handle both numeric and string comparisons appropriately.
def compare(lhs, rhs, operator):
if operator == "gt":
return lhs > rhs
elif operator == "gte":
return lhs >= rhs
elif operator == "lt":
return lhs < rhs
elif operator == "lte":
return lhs <= rhs
else:
raise ValueError(f"Invalid operator: {operator}")
if operator == "gte":
return type(override_value) is type(value) and override_value >= value
parsed_value = None
try:
parsed_value = float(value) # type: ignore
except Exception:
pass
if operator == "lt":
return type(override_value) is type(value) and override_value < value
if operator == "lte":
return type(override_value) is type(value) and override_value <= value
if parsed_value is not None and override_value is not None:
if isinstance(override_value, str):
return compare(override_value, str(value), operator)
else:
return compare(override_value, parsed_value, operator)
else:
return compare(str(override_value), str(value), operator)
if operator in ["is_date_before", "is_date_after"]:
try:
parsed_date = parser.parse(value)
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception:
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
if not parsed_date:
parsed_date = parser.parse(str(value))
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception as e:
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
if not parsed_date:
raise InconclusiveMatchError("The date set on the flag is not a valid format")
if isinstance(override_value, datetime.datetime):
@@ -185,7 +212,8 @@ def match_property(property, property_values) -> bool:
else:
raise InconclusiveMatchError("The date provided must be a string or date object")
return False
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
def match_cohort(property, property_values, cohort_properties) -> bool:
@@ -271,3 +299,33 @@ def match_property_group(property_group, property_values, cohort_properties) ->
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
match = re.search(regex, value)
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
if match:
number = int(match.group("number"))
if number >= 10_000:
# Guard against overflow, disallow numbers greater than 10_000
return None
interval = match.group("interval")
if interval == "h":
parsed_dt = parsed_dt - relativedelta(hours=number)
elif interval == "d":
parsed_dt = parsed_dt - relativedelta(days=number)
elif interval == "w":
parsed_dt = parsed_dt - relativedelta(weeks=number)
elif interval == "m":
parsed_dt = parsed_dt - relativedelta(months=number)
elif interval == "y":
parsed_dt = parsed_dt - relativedelta(years=number)
else:
return None
return parsed_dt
else:
return None
+15 -1
View File
@@ -13,10 +13,24 @@ from posthog.version import VERSION
_session = requests.sessions.Session()
DEFAULT_HOST = "https://app.posthog.com"
US_INGESTION_ENDPOINT = "https://us-api.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu-api.i.posthog.com"
DEFAULT_HOST = US_INGESTION_ENDPOINT
USER_AGENT = "posthog-python/" + VERSION
def determine_server_host(host: Optional[str]) -> str:
"""Determines the server host to use."""
host_or_default = host or DEFAULT_HOST
trimmed_host = remove_trailing_slash(host_or_default)
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
return US_INGESTION_ENDPOINT
elif trimmed_host == "https://eu.posthog.com":
return EU_INGESTION_ENDPOINT
else:
return host_or_default
def post(
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
) -> requests.Response:
+3 -3
View File
@@ -43,9 +43,9 @@ class PostHogIntegration(Integration):
not not Hub.current.client.dsn and Dsn(Hub.current.client.dsn).project_id
)
if project_id:
properties[
"$sentry_url"
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
properties["$sentry_url"] = (
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
)
posthog.capture(posthog_distinct_id, "$exception", properties)
+265 -30
View File
@@ -106,24 +106,187 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
@mock.patch("posthog.client.decide")
def test_get_active_feature_flags(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
variants = client._get_active_feature_variants("some_id", None, None, None, False)
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
distinct_id="some_id",
groups={},
person_properties=None,
group_properties=None,
disable_geoip=False,
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
},
{
"rollout_percentage": 50,
},
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
},
}
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": 300},
},
}
false_flag = {
"id": 1,
"name": "Beta Feature",
"key": "false-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 0,
}
],
"payloads": {"true": 300},
},
}
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "third-variant")
self.assertEqual(msg["properties"]["$feature/false-flag"], False)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
assert "$feature/beta-feature" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
# test that flags are not evaluated without local evaluation
client.feature_flags = []
success, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/beta-feature-local" not in msg["properties"]
assert "$feature/false-flag" not in msg["properties"]
assert "$active_feature_flags" not in msg["properties"]
@mock.patch("posthog.client.decide")
def test_dont_override_capture_with_local_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"is_simple_flag": False,
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
],
"rollout_percentage": 100,
},
{
"rollout_percentage": 50,
},
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
},
}
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": True,
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": 300},
},
}
client.feature_flags = [multivariate_flag, basic_flag]
success, msg = client.capture(
"distinct_id", "python test event", {"$feature/beta-feature-local": "my-custom-variant"}
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "my-custom-variant")
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/person-flag" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
@@ -151,8 +314,8 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
"https://us-api.i.posthog.com",
timeout=3,
distinct_id="distinct_id",
groups={},
person_properties=None,
@@ -167,7 +330,12 @@ class TestClient(unittest.TestCase):
}
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True
FAKE_TEST_API_KEY,
host="https://app.posthog.com",
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
disable_geoip=True,
feature_flags_request_timeout_seconds=12,
)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True, disable_geoip=False)
client.flush()
@@ -188,8 +356,8 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_decide.call_count, 1)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
"https://us-api.i.posthog.com",
timeout=12,
distinct_id="distinct_id",
groups={},
person_properties=None,
@@ -616,11 +784,11 @@ class TestClient(unittest.TestCase):
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
"https://us-api.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
person_properties={},
person_properties={"distinct_id": "some_id"},
group_properties={},
disable_geoip=True,
)
@@ -628,11 +796,11 @@ class TestClient(unittest.TestCase):
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
"https://us-api.i.posthog.com",
timeout=3,
distinct_id="feature_enabled_distinct_id",
groups={},
person_properties={},
person_properties={"distinct_id": "feature_enabled_distinct_id"},
group_properties={},
disable_geoip=True,
)
@@ -640,11 +808,11 @@ class TestClient(unittest.TestCase):
client.get_all_flags_and_payloads("all_flags_payloads_id")
patch_decide.assert_called_with(
"random_key",
None,
timeout=10,
"https://us-api.i.posthog.com",
timeout=3,
distinct_id="all_flags_payloads_id",
groups={},
person_properties={},
person_properties={"distinct_id": "all_flags_payloads_id"},
group_properties={},
disable_geoip=False,
)
@@ -660,3 +828,70 @@ class TestClient(unittest.TestCase):
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
self.assertFalse(client.feature_enabled("example", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_default_properties_get_added_properly(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, host="http://app2.posthog.com", on_error=self.set_fail, disable_geoip=False)
client.get_feature_flag(
"random_key",
"some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"x1": "y1"},
group_properties={"company": {"x": "y"}},
)
patch_decide.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"distinct_id": "some_id", "x1": "y1"},
group_properties={
"company": {"$group_key": "id:5", "x": "y"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
client.get_feature_flag(
"random_key",
"some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"distinct_id": "override"},
group_properties={
"company": {
"$group_key": "group_override",
}
},
)
patch_decide.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
distinct_id="some_id",
groups={"company": "id:5", "instance": "app.posthog.com"},
person_properties={"distinct_id": "override"},
group_properties={
"company": {"$group_key": "group_override"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
)
patch_decide.reset_mock()
# test nones
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
patch_decide.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
person_properties={"distinct_id": "some_id"},
group_properties={},
disable_geoip=False,
)
+319 -3
View File
@@ -6,7 +6,7 @@ from dateutil import parser, tz
from freezegun import freeze_time
from posthog.client import Client
from posthog.feature_flags import InconclusiveMatchError, match_property
from posthog.feature_flags import InconclusiveMatchError, match_property, relative_date_parse_for_feature_flag_matching
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
@@ -1775,7 +1775,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_a, {"key": 0}))
self.assertFalse(match_property(property_a, {"key": -1}))
self.assertFalse(match_property(property_a, {"key": "23"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_a, {"key": "23"}))
property_b = self.property(key="key", value=1, operator="lt")
self.assertTrue(match_property(property_b, {"key": 0}))
@@ -1792,7 +1793,8 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_c, {"key": 0}))
self.assertFalse(match_property(property_c, {"key": -1}))
self.assertFalse(match_property(property_c, {"key": "3"}))
# now we handle type mismatches so this should be true
self.assertTrue(match_property(property_c, {"key": "3"}))
property_d = self.property(key="key", value="43", operator="lte")
self.assertTrue(match_property(property_d, {"key": "41"}))
@@ -1801,6 +1803,21 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_d, {"key": "44"}))
self.assertFalse(match_property(property_d, {"key": 44}))
self.assertTrue(match_property(property_d, {"key": 42}))
property_e = self.property(key="key", value="30", operator="lt")
self.assertTrue(match_property(property_e, {"key": "29"}))
# depending on the type of override, we adjust type comparison
self.assertTrue(match_property(property_e, {"key": "100"}))
self.assertFalse(match_property(property_e, {"key": 100}))
property_f = self.property(key="key", value="123aloha", operator="gt")
self.assertFalse(match_property(property_f, {"key": "123"}))
self.assertFalse(match_property(property_f, {"key": 122}))
# this turns into a string comparison
self.assertTrue(match_property(property_f, {"key": 129}))
def test_match_property_date_operators(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_date_before")
@@ -1854,6 +1871,305 @@ class TestMatchProperties(unittest.TestCase):
self.assertTrue(match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"}))
@freeze_time("2022-05-01")
def test_match_property_relative_date_operators(self):
property_a = self.property(key="key", value="-6h", operator="is_date_before")
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
self.assertTrue(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)}))
# false because date comparison, instead of datetime, so reduces to same date
self.assertFalse(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
self.assertFalse(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}))
self.assertTrue(
match_property(
property_a,
{"key": datetime.datetime(2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid"))},
)
)
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
# Can't be a number
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": 1})
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
match_property(property_a, {"key": "abcdef"})
property_b = self.property(key="key", value="1h", operator="is_date_after")
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_b, {"key": datetime.datetime(2022, 5, 30)}))
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
# can't be invalid string
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
# Invalid flag property
property_c = self.property(key="key", value=1234, operator="is_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_c, {"key": 1}))
# parsed as 1234-05-01 for some reason?
self.assertTrue(match_property(property_c, {"key": "2022-05-30"}))
# # Timezone aware property
property_d = self.property(key="key", value="12d", operator="is_date_before")
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"}))
self.assertTrue(match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"}))
self.assertFalse(match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"}))
# Try all possible relative dates
property_e = self.property(key="key", value="1h", operator="is_date_before")
self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))
property_f = self.property(key="key", value="-1d", operator="is_date_before")
self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))
property_g = self.property(key="key", value="1w", operator="is_date_before")
self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))
property_h = self.property(key="key", value="1m", operator="is_date_before")
self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))
property_i = self.property(key="key", value="1y", operator="is_date_before")
self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))
property_j = self.property(key="key", value="122h", operator="is_date_after")
self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))
property_k = self.property(key="key", value="2d", operator="is_date_after")
self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))
property_l = self.property(key="key", value="-02w", operator="is_date_after")
self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))
property_m = self.property(key="key", value="1m", operator="is_date_after")
self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))
property_n = self.property(key="key", value="1y", operator="is_date_after")
self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))
def test_none_property_value_with_all_operators(self):
property_a = self.property(key="key", value="none", operator="is_not")
self.assertFalse(match_property(property_a, {"key": None}))
self.assertTrue(match_property(property_a, {"key": "non"}))
property_b = self.property(key="key", value=None, operator="is_set")
self.assertTrue(match_property(property_b, {"key": None}))
property_c = self.property(key="key", value="no", operator="icontains")
self.assertTrue(match_property(property_c, {"key": None}))
self.assertFalse(match_property(property_c, {"key": "smh"}))
property_d = self.property(key="key", value="No", operator="regex")
self.assertTrue(match_property(property_d, {"key": None}))
property_d_lower_case = self.property(key="key", value="no", operator="regex")
self.assertFalse(match_property(property_d_lower_case, {"key": None}))
property_e = self.property(key="key", value=1, operator="gt")
self.assertTrue(match_property(property_e, {"key": None}))
property_f = self.property(key="key", value=1, operator="lt")
self.assertFalse(match_property(property_f, {"key": None}))
property_g = self.property(key="key", value="xyz", operator="gte")
self.assertFalse(match_property(property_g, {"key": None}))
property_h = self.property(key="key", value="Oo", operator="lte")
self.assertTrue(match_property(property_h, {"key": None}))
property_i = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_i, {"key": None}))
property_j = self.property(key="key", value="2022-05-01", operator="is_date_after")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_j, {"key": None}))
property_k = self.property(key="key", value="2022-05-01", operator="is_date_before")
with self.assertRaises(InconclusiveMatchError):
self.assertFalse(match_property(property_k, {"key": "random"}))
def test_unknown_operator(self):
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
with self.assertRaises(InconclusiveMatchError) as exception_context:
match_property(property_a, {"key": "random"})
self.assertEqual(str(exception_context.exception), "Unknown operator is_unknown")
class TestRelativeDateParsing(unittest.TestCase):
def test_invalid_input(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1") is None
assert relative_date_parse_for_feature_flag_matching("1x") is None
assert relative_date_parse_for_feature_flag_matching("1.2y") is None
assert relative_date_parse_for_feature_flag_matching("1z") is None
assert relative_date_parse_for_feature_flag_matching("1s") is None
assert relative_date_parse_for_feature_flag_matching("123344000.134m") is None
assert relative_date_parse_for_feature_flag_matching("bazinga") is None
assert relative_date_parse_for_feature_flag_matching("000bello") is None
assert relative_date_parse_for_feature_flag_matching("000hello") is None
assert relative_date_parse_for_feature_flag_matching("000h") is not None
assert relative_date_parse_for_feature_flag_matching("1000h") is not None
def test_overflow(self):
assert relative_date_parse_for_feature_flag_matching("1000000h") is None
assert relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
def test_hour_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1h") == datetime.datetime(
2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2h") == datetime.datetime(
2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("24h") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30h") == datetime.datetime(
2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("48h") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"24h"
) == relative_date_parse_for_feature_flag_matching("1d")
assert relative_date_parse_for_feature_flag_matching(
"48h"
) == relative_date_parse_for_feature_flag_matching("2d")
def test_day_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1d") == datetime.datetime(
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2d") == datetime.datetime(
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("14d") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("30d") == datetime.datetime(
2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("7d") == relative_date_parse_for_feature_flag_matching(
"1w"
)
def test_week_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1w") == datetime.datetime(
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2w") == datetime.datetime(
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") == datetime.datetime(
2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8w") == datetime.datetime(
2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4w") != relative_date_parse_for_feature_flag_matching(
"1m"
)
def test_month_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
with freeze_time("2020-04-03T00:00:00"):
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching(
"12m"
) == relative_date_parse_for_feature_flag_matching("1y")
def test_year_parsing(self):
with freeze_time("2020-01-01T12:01:20.1340Z"):
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("2y") == datetime.datetime(
2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("4y") == datetime.datetime(
2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
assert relative_date_parse_for_feature_flag_matching("8y") == datetime.datetime(
2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
)
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
+10 -3
View File
@@ -6,6 +6,10 @@ 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
@@ -22,15 +26,18 @@ class TestModule(unittest.TestCase):
self.assertRaises(Exception, self.posthog.capture)
def test_track(self):
self.posthog.capture("distinct_id", "python module event")
res = self.posthog.capture("distinct_id", "python module event")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_identify(self):
self.posthog.identify("distinct_id", {"email": "user@email.com"})
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
self._assert_enqueue_result(res)
self.posthog.flush()
def test_alias(self):
self.posthog.alias("previousId", "distinct_id")
res = self.posthog.alias("previousId", "distinct_id")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_page(self):
+25 -1
View File
@@ -2,9 +2,10 @@ import json
import unittest
from datetime import date, datetime
import pytest
import requests
from posthog.request import DatetimeSerializer, batch_post
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
from posthog.test.test_utils import TEST_API_KEY
@@ -42,3 +43,26 @@ class TestRequests(unittest.TestCase):
batch_post(
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
)
@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-api.i.posthog.com"),
("https://eu.posthog.com", "https://eu-api.i.posthog.com"),
("https://us.posthog.com", "https://us-api.i.posthog.com"),
("https://app.posthog.com/", "https://us-api.i.posthog.com"),
("https://eu.posthog.com/", "https://eu-api.i.posthog.com"),
("https://us.posthog.com/", "https://us-api.i.posthog.com"),
(None, "https://us-api.i.posthog.com"),
],
)
def test_routing_to_custom_host(host, expected):
assert determine_server_host(host) == expected
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.1.0"
VERSION = "3.5.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
@@ -13,6 +13,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path