Compare commits

..
12 Commits
Author SHA1 Message Date
Dylan MartinandGitHub dfa7f70a04 fix(flags): pass in the correct hashes. (#221)
* shoot

* cut customer token

* bump version

* dump posthog api from excluded
2025-04-15 16:09:47 -04:00
Dylan MartinandGitHub d00d69e448 ubunut 20-04 is EOL (#220) 2025-04-15 13:44:19 -04:00
Dylan MartinandGitHub a833955ee0 chore(flags): roll 10% of posthog-python /decide traffic (and all of PostHog's personal SDK traffic) to /flags (#218)
* init

* moved the constants

* formatting

* mypy

* fr do some damn formatting

* don't exclude posthog

* make it a set

* differentiate

* fix AI test

* use the same type everywhere

* ready to release
2025-04-15 12:57:49 -04:00
58fbe05cb0 test(llm-observability): Account for LangGraph 0.3.29 changes (#219)
* test(llm-observability): Account for LangGraph 0.3.29 changes

* formatting

---------

Co-authored-by: dylan <dylan@posthog.com>
2025-04-15 12:36:15 -04:00
David NewellandGitHub 7a6e185902 fix: add field to proxy client setup (#217) 2025-04-11 13:57:26 +01:00
David NewellandGitHub e9c72e7f8c chore: update license (#213) 2025-04-10 15:30:54 +01:00
David NewellandGitHub 51380ac207 feat: log captured exceptions (#215) 2025-04-10 12:14:29 +01:00
David NewellandGitHub 53ed80366b fix failing ai test (#216) 2025-04-10 12:01:25 +01:00
Frank HamandandGitHub 18729e33b8 bump version (#209) 2025-03-26 16:10:26 +00:00
Frank HamandandGitHub 334394bed2 update automatic retries to include read errors (#208)
in e.g. lambda environments the connection can time out between invocations,
this comes through to the client as a "RemoteDisconnected" error, which it
turns out urllib classifies as a "read" error not a connection error (as
it's possible to get this error after data has been sent)
2025-03-26 16:02:08 +00:00
Phil HaackandGitHub 14a2f80c6d feat(flags): Add more details such as version, id, and reason to $feature_flag_called events (#207)
* Flesh out Decide response types

* Ensure we normalize get_decide

In a back compat manner.

* Populate feature_flags_by_key when setting feature_flags

Since `self.feature_flags_by_key` is derived from `self.feature_flags`, and we often set the latter in unit tests, but forget to set the former, our tests can be wonky.

This ensures that when we set `self.feature_flags`, we always set `self. feature_flags_by_key`

* Annotate types

* Lookup local flag by key

Fixes #121

* Refactor local flag evaluation into its own method

* Include extra details in `$feature_flag_called` events

* Fix up type annotations, tests, and formatting

* Update lib to decide v4

* Bump version and add changelog
2025-03-25 16:27:24 -07:00
RossandGitHub 2779ad194c feat: Support serializing dataclasses (#206)
* Support serializing dataclasses

* Update version

* Run black

* Fix for Python 3.9
2025-03-17 14:28:33 +00:00
24 changed files with 1256 additions and 213 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
release:
name: Publish release
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+27
View File
@@ -1,4 +1,31 @@
## 3.24.3  2025-04-15
1. Fix hash inclusion/exclusion for flag rollout
## 3.24.2  2025-04-15
1. Roll out new /flags endpoint to 10% of /decide traffic
## 3.24.1  2025-04-11
1. Add `log_captured_exceptions` option to proxy setup
## 3.24.0  2025-04-10
1. Add config option to `log_captured_exceptions`
## 3.23.0  2025-03-26
1. Expand automatic retries to include read errors (e.g. RemoteDisconnected)
## 3.22.0  2025-03-26
1. Add more information to `$feature_flag_called` events.
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
## 3.21.0  2025-03-17
1. Support serializing dataclasses.
## 3.20.0  2025-03-13
+26
View File
@@ -20,3 +20,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry.
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
MIT License
Copyright (c) 2012 Functional Software, Inc. dba Sentry
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10 -4
View File
@@ -4,6 +4,7 @@ from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
from posthog.client import Client
from posthog.exception_capture import Integrations # noqa: F401
from posthog.types import FeatureFlag, FlagsAndPayloads
from posthog.version import VERSION
__version__ = VERSION
@@ -25,6 +26,7 @@ super_properties = None # type: Optional[Dict]
# Currently alpha, use at your own risk
enable_exception_autocapture = False # type: bool
exception_autocapture_integrations = [] # type: List[Integrations]
log_captured_exceptions = False # type: bool
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
project_root = None # type: Optional[str]
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
@@ -313,6 +315,7 @@ def capture_exception(
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
**kwargs
):
# type: (...) -> Tuple[bool, dict]
"""
@@ -326,6 +329,7 @@ def capture_exception(
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
- remaining `kwargs` will be logged if `log_captured_exceptions` is enabled
For example:
```python
@@ -354,6 +358,7 @@ def capture_exception(
timestamp=timestamp,
uuid=uuid,
groups=groups,
**kwargs
)
@@ -403,7 +408,7 @@ def get_feature_flag(
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
):
) -> Optional[FeatureFlag]:
"""
Get feature flag variant for users. Used with experiments.
Example:
@@ -446,7 +451,7 @@ def get_all_flags(
group_properties={}, # type: dict
only_evaluate_locally=False, # type: bool
disable_geoip=None, # type: Optional[bool]
):
) -> Optional[dict[str, FeatureFlag]]:
"""
Get all flags for a given user.
Example:
@@ -477,7 +482,7 @@ def get_feature_flag_payload(
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
):
) -> Optional[str]:
return _proxy(
"get_feature_flag_payload",
key=key,
@@ -519,7 +524,7 @@ def get_all_flags_and_payloads(
group_properties={},
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
):
) -> FlagsAndPayloads:
return _proxy(
"get_all_flags_and_payloads",
distinct_id=distinct_id,
@@ -589,6 +594,7 @@ def _proxy(method, *args, **kwargs):
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
enable_exception_autocapture=enable_exception_autocapture,
log_captured_exceptions=log_captured_exceptions,
exception_autocapture_integrations=exception_autocapture_integrations,
)
+1 -1
View File
@@ -116,7 +116,7 @@ class WrappedMessages(Messages):
def generator():
nonlocal usage_stats
nonlocal accumulated_content
nonlocal accumulated_content # noqa: F824
try:
for event in response:
if hasattr(event, "usage") and event.usage:
+1 -1
View File
@@ -116,7 +116,7 @@ class AsyncWrappedMessages(AsyncMessages):
async def generator():
nonlocal usage_stats
nonlocal accumulated_content
nonlocal accumulated_content # noqa: F824
try:
async for event in response:
if hasattr(event, "usage") and event.usage:
+3 -3
View File
@@ -89,7 +89,7 @@ class WrappedResponses(openai.resources.responses.Responses):
def generator():
nonlocal usage_stats
nonlocal final_content
nonlocal final_content # noqa: F824
try:
for chunk in response:
@@ -261,8 +261,8 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
def generator():
nonlocal usage_stats
nonlocal accumulated_content
nonlocal accumulated_tools
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
try:
for chunk in response:
+2 -2
View File
@@ -88,7 +88,7 @@ class WrappedResponses(openai.resources.responses.Responses):
async def async_generator():
nonlocal usage_stats
nonlocal final_content
nonlocal final_content # noqa: F824
try:
async for chunk in response:
@@ -261,7 +261,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
response = await super().create(**kwargs)
async def async_generator():
nonlocal usage_stats, accumulated_content, accumulated_tools
nonlocal usage_stats, accumulated_content, accumulated_tools # noqa: F824
try:
async for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
+354 -123
View File
@@ -1,4 +1,5 @@
import atexit
import hashlib
import logging
import numbers
import os
@@ -6,7 +7,7 @@ import platform
import sys
import warnings
from datetime import datetime, timedelta
from typing import Any
from typing import Any, Optional, Union
from uuid import UUID, uuid4
import distro # For Linux OS detection
@@ -18,7 +19,27 @@ from posthog.exception_capture import ExceptionCapture
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
from posthog.poller import Poller
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
from posthog.request import (
DEFAULT_HOST,
APIError,
batch_post,
decide,
determine_server_host,
flags,
get,
remote_config,
)
from posthog.types import (
FeatureFlag,
FlagMetadata,
FlagsAndPayloads,
FlagsResponse,
FlagValue,
normalize_flags_response,
to_flags_and_payloads,
to_payloads,
to_values,
)
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
from posthog.version import VERSION
@@ -31,6 +52,76 @@ except ImportError:
ID_TYPES = (numbers.Number, string_types, UUID)
MAX_DICT_SIZE = 50_000
# TODO: Get rid of these when you're done rolling out `/flags` to all customers
ROLLOUT_PERCENTAGE = 0.1
INCLUDED_HASHES = set({"bc94e67150c97dbcbf52549d50a7b80814841dbf"}) # this is PostHog's API key
# Explicitly excluding all the API tokens associated with the top 10 customers; we'll get to them soon, but don't want to rollout to them just yet
EXCLUDED_HASHES = set(
{
"03005596796f9ee626e9596b8062972cb6a556a0",
"05620a20b287e0d5cb1d4a0dd492797f36b952c5",
"0f95b5ca12878693c01c6420e727904f1737caa7",
"1212b6287a6e7e5ff6be5cb30ec563f35c2139d6",
"171ec1bb2caf762e06b1fde2e36a38c4638691a8",
"171faa9fc754b1aa42252a4eedb948b7c805d5cb",
"178ddde3f628fb0030321387acf939e4e6946d35",
"1790085d7e9aa136e8b73c180dd6a6060e2ef949",
"1895a3349c2371559c886f19ef1bf60617a934e0",
"1f01267d4f0295f88e8943bc963d816ee4abc84b",
"213df54990a34e62e3570b430f7ee36ec0928743",
"23d235537d988ab98ad259853eab02b07d828c2b",
"27135f7ae8f936222a5fcfcdc75c139b27dd3254",
"2817396d80fafc86c0816af8e73880f8b3e54320",
"29d3235e63db42056858ef04c6a5488c2a459eaa",
"2a76d9b5eb9307e540de9d516aa80f6cb5a0292f",
"2a92965a1344ab8a1f7dac2507e858f579a88ac2",
"2d5823818261512d616161de2bb8a161d48f1e35",
"32942f6a879dbfa8011cc68288c098e4a76e6cc0",
"3db6c17ab65827ceadf77d9a8462fabd94170ca6",
"4975b24f9ced9b2c06b604ddc9612f663f9452d5",
"497c7b017b13cd6cdbfe641c71f0dfb660a4c518",
"49c79e1dbce4a7b9394d6c14bf0421e04cecb445",
"4d63e1c5cd3a80972eac4e7526f03357ac538043",
"4da0f42a6f8f116822411152e5cda3c65ed2561f",
"4e494675ecd2b841784d6f29b658b38a0877a62e",
"4e852d8422130cec991eca2d6416dbe321d0a689",
"5120bfd92c9c6731074a89e4a82f49e947d34369",
"512cd72f9aa7ab11dfd012cc2e19394a020bd9a8",
"5b175d4064cc62f01118a2c6818c2c02fc8f27e1",
"5ba4bba3979e97d2c84df2aba394ca29c6c43187",
"639014946463614353ca640b268dc6592f62b652",
"643b9be9d50104e2b4ba94bc56688adba69c80fe",
"658f92992af9fc6a360143d72d93a36f63bbccb0",
"673a59c99739dfcee35202e428dd020b94866d52",
"67a9829b4997f5c6f3ab8173ad299f634adcfa53",
"6d686043e914ae8275df65e1ad890bd32a3b6fdd",
"6e4b5e1d649ad006d78f1f1617a9a0f35fc73078",
"6f1fc3a8fa9df54d00cbc1ef9ad5f24640589fd0",
"764e5fec2c7899cfee620fae8450fcc62cd72bf0",
"80ea6d6ed9a5895633c7bee7aba4323eeacdc90e",
"872e420156f583bc97351f3d83c02dae734a85df",
"8a24844cbeae31e74b4372964cdea74e99d9c0e2",
"975ae7330506d4583b000f96ad87abb41a0141ce",
"9e3d71378b340def3080e0a3a785a1b964cf43ef",
"9ede7b21365661331d024d92915de6e69749892b",
"a1ed1b4216ef4cec542c6b3b676507770be24ddc",
"a4f66a70a9647b3b89fc59f7642af8ffab073ba1",
"a7adb80be9e90948ab6bb726cc6e8e52694aec74",
"bca4b14ac8de49cccc02306c7bb6e5ae2acc0f72",
"bde5fe49f61e13629c5498d7428a7f6215e482a6",
"c54a7074c323aa7c5cb7b24bf826751b2a58f5d8",
"c552d20da0c87fb4ebe2da97c7f95c05eef2bca1",
"d7682f2d268f3064d433309af34f2935810989d2",
"d794ac43d8be26bf99f369ea79501eb774fe1b16",
"e0963e2552af77d46bb24d5b5806b5b456c64c5f",
"e6f14b2100cb0598925958b097ace82486037a25",
"e79ec399ad45f44a4295a5bb1322e2f14600ae39",
"eecf29f73f9c31009e5737a6c5ec3f87ec5b8ea6",
"f2c01f3cc770c7788257ee60910e2530f92eefc3",
"f7bbc58f4122b1e2812c0f1962c584cb404a1ac3",
}
)
def get_os_info():
"""
@@ -86,6 +177,43 @@ def system_context() -> dict[str, Any]:
}
def is_token_in_rollout(
token: str,
percentage: float = 0,
included_hashes: Optional[set[str]] = None,
excluded_hashes: Optional[set[str]] = None,
) -> bool:
"""
Determines if a token should be included in a rollout based on:
1. If its hash matches any included_hashes provided
2. If its hash falls within the percentage rollout
Args:
token: String to hash (usually API key)
percentage: Float between 0 and 1 representing rollout percentage
included_hashes: Optional set of specific SHA1 hashes to match against
excluded_hashes: Optional set of specific SHA1 hashes to exclude from rollout
Returns:
bool: True if token should be included in rollout
"""
# First generate SHA1 hash of token
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
# Check if hash matches any included hashes
if included_hashes and token_hash in included_hashes:
return True
# Check if hash matches any excluded hashes
if excluded_hashes and token_hash in excluded_hashes:
return False
# Convert first 8 chars of hash to int and divide by max value to get number between 0-1
hash_int = int(token_hash[:8], 16)
hash_float = hash_int / 0xFFFFFFFF
return hash_float < percentage
class Client(object):
"""Create a new PostHog client."""
@@ -115,6 +243,7 @@ class Client(object):
feature_flags_request_timeout_seconds=3,
super_properties=None,
enable_exception_autocapture=False,
log_captured_exceptions=False,
exception_autocapture_integrations=None,
project_root=None,
privacy_mode=False,
@@ -135,7 +264,7 @@ class Client(object):
self.host = determine_server_host(host)
self.gzip = gzip
self.timeout = timeout
self.feature_flags = None
self._feature_flags = None # private variable to store flags
self.feature_flags_by_key = None
self.group_type_mapping = None
self.cohorts = None
@@ -148,6 +277,7 @@ class Client(object):
self.historical_migration = historical_migration
self.super_properties = super_properties
self.enable_exception_autocapture = enable_exception_autocapture
self.log_captured_exceptions = log_captured_exceptions
self.exception_autocapture_integrations = exception_autocapture_integrations
self.exception_capture = None
self.privacy_mode = privacy_mode
@@ -204,6 +334,24 @@ class Client(object):
if send:
consumer.start()
@property
def feature_flags(self):
"""
Get the local evaluation feature flags.
"""
return self._feature_flags
@feature_flags.setter
def feature_flags(self, flags):
"""
Set the local evaluation feature flags.
"""
self._feature_flags = flags or []
self.feature_flags_by_key = {flag["key"]: flag for flag in self._feature_flags if flag.get("key") is not None}
assert (
self.feature_flags_by_key is not None
), "feature_flags_by_key should be initialized when feature_flags is set"
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
if context is not None:
warnings.warn(
@@ -228,26 +376,37 @@ class Client(object):
def get_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlags"]
) -> dict[str, Union[bool, str]]:
"""
Get feature flag variants for a distinct_id by calling decide.
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
return to_values(resp_data) or {}
def get_feature_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlagPayloads"]
) -> dict[str, str]:
"""
Get feature flag payloads for a distinct_id by calling decide.
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
return to_payloads(resp_data) or {}
def get_feature_flags_and_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return {
"featureFlags": resp_data["featureFlags"],
"featureFlagPayloads": resp_data["featureFlagPayloads"],
}
) -> FlagsAndPayloads:
"""
Get feature flags and payloads for a distinct_id by calling decide.
"""
resp = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
return to_flags_and_payloads(resp)
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
def get_flags_decision(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
) -> FlagsResponse:
"""
Get feature flags decision, using either flags() or decide() API based on rollout.
"""
require("distinct_id", distinct_id, ID_TYPES)
if disable_geoip is None:
@@ -265,9 +424,21 @@ class Client(object):
"group_properties": group_properties,
"disable_geoip": disable_geoip,
}
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
return resp_data
use_flags = is_token_in_rollout(
self.api_key, ROLLOUT_PERCENTAGE, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
)
if use_flags:
resp_data = flags(
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
)
else:
resp_data = decide(
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
)
return normalize_flags_response(resp_data)
def capture(
self,
@@ -306,8 +477,8 @@ class Client(object):
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
extra_properties = {}
feature_variants = {}
extra_properties: dict[str, Any] = {}
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
if send_feature_flags:
try:
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
@@ -320,10 +491,10 @@ class Client(object):
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
)
for feature, variant in feature_variants.items():
for feature, variant in (feature_variants or {}).items():
extra_properties[f"$feature/{feature}"] = variant
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
active_feature_flags = [key for (key, value) in (feature_variants or {}).items() if value is not False]
if active_feature_flags:
extra_properties["$active_feature_flags"] = active_feature_flags
@@ -476,6 +647,7 @@ class Client(object):
timestamp=None,
uuid=None,
groups=None,
**kwargs,
):
if context is not None:
warnings.warn(
@@ -529,6 +701,9 @@ class Client(object):
**properties,
}
if self.log_captured_exceptions:
self.log.exception(exception, extra=kwargs)
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
@@ -640,9 +815,6 @@ class Client(object):
)
self.feature_flags = response["flags"] or []
self.feature_flags_by_key = {
flag["key"]: flag for flag in self.feature_flags if flag.get("key") is not None
}
self.group_type_mapping = response["group_type_mapping"] or {}
self.cohorts = response["cohorts"] or {}
@@ -664,7 +836,6 @@ class Client(object):
)
# Reset all feature flag data when quota limited
self.feature_flags = []
self.feature_flags_by_key = {}
self.group_type_mapping = {}
self.cohorts = {}
@@ -704,7 +875,7 @@ class Client(object):
person_properties={},
group_properties={},
warn_on_unknown_groups=True,
):
) -> FlagValue:
if feature_flag.get("ensure_experience_continuity", False):
raise InconclusiveMatchError("Flag has experience continuity enabled")
@@ -779,7 +950,14 @@ class Client(object):
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
):
) -> Optional[FlagValue]:
"""
Get a feature flag value for a key by evaluating locally or remotely
depending on whether local evaluation is enabled and the flag can be
locally evaluated.
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
"""
require("key", key, string_types)
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
@@ -791,65 +969,69 @@ class Client(object):
distinct_id, groups, person_properties, group_properties
)
response = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
flag_details = None
request_id = None
flag_was_locally_evaluated = response is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
flag_details, request_id = self._get_feature_flag_details_from_decide(
key, distinct_id, groups, person_properties, group_properties, disable_geoip
)
response = flag_details.get_value() if flag_details else False
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
if send_feature_flag_events:
self._capture_feature_flag_called(
distinct_id,
key,
response or False,
None,
flag_was_locally_evaluated,
groups,
disable_geoip,
request_id,
flag_details,
)
return response
def _locally_evaluate_flag(
self,
key: str,
distinct_id: str,
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
) -> Optional[FlagValue]:
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
response = None
if self.feature_flags:
for flag in self.feature_flags:
if flag["key"] == key:
try:
response = self._compute_flag_locally(
flag,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
)
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
except InconclusiveMatchError as e:
self.log.debug(f"Failed to compute flag {key} locally: {e}")
continue
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
continue
break
flag_was_locally_evaluated = response is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
feature_flags = self.get_feature_variants(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
disable_geoip=disable_geoip,
)
response = feature_flags.get(key)
if response is None:
response = False
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
feature_flag_reported_key = f"{key}_{str(response)}"
if (
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
and send_feature_flag_events # noqa: W503
):
self.capture(
distinct_id,
"$feature_flag_called",
{
"$feature_flag": key,
"$feature_flag_response": response,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
groups=groups,
disable_geoip=disable_geoip,
)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
assert (
self.feature_flags_by_key is not None
), "feature_flags_by_key should be initialized when feature_flags is set"
# Local evaluation
flag = self.feature_flags_by_key.get(key)
if flag:
try:
response = self._compute_flag_locally(
flag,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
)
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
except InconclusiveMatchError as e:
self.log.debug(f"Failed to compute flag {key} locally: {e}")
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
return response
def get_feature_flag_payload(
@@ -869,21 +1051,15 @@ class Client(object):
return None
if match_value is None:
match_value = self.get_feature_flag(
key,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
send_feature_flag_events=False,
# Disable automatic sending of feature flag events because we're manually handling event dispatch.
# This prevents sending events with empty data when `get_feature_flag` cannot be evaluated locally.
only_evaluate_locally=True, # Enable local evaluation of feature flags to avoid making multiple requests to `/decide`.
disable_geoip=disable_geoip,
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
match_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
response = None
payload = None
flag_details = None
request_id = None
if match_value is not None:
payload = self._compute_payload_locally(key, match_value)
@@ -891,37 +1067,92 @@ class Client(object):
flag_was_locally_evaluated = payload is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
responses_and_payloads = self.get_feature_flags_and_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
flag_details, request_id = self._get_feature_flag_details_from_decide(
key, distinct_id, groups, person_properties, group_properties, disable_geoip
)
response = responses_and_payloads["featureFlags"].get(key, None)
payload = responses_and_payloads["featureFlagPayloads"].get(str(key), None)
payload = flag_details.metadata.payload if flag_details else None
response = flag_details.get_value() if flag_details else False
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
if send_feature_flag_events:
self._capture_feature_flag_called(
distinct_id,
key,
response or False,
payload,
flag_was_locally_evaluated,
groups,
disable_geoip,
request_id,
flag_details,
)
return payload
def _get_feature_flag_details_from_decide(
self,
key: str,
distinct_id: str,
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
disable_geoip: Optional[bool],
) -> tuple[Optional[FeatureFlag], Optional[str]]:
"""
Calls /decide and returns the flag details and request id
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
request_id = resp_data.get("requestId")
flags = resp_data.get("flags")
flag_details = flags.get(key) if flags else None
return flag_details, request_id
def _capture_feature_flag_called(
self,
distinct_id: str,
key: str,
response: FlagValue,
payload: Optional[str],
flag_was_locally_evaluated: bool,
groups: dict[str, str],
disable_geoip: Optional[bool],
request_id: Optional[str],
flag_details: Optional[FeatureFlag],
):
feature_flag_reported_key = f"{key}_{str(response)}"
if (
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
and send_feature_flag_events # noqa: W503
):
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
properties: dict[str, Any] = {
"$feature_flag": key,
"$feature_flag_response": response,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
}
if payload:
properties["$feature_flag_payload"] = payload
if request_id:
properties["$feature_flag_request_id"] = request_id
if isinstance(flag_details, FeatureFlag):
if flag_details.reason and flag_details.reason.description:
properties["$feature_flag_reason"] = flag_details.reason.description
if isinstance(flag_details.metadata, FlagMetadata):
if flag_details.metadata.version:
properties["$feature_flag_version"] = flag_details.metadata.version
if flag_details.metadata.id:
properties["$feature_flag_id"] = flag_details.metadata.id
self.capture(
distinct_id,
"$feature_flag_called",
{
"$feature_flag": key,
"$feature_flag_response": response,
"$feature_flag_payload": payload,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
properties,
groups=groups,
disable_geoip=disable_geoip,
)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
return payload
def get_remote_config_payload(self, key: str):
if self.disabled:
return None
@@ -942,7 +1173,7 @@ class Client(object):
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}")
def _compute_payload_locally(self, key, match_value):
def _compute_payload_locally(self, key: str, match_value: FlagValue) -> Optional[str]:
payload = None
if self.feature_flags_by_key is None:
@@ -967,8 +1198,8 @@ class Client(object):
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
flags = self.get_all_flags_and_payloads(
) -> Optional[dict[str, Union[bool, str]]]:
response = self.get_all_flags_and_payloads(
distinct_id,
groups=groups,
person_properties=person_properties,
@@ -976,7 +1207,8 @@ class Client(object):
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
return flags["featureFlags"]
return response["featureFlags"]
def get_all_flags_and_payloads(
self,
@@ -987,7 +1219,7 @@ class Client(object):
group_properties={},
only_evaluate_locally=False,
disable_geoip=None,
):
) -> FlagsAndPayloads:
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
@@ -995,21 +1227,20 @@ class Client(object):
distinct_id, groups, person_properties, group_properties
)
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
response, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
)
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
if fallback_to_decide and not only_evaluate_locally:
try:
flags_and_payloads = self.get_decide(
decide_response = self.get_flags_decision(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
disable_geoip=disable_geoip,
)
response = flags_and_payloads
return to_flags_and_payloads(decide_response)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
@@ -1017,15 +1248,15 @@ class Client(object):
def _get_all_flags_and_payloads_locally(
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, warn_on_unknown_groups=False
):
) -> tuple[FlagsAndPayloads, bool]:
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
flags = {}
payloads = {}
flags: dict[str, FlagValue] = {}
payloads: dict[str, str] = {}
fallback_to_decide = False
# If loading in previous line failed
if self.feature_flags:
@@ -1051,7 +1282,7 @@ class Client(object):
else:
fallback_to_decide = True
return flags, payloads, fallback_to_decide
return {"featureFlags": flags, "featureFlagPayloads": payloads}, fallback_to_decide
def feature_flag_definitions(self):
return self.feature_flags
+5
View File
@@ -1,3 +1,8 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import logging
import sys
import threading
+5
View File
@@ -1,3 +1,8 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import re
import sys
from typing import TYPE_CHECKING
+3
View File
@@ -1,3 +1,6 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
# 💖open source (under MIT License)
# We want to keep payloads as similar to Sentry as possible for easy interoperability
+4 -3
View File
@@ -8,6 +8,7 @@ from dateutil import parser
from dateutil.relativedelta import relativedelta
from posthog import utils
from posthog.types import FlagValue
from posthog.utils import convert_to_datetime_aware, is_valid_regex
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
@@ -25,7 +26,7 @@ class InconclusiveMatchError(Exception):
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
# we can do _hash(key, distinct_id) < 0.2
def _hash(key, distinct_id, salt=""):
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
hash_key = f"{key}.{distinct_id}{salt}"
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
@@ -50,7 +51,7 @@ def variant_lookup_table(feature_flag):
return lookup_table
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None):
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
@@ -87,7 +88,7 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
return False
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties):
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
for prop in condition.get("properties"):
+18 -3
View File
@@ -7,11 +7,20 @@ from typing import Any, Optional, Union
import requests
from dateutil.tz import tzutc
from urllib3.util.retry import Retry
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
adapter = requests.adapters.HTTPAdapter(max_retries=2)
# Retry on both connect and read errors
# by default read errors will only retry idempotent HTTP methods (so not POST)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=2,
connect=2,
read=2,
)
)
_session = requests.sessions.Session()
_session.mount("https://", adapter)
@@ -43,7 +52,7 @@ def post(
url = remove_trailing_slash(host or DEFAULT_HOST) + path
body["api_key"] = api_key
data = json.dumps(body, cls=DatetimeSerializer)
log.debug("making request: %s", data)
log.debug("making request: %s to url: %s", data, url)
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
if gzip:
headers["Content-Encoding"] = "gzip"
@@ -93,10 +102,16 @@ def _process_response(
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the decide API endpoint"""
res = post(api_key, host, "/decide/?v=3", gzip, timeout, **kwargs)
res = post(api_key, host, "/decide/?v=4", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags decided successfully")
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags evaluated successfully")
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
"""Get remote config flag value from remote_config API endpoint"""
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)
+32 -63
View File
@@ -618,88 +618,57 @@ def test_graph_state(mock_client):
assert isinstance(result["messages"][2], AIMessage)
assert result["messages"][2].content == "It's a type of greeble."
assert mock_client.capture.call_count == 11
assert mock_client.capture.call_count == 6
calls = [call[1] for call in mock_client.capture.call_args_list]
trace_args = calls[10]
trace_props = calls[10]["properties"]
# The trace event is captured at the end
trace_args = calls[-1]
trace_props = calls[-1]["properties"]
# Events are captured in the reverse order.
# Check all trace_ids
for call in calls:
assert call["properties"]["$ai_trace_id"] == trace_props["$ai_trace_id"]
# First span, write the state
assert calls[0]["event"] == "$ai_span"
assert calls[0]["properties"]["$ai_parent_id"] == calls[2]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[0]["properties"]
assert calls[0]["properties"]["$ai_input_state"] == initial_state
assert calls[0]["properties"]["$ai_output_state"] == initial_state
# Second span, set the START node
assert calls[1]["event"] == "$ai_span"
assert calls[1]["properties"]["$ai_parent_id"] == calls[2]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[1]["properties"]
assert calls[1]["properties"]["$ai_input_state"] == initial_state
assert calls[1]["properties"]["$ai_output_state"] == initial_state
# Third span, finish initialization
assert calls[2]["event"] == "$ai_span"
assert "$ai_span_id" in calls[2]["properties"]
assert calls[2]["properties"]["$ai_span_name"] == START
assert calls[2]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
assert calls[2]["properties"]["$ai_input_state"] == initial_state
assert calls[2]["properties"]["$ai_output_state"] == initial_state
# Fourth span, save the value of fake_plain during its execution
# 1. Span, finish initialization
second_state = {
"messages": [HumanMessage(content="What's a bar?"), AIMessage(content="Let's explore bar.")],
"xyz": "abc",
}
# 1. Span - the fake_plain node, which doesn't do anything
assert calls[0]["event"] == "$ai_span"
assert calls[0]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
assert "$ai_span_id" in calls[0]["properties"]
assert calls[0]["properties"]["$ai_span_name"] == "fake_plain"
assert calls[0]["properties"]["$ai_input_state"] == initial_state
assert calls[0]["properties"]["$ai_output_state"] == second_state
# 2. Span - the ChatPromptTemplate within fake_llm's FakeMessagesListChatModel
assert calls[1]["event"] == "$ai_span"
assert calls[1]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[1]["properties"]
assert calls[1]["properties"]["$ai_span_name"] == "ChatPromptTemplate"
# 3. Generation - the FakeMessagesListChatModel within fake_llm's RunnableSequence
assert calls[2]["event"] == "$ai_generation"
assert calls[2]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[2]["properties"]
assert calls[2]["properties"]["$ai_span_name"] == "FakeMessagesListChatModel"
# 4. Span - RunnableSequence within fake_llm
assert calls[3]["event"] == "$ai_span"
assert calls[3]["properties"]["$ai_parent_id"] == calls[4]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[3]["properties"]
assert calls[3]["properties"]["$ai_input_state"] == second_state
assert calls[3]["properties"]["$ai_output_state"] == second_state
assert calls[3]["properties"]["$ai_span_name"] == "RunnableSequence"
# Fifth span, run the fake_plain node
# 5. Span - the fake_llm node
assert calls[4]["event"] == "$ai_span"
assert "$ai_span_id" in calls[4]["properties"]
assert calls[4]["properties"]["$ai_span_name"] == "fake_plain"
assert calls[4]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
assert calls[4]["properties"]["$ai_input_state"] == initial_state
assert calls[4]["properties"]["$ai_output_state"] == second_state
assert "$ai_span_id" in calls[4]["properties"]
assert calls[4]["properties"]["$ai_span_name"] == "fake_llm"
# Sixth span, chat prompt template
assert calls[5]["event"] == "$ai_span"
assert calls[5]["properties"]["$ai_parent_id"] == calls[7]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[5]["properties"]
assert calls[5]["properties"]["$ai_span_name"] == "ChatPromptTemplate"
# 7. Generation, fake_llm
assert calls[6]["event"] == "$ai_generation"
assert calls[6]["properties"]["$ai_parent_id"] == calls[7]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[6]["properties"]
assert calls[6]["properties"]["$ai_span_name"] == "FakeMessagesListChatModel"
# 8. Span, RunnableSequence
assert calls[7]["event"] == "$ai_span"
assert calls[7]["properties"]["$ai_parent_id"] == calls[9]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[7]["properties"]
assert calls[7]["properties"]["$ai_span_name"] == "RunnableSequence"
# 9. Span, fake_llm write
assert calls[8]["event"] == "$ai_span"
assert calls[8]["properties"]["$ai_parent_id"] == calls[9]["properties"]["$ai_span_id"]
assert "$ai_span_id" in calls[8]["properties"]
# 10. Span, fake_llm node
assert calls[9]["event"] == "$ai_span"
assert calls[9]["properties"]["$ai_parent_id"] == trace_props["$ai_trace_id"]
assert "$ai_span_id" in calls[9]["properties"]
assert calls[9]["properties"]["$ai_span_name"] == "fake_llm"
# 11. Trace
# 6. Trace
assert trace_args["event"] == "$ai_trace"
assert trace_props["$ai_span_name"] == "LangGraph"
+1
View File
@@ -83,6 +83,7 @@ def mock_openai_response_with_responses_api():
usage=ResponseUsage(
input_tokens=10,
output_tokens=10,
input_tokens_details={"prompt_tokens": 10, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 15},
total_tokens=20,
),
+129 -1
View File
@@ -1,3 +1,4 @@
import hashlib
import time
import unittest
from datetime import datetime
@@ -7,9 +8,10 @@ import mock
import six
from parameterized import parameterized
from posthog.client import Client
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, LegacyFlagMetadata
from posthog.version import VERSION
@@ -262,6 +264,13 @@ class TestClient(unittest.TestCase):
"WARNING:posthog:No exception information available",
)
def test_capture_exception_logs_when_enabled(self):
client = Client(FAKE_TEST_API_KEY, log_captured_exceptions=True)
with self.assertLogs("posthog", level="ERROR") as logs:
client.capture_exception(Exception("test exception"), "distinct_id", path="one/two/three")
self.assertEqual(logs.output[0], "ERROR:posthog:test exception\nNoneType: None")
self.assertEqual(getattr(logs.records[0], "path"), "one/two/three")
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@@ -1212,3 +1221,122 @@ class TestClient(unittest.TestCase):
}
assert context == expected_context
@mock.patch("posthog.client.decide")
def test_get_decide_returns_normalized_decide_response(self, patch_decide):
patch_decide.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False},
"featureFlagPayloads": {"beta-feature": '{"some": "data"}'},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
client = Client(FAKE_TEST_API_KEY)
distinct_id = "test_distinct_id"
groups = {"test_group_type": "test_group_id"}
person_properties = {"test_property": "test_value"}
response = client.get_flags_decision(distinct_id, groups, person_properties)
assert response == {
"flags": {
"beta-feature": FeatureFlag(
key="beta-feature",
enabled=True,
variant="random-variant",
reason=None,
metadata=LegacyFlagMetadata(
payload='{"some": "data"}',
),
),
"alpha-feature": FeatureFlag(
key="alpha-feature",
enabled=True,
variant=None,
reason=None,
metadata=LegacyFlagMetadata(
payload=None,
),
),
"off-feature": FeatureFlag(
key="off-feature",
enabled=False,
variant=None,
reason=None,
metadata=LegacyFlagMetadata(
payload=None,
),
),
},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.decide")
def test_get_flags_decision_rollout(self, patch_decide, patch_flags):
# Set up mock responses
decide_response = {
"featureFlags": {"flag1": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
flags_response = {
"featureFlags": {"flag2": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
patch_decide.return_value = decide_response
patch_flags.return_value = flags_response
client = Client(FAKE_TEST_API_KEY)
# Test 0% rollout - should use decide
with mock.patch("posthog.client.is_token_in_rollout", return_value=False) as mock_rollout:
client.get_flags_decision("distinct_id")
mock_rollout.assert_called_with(
FAKE_TEST_API_KEY, 0.1, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
)
patch_decide.assert_called_once()
patch_flags.assert_not_called()
patch_decide.reset_mock()
patch_flags.reset_mock()
# Test 100% rollout - should use flags
with mock.patch("posthog.client.is_token_in_rollout", return_value=True) as mock_rollout:
client.get_flags_decision("distinct_id")
mock_rollout.assert_called_with(
FAKE_TEST_API_KEY, 0.1, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
)
patch_flags.assert_called_once()
patch_decide.assert_not_called()
def test_token_rollout_calculation(self):
# Test specific hash inclusion
token = "test_token"
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
included_hashes = {token_hash}
# Should be included due to specific hash, even with 0% rollout
self.assertTrue(expr=is_token_in_rollout(token, percentage=0.0, included_hashes=included_hashes))
# Should not be included with 0% rollout and no specific hash
self.assertFalse(is_token_in_rollout(token, percentage=0.0))
# Should be included with 100% rollout regardless of specific hash
self.assertTrue(is_token_in_rollout(token, percentage=1.0))
self.assertTrue(is_token_in_rollout(token, percentage=1.0, included_hashes=included_hashes))
# Test deterministic behavior - same token should always give same result
hash_float = int(token_hash[:8], 16) / 0xFFFFFFFF
percentage = hash_float + 0.1 # Just above the hash value
self.assertTrue(is_token_in_rollout(token, percentage))
self.assertFalse(is_token_in_rollout(token, percentage - 0.2)) # Just below the hash value
# Test that the token exclusion works correctly
self.assertFalse(is_token_in_rollout(token, percentage=1.0, excluded_hashes={token_hash}))
# Should work for other specific token hashes
# Include our API key
self.assertTrue(is_token_in_rollout("sTMFPsFhdP1Ssg", percentage=0.1, included_hashes=INCLUDED_HASHES))
+142
View File
@@ -0,0 +1,142 @@
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.assertEqual(reason.condition_index, 0) # default value
self.assertEqual(reason.description, "") # default value
# 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)
+88 -5
View File
@@ -726,7 +726,6 @@ class TestLocalEvaluation(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_no_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
client = self.client
basic_flag = {
"id": 1,
@@ -767,7 +766,6 @@ class TestLocalEvaluation(unittest.TestCase):
basic_flag,
disabled_flag,
]
client.feature_flags_by_key = {"beta-feature": basic_flag, "disabled-feature": disabled_flag}
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], {"beta-feature": "new"}
)
@@ -898,7 +896,6 @@ class TestLocalEvaluation(unittest.TestCase):
flag_2,
flag_3,
]
client.feature_flags_by_key = {"beta-feature": flag_1, "disabled-feature": flag_2, "beta-feature2": flag_3}
# beta-feature2 has no value
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id", only_evaluate_locally=True)["featureFlagPayloads"],
@@ -1626,7 +1623,6 @@ class TestLocalEvaluation(unittest.TestCase):
},
}
self.client.feature_flags = [basic_flag]
self.client.feature_flags_by_key = {"person-flag": basic_flag}
self.assertEqual(
self.client.get_feature_flag_payload(
@@ -1694,7 +1690,6 @@ class TestLocalEvaluation(unittest.TestCase):
},
}
self.client.feature_flags = [multivariate_flag]
self.client.feature_flags_by_key = {"beta-feature": multivariate_flag}
self.assertEqual(
self.client.get_feature_flag_payload(
@@ -2346,6 +2341,94 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called_with_flag_details(self, patch_decide, patch_capture):
patch_decide.return_value = {
"flags": {
"decide-flag": {
"key": "decide-flag",
"enabled": True,
"variant": "decide-variant",
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
},
}
},
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
}
client = Client(FAKE_TEST_API_KEY)
self.assertEqual(client.get_feature_flag("decide-flag", "some-distinct-id"), "decide-variant")
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-variant",
"locally_evaluated": False,
"$feature/decide-flag": "decide-variant",
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 23,
"$feature_flag_version": 42,
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
},
groups={},
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called_with_flag_details_and_payload(self, patch_decide, patch_capture):
patch_decide.return_value = {
"flags": {
"decide-flag-with-payload": {
"key": "decide-flag-with-payload",
"enabled": True,
"variant": None,
"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",
}
client = Client(FAKE_TEST_API_KEY)
self.assertEqual(
client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), '{"foo": "bar"}'
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "decide-flag-with-payload",
"$feature_flag_response": True,
"locally_evaluated": False,
"$feature/decide-flag-with-payload": True,
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 23,
"$feature_flag_version": 42,
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
"$feature_flag_payload": '{"foo": "bar"}',
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.decide")
def test_capture_is_called_but_does_not_add_all_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
+175
View File
@@ -0,0 +1,175 @@
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"}')
+48 -2
View File
@@ -1,4 +1,5 @@
import unittest
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
@@ -56,7 +57,10 @@ class TestUtils(unittest.TestCase):
self.assertEqual(combined.keys(), pre_clean_keys)
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
self.assertEqual(utils.clean(UUID("12345678123456781234567812345678")), "12345678-1234-5678-1234-567812345678")
self.assertEqual(
utils.clean(UUID("12345678123456781234567812345678")),
"12345678-1234-5678-1234-567812345678",
)
def test_clean_with_dates(self):
dict_with_dates = {
@@ -100,7 +104,8 @@ class TestUtils(unittest.TestCase):
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
self.assertEqual(
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))), {"foo": {"foo": "1", "bar": 2, "baz": "3"}}
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))),
{"foo": {"foo": "1", "bar": 2, "baz": "3"}},
)
class Dummy:
@@ -110,6 +115,47 @@ class TestUtils(unittest.TestCase):
# Skips a class with a defined non-Pydantic `model_dump` method.
self.assertEqual(utils.clean({"test": Dummy()}), {})
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
self.assertEqual(
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,
},
},
)
class TestSizeLimitedDict(unittest.TestCase):
def test_size_limited_dict(self):
+171
View File
@@ -0,0 +1,171 @@
from dataclasses import dataclass
from typing import Any, List, Optional, TypedDict, Union, cast
FlagValue = Union[bool, str]
@dataclass(frozen=True)
class FlagReason:
code: str
condition_index: int
description: str
@classmethod
def from_json(cls, resp: Any) -> Optional["FlagReason"]:
if not resp:
return None
return cls(
code=resp.get("code", ""),
condition_index=resp.get("condition_index", 0),
description=resp.get("description", ""),
)
@dataclass(frozen=True)
class LegacyFlagMetadata:
payload: Any
@dataclass(frozen=True)
class FlagMetadata:
id: int
payload: Optional[str]
version: int
description: str
@classmethod
def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]:
if not resp:
return LegacyFlagMetadata(payload=None)
return cls(
id=resp.get("id", 0),
payload=resp.get("payload"),
version=resp.get("version", 0),
description=resp.get("description", ""),
)
@dataclass(frozen=True)
class FeatureFlag:
key: str
enabled: bool
variant: Optional[str]
reason: Optional[FlagReason]
metadata: Union[FlagMetadata, LegacyFlagMetadata]
def get_value(self) -> FlagValue:
return self.variant or self.enabled
@classmethod
def from_json(cls, resp: Any) -> "FeatureFlag":
reason = None
if resp.get("reason"):
reason = FlagReason.from_json(resp.get("reason"))
metadata = None
if resp.get("metadata"):
metadata = FlagMetadata.from_json(resp.get("metadata"))
else:
metadata = LegacyFlagMetadata(payload=None)
return cls(
key=resp.get("key"),
enabled=resp.get("enabled"),
variant=resp.get("variant"),
reason=reason,
metadata=metadata,
)
@classmethod
def from_value_and_payload(cls, key: str, value: FlagValue, payload: Any) -> "FeatureFlag":
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
return cls(
key=key,
enabled=enabled,
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
),
)
class FlagsResponse(TypedDict, total=False):
flags: dict[str, FeatureFlag]
errorsWhileComputingFlags: bool
requestId: str
quotaLimit: Optional[List[str]]
class FlagsAndPayloads(TypedDict, total=True):
featureFlags: Optional[dict[str, FlagValue]]
featureFlagPayloads: Optional[dict[str, Any]]
def normalize_flags_response(resp: Any) -> FlagsResponse:
"""
Normalize the response from the decide or flags API endpoint into a FlagsResponse.
Args:
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
Returns:
A FlagsResponse containing feature flags and their details.
"""
if "requestId" not in resp:
resp["requestId"] = None
if "flags" in resp:
flags = resp["flags"]
# For each flag, create a FeatureFlag object
for key, value in flags.items():
if isinstance(value, FeatureFlag):
continue
value["key"] = key
flags[key] = FeatureFlag.from_json(value)
else:
# Handle legacy format
featureFlags = resp.get("featureFlags", {})
featureFlagPayloads = resp.get("featureFlagPayloads", {})
resp.pop("featureFlags", None)
resp.pop("featureFlagPayloads", None)
# look at each key in featureFlags and create a FeatureFlag object
flags = {}
for key, value in featureFlags.items():
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
resp["flags"] = flags
return cast(FlagsResponse, resp)
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
"""
Convert a FlagsResponse into a FlagsAndPayloads object which is a
dict of feature flags and their payloads. This is needed by certain
functions in the client.
Args:
resp: A FlagsResponse containing feature flags and their payloads.
Returns:
A tuple containing:
- A dictionary mapping flag keys to their values (bool or str)
- A dictionary mapping flag keys to their payloads
"""
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
if "flags" not in response:
return None
flags = response.get("flags", {})
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
if "flags" not in response:
return None
return {
key: value.metadata.payload
for key, value in response.get("flags", {}).items()
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
}
+9
View File
@@ -2,6 +2,7 @@ import logging
import numbers
import re
from collections import defaultdict
from dataclasses import asdict, is_dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
@@ -68,6 +69,8 @@ def clean(item):
pass
if isinstance(item, dict):
return _clean_dict(item)
if is_dataclass(item) and not isinstance(item, type):
return _clean_dataclass(item)
return _coerce_unicode(item)
@@ -90,6 +93,12 @@ def _clean_dict(dict_):
return data
def _clean_dataclass(dataclass_):
data = asdict(dataclass_)
data = _clean_dict(data)
return data
def _coerce_unicode(cmplx):
try:
item = cmplx.decode("utf-8", "strict")
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.20.0"
VERSION = "3.24.3"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201