Compare commits

...
15 Commits
Author SHA1 Message Date
Rafael AudibertandGitHub 7aea6b72d3 feat: Remove deprecated monotonic lib (#231) 2025-04-29 11:14:59 -03:00
Rafael AudibertandGitHub 7bb7c90a49 chore: Release automatically when changed version.py (#232) 2025-04-29 11:14:50 -03:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
c1f668e8bb feat: Add new FeatureFlagResult class and tests (#227)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-04-24 10:09:29 -07:00
Phil HaackandGitHub a1b81ee3d9 chore: Add parameters to bin/test (#228) 2025-04-23 13:53:28 -07:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
a6fb39902d chore: Make condition_index optional. Also added some scripts for local dev. (#223)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-04-18 11:59:49 -07:00
Dylan MartinandGitHub a1583f6627 chore(flags): latest version of posthog-python now uses /flags by default, except for a few exceptions (#222)
* wahahaha

* fix tests

* whoops don't forget to roll it out
2025-04-15 17:13:26 -04:00
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
33 changed files with 1428 additions and 499 deletions
+38 -33
View File
@@ -1,38 +1,43 @@
name: 'Release'
name: "Release"
on:
- workflow_dispatch
push:
branches:
- master
paths:
- "posthog/version.py"
workflow_dispatch:
jobs:
release:
name: Publish release
runs-on: ubuntu-20.04
release:
name: Publish release
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
+57 -2
View File
@@ -1,3 +1,59 @@
## 4.0.1 2025-04-29
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
2. Clarify Python 3.9+ is required
## 4.0.0 - 2025-04-24
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
Example:
```python
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
print(result.enabled) # True or False
print(result.variant) # 'the-variant-value' or None
print(result.payload) # {'foo': 'bar'}
print(result.get_value()) # 'the-variant-value' or True or False
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
```
Breaking change:
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
Before:
```python
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
```
After:
```python
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
```
## 3.25.0 2025-04-15
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.
## 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)
@@ -5,7 +61,7 @@
## 3.22.0  2025-03-26
1. Add more information to `$feature_flag_called` events.
2. Support for the `/decide?v=3` endpoint which contains more information about feature flags.
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
## 3.21.0  2025-03-17
@@ -203,7 +259,6 @@
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
+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.
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
#/ Usage: bin/build
#/ Description: Runs linter and mypy
source bin/helpers/_utils.sh
set_source_and_root_dir
flake8 posthog --ignore E501,W503
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
#/ Usage: bin/fmt
#/ Description: Formats and lints the code
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
if [[ "$1" == "--check" ]]; then
black --check .
isort --check-only .
else
black .
isort .
fi
+26
View File
@@ -0,0 +1,26 @@
error() {
echo "$@" >&2
}
fatal() {
error "$@"
exit 1
}
set_source_and_root_dir() {
{ set +x; } 2>/dev/null
source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )"
root_dir=$(cd "$source_dir" && cd ../ && pwd)
cd "$root_dir"
}
ensure_virtual_env() {
if [ -z "$VIRTUAL_ENV" ]; then
echo "Virtual environment not activated. Activating now..."
if [ ! -f env/bin/activate ]; then
echo "Virtual environment not found. Please run 'python -m venv env' first."
exit 1
fi
source env/bin/activate
fi
}
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
#/ Usage: bin/setup
#/ Description: Sets up the dependencies needed to develop this project
source bin/helpers/_utils.sh
set_source_and_root_dir
if [ ! -d "env" ]; then
python3 -m venv env
fi
source env/bin/activate
pip install -e ".[dev,test]"
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
#/ Usage: bin/test
#/ Description: Runs all the unit tests for this project
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
# Pass through all arguments to pytest
pytest "$@"
+1
View File
@@ -9,6 +9,7 @@ check_untyped_defs = True
warn_unreachable = True
strict_equality = True
ignore_missing_imports = True
exclude = env/.*|venv/.*
[mypy-django.*]
ignore_missing_imports = True
+5
View File
@@ -26,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
@@ -314,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]
"""
@@ -327,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
@@ -355,6 +358,7 @@ def capture_exception(
timestamp=timestamp,
uuid=uuid,
groups=groups,
**kwargs
)
@@ -590,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:
+266 -97
View File
@@ -1,4 +1,5 @@
import atexit
import hashlib
import logging
import numbers
import os
@@ -18,14 +19,24 @@ 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 (
DecideResponse,
FeatureFlag,
FeatureFlagResult,
FlagMetadata,
FlagsAndPayloads,
FlagsResponse,
FlagValue,
normalize_decide_response,
normalize_flags_response,
to_flags_and_payloads,
to_payloads,
to_values,
@@ -42,6 +53,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 = 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():
"""
@@ -97,6 +178,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."""
@@ -126,6 +244,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,
@@ -159,6 +278,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
@@ -261,7 +381,7 @@ class Client(object):
"""
Get feature flag variants for a distinct_id by calling decide.
"""
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
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(
@@ -270,7 +390,7 @@ class Client(object):
"""
Get feature flag payloads for a distinct_id by calling decide.
"""
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
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(
@@ -279,12 +399,15 @@ class Client(object):
"""
Get feature flags and payloads for a distinct_id by calling decide.
"""
resp = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
resp = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
return to_flags_and_payloads(resp)
def get_decide(
def get_flags_decision(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
) -> DecideResponse:
) -> 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:
@@ -302,9 +425,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 normalize_decide_response(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,
@@ -513,6 +648,7 @@ class Client(object):
timestamp=None,
uuid=None,
groups=None,
**kwargs,
):
if context is not None:
warnings.warn(
@@ -566,6 +702,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}")
@@ -801,6 +940,95 @@ class Client(object):
return None
return bool(response)
def _get_feature_flag_result(
self,
key,
distinct_id,
*,
override_match_value: Optional[FlagValue] = None,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
) -> Optional[FeatureFlagResult]:
require("key", key, string_types)
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.disabled:
return None
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
flag_result = None
flag_details = None
request_id = None
flag_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
flag_was_locally_evaluated = flag_value is not None
if flag_was_locally_evaluated:
lookup_match_value = override_match_value or flag_value
payload = self._compute_payload_locally(key, lookup_match_value) if lookup_match_value else None
flag_result = FeatureFlagResult.from_value_and_payload(key, lookup_match_value, payload)
elif 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
)
flag_result = FeatureFlagResult.from_flag_details(flag_details, override_match_value)
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}")
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,
flag_result.get_value() if flag_result else None,
flag_result.payload if flag_result else None,
flag_was_locally_evaluated,
groups,
disable_geoip,
request_id,
flag_details,
)
return flag_result
def get_feature_flag_result(
self,
key,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
) -> Optional[FeatureFlagResult]:
"""
Get a FeatureFlagResult object which contains the flag result and payload 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.
"""
return self._get_feature_flag_result(
key,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_feature_flag(
self,
key,
@@ -820,47 +1048,17 @@ class Client(object):
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)
if self.disabled:
return None
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
feature_flag_result = self.get_feature_flag_result(
key,
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
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
return feature_flag_result.get_value() if feature_flag_result else None
def _locally_evaluate_flag(
self,
@@ -901,7 +1099,7 @@ class Client(object):
key,
distinct_id,
*,
match_value=None,
match_value: Optional[FlagValue] = None,
groups={},
person_properties={},
group_properties={},
@@ -909,48 +1107,18 @@ class Client(object):
send_feature_flag_events=True,
disable_geoip=None,
):
if self.disabled:
return None
if match_value is None:
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)
flag_was_locally_evaluated = payload 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
)
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
feature_flag_result = self._get_feature_flag_result(
key,
distinct_id,
override_match_value=match_value,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
return feature_flag_result.payload if feature_flag_result else None
def _get_feature_flag_details_from_decide(
self,
@@ -964,7 +1132,7 @@ class Client(object):
"""
Calls /decide and returns the flag details and request id
"""
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
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
@@ -974,7 +1142,7 @@ class Client(object):
self,
distinct_id: str,
key: str,
response: FlagValue,
response: Optional[FlagValue],
payload: Optional[str],
flag_was_locally_evaluated: bool,
groups: dict[str, str],
@@ -982,7 +1150,7 @@ class Client(object):
request_id: Optional[str],
flag_details: Optional[FeatureFlag],
):
feature_flag_reported_key = f"{key}_{str(response)}"
feature_flag_reported_key = f"{key}_{'::null::' if response is None else str(response)}"
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
properties: dict[str, Any] = {
@@ -993,6 +1161,7 @@ class Client(object):
}
if payload:
# if payload is not a string, json serialize it to a string
properties["$feature_flag_payload"] = payload
if request_id:
@@ -1095,7 +1264,7 @@ class Client(object):
if fallback_to_decide and not only_evaluate_locally:
try:
decide_response = self.get_decide(
decide_response = self.get_flags_decision(
distinct_id,
groups=groups,
person_properties=person_properties,
+3 -3
View File
@@ -1,9 +1,9 @@
import json
import logging
import time
from threading import Thread
import backoff
import monotonic
from posthog.request import APIError, DatetimeSerializer, batch_post
@@ -96,11 +96,11 @@ class Consumer(Thread):
queue = self.queue
items = []
start_time = monotonic.monotonic()
start_time = time.monotonic()
total_size = 0
while len(items) < self.flush_at:
elapsed = monotonic.monotonic() - start_time
elapsed = time.monotonic() - start_time
if elapsed >= self.flush_interval:
break
try:
+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
+7 -1
View File
@@ -52,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"
@@ -106,6 +106,12 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
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)
+11 -2
View File
@@ -3,12 +3,21 @@ import time
from unittest.mock import patch
import pytest
from anthropic.types import Message, Usage
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
try:
from anthropic.types import Message, Usage
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
# Skip all tests if Anthropic is not available
pytestmark = pytest.mark.skipif(not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available")
@pytest.fixture
def mock_client():
+67 -75
View File
@@ -8,19 +8,42 @@ from typing import List, Literal, Optional, TypedDict, Union
from unittest.mock import patch
import pytest
from langchain_anthropic.chat_models import ChatAnthropic
from langchain_community.chat_models.fake import FakeMessagesListChatModel
from langchain_community.llms.fake import FakeListLLM, FakeStreamingListLLM
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
from langchain_core.tools import tool
from langchain_openai.chat_models import ChatOpenAI
from langgraph.graph.state import END, START, StateGraph
from langgraph.prebuilt import create_react_agent
from posthog.ai.langchain import CallbackHandler
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
try:
from langchain_anthropic.chat_models import ChatAnthropic
from langchain_community.chat_models.fake import FakeMessagesListChatModel
from langchain_community.llms.fake import FakeListLLM, FakeStreamingListLLM
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
from langchain_core.tools import tool
from langchain_openai.chat_models import ChatOpenAI
from langgraph.graph.state import END, START, StateGraph
from langgraph.prebuilt import create_react_agent
from posthog.ai.langchain import CallbackHandler
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
LANGCHAIN_AVAILABLE = True
except ImportError:
class FakeListLLM:
pass
class FakeStreamingListLLM:
pass
class HumanMessage:
pass
class AIMessage:
pass
LANGCHAIN_AVAILABLE = False
# Skip all tests if LangChain is not available
pytestmark = pytest.mark.skipif(not LANGCHAIN_AVAILABLE, reason="LangChain package is not available")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
@@ -618,88 +641,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"
+20 -11
View File
@@ -3,18 +3,27 @@ import time
from unittest.mock import patch
import pytest
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
from openai.types.completion_usage import CompletionUsage
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
from openai.types.embedding import Embedding
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
from posthog.ai.openai import OpenAI
try:
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
from openai.types.completion_usage import CompletionUsage
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
from openai.types.embedding import Embedding
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
from posthog.ai.openai import OpenAI
OPENAI_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
# Skip all tests if OpenAI is not available
pytestmark = pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI package is not available")
@pytest.fixture
+120 -54
View File
@@ -1,3 +1,4 @@
import hashlib
import time
import unittest
from datetime import datetime
@@ -7,7 +8,7 @@ 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
@@ -263,9 +264,16 @@ class TestClient(unittest.TestCase):
"WARNING:posthog:No exception information available",
)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
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.flags")
def test_basic_capture_with_feature_flags(self, patch_flags):
patch_flags.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)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
@@ -282,11 +290,11 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature"])
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@mock.patch("posthog.client.flags")
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags):
patch_flags.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 = {
@@ -372,7 +380,7 @@ class TestClient(unittest.TestCase):
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)
self.assertEqual(patch_flags.call_count, 0)
# test that flags are not evaluated without local evaluation
client.feature_flags = []
@@ -404,9 +412,9 @@ class TestClient(unittest.TestCase):
self.assertEqual(client.cohorts, {})
self.assertIn("PostHog feature flags quota limited", logs.output[0])
@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"}}
@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
patch_flags.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 = {
@@ -479,11 +487,11 @@ class TestClient(unittest.TestCase):
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/person-flag" not in msg["properties"]
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
@@ -504,8 +512,8 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
self.assertEqual(patch_decide.call_count, 1)
patch_decide.assert_called_with(
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
@@ -516,9 +524,9 @@ class TestClient(unittest.TestCase):
disable_geoip=True,
)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(self, patch_decide):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
@@ -546,8 +554,8 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
self.assertEqual(patch_decide.call_count, 1)
patch_decide.assert_called_with(
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=12,
@@ -558,9 +566,9 @@ class TestClient(unittest.TestCase):
disable_geoip=False,
)
@mock.patch("posthog.client.decide")
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_flags):
patch_flags.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)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=False)
@@ -577,7 +585,7 @@ class TestClient(unittest.TestCase):
self.assertTrue("$feature/beta-feature" not in msg["properties"])
self.assertTrue("$active_feature_flags" not in msg["properties"])
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
def test_stringifies_distinct_id(self):
# A large number that loses precision in node:
@@ -934,29 +942,29 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg, "disabled")
@mock.patch("posthog.client.decide")
def test_disabled_with_feature_flags(self, patch_decide):
@mock.patch("posthog.client.flags")
def test_disabled_with_feature_flags(self, patch_flags):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
response = client.get_feature_flag("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
patch_flags.assert_not_called()
response = client.feature_enabled("beta-feature", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
patch_flags.assert_not_called()
response = client.get_all_flags("12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
patch_flags.assert_not_called()
response = client.get_feature_flag_payload("key", "12345")
self.assertIsNone(response)
patch_decide.assert_not_called()
patch_flags.assert_not_called()
response = client.get_all_flags_and_payloads("12345")
self.assertEqual(response, {"featureFlags": None, "featureFlagPayloads": None})
patch_decide.assert_not_called()
patch_flags.assert_not_called()
# no capture calls
self.assertTrue(client.queue.empty())
@@ -1004,14 +1012,14 @@ class TestClient(unittest.TestCase):
client.flush()
self.assertTrue("$geoip_disable" not in msg["properties"])
@mock.patch("posthog.client.decide")
def test_disable_geoip_default_on_decide(self, patch_decide):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_disable_geoip_default_on_decide(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
@@ -1021,9 +1029,9 @@ class TestClient(unittest.TestCase):
group_properties={},
disable_geoip=True,
)
patch_decide.reset_mock()
patch_flags.reset_mock()
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
@@ -1033,9 +1041,9 @@ class TestClient(unittest.TestCase):
group_properties={},
disable_geoip=True,
)
patch_decide.reset_mock()
patch_flags.reset_mock()
client.get_all_flags_and_payloads("all_flags_payloads_id")
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
timeout=3,
@@ -1058,9 +1066,9 @@ class TestClient(unittest.TestCase):
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 = {
@mock.patch("posthog.client.flags")
def test_default_properties_get_added_properly(self, patch_flags):
patch_flags.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)
@@ -1071,7 +1079,7 @@ class TestClient(unittest.TestCase):
person_properties={"x1": "y1"},
group_properties={"company": {"x": "y"}},
)
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
@@ -1085,7 +1093,7 @@ class TestClient(unittest.TestCase):
disable_geoip=False,
)
patch_decide.reset_mock()
patch_flags.reset_mock()
client.get_feature_flag(
"random_key",
"some_id",
@@ -1097,7 +1105,7 @@ class TestClient(unittest.TestCase):
}
},
)
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
@@ -1111,10 +1119,10 @@ class TestClient(unittest.TestCase):
disable_geoip=False,
)
patch_decide.reset_mock()
patch_flags.reset_mock()
# test nones
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
patch_decide.assert_called_with(
patch_flags.assert_called_with(
"random_key",
"http://app2.posthog.com",
timeout=3,
@@ -1214,9 +1222,9 @@ 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 = {
@mock.patch("posthog.client.flags")
def test_get_decide_returns_normalized_decide_response(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False},
"featureFlagPayloads": {"beta-feature": '{"some": "data"}'},
"errorsWhileComputingFlags": False,
@@ -1228,7 +1236,7 @@ class TestClient(unittest.TestCase):
groups = {"test_group_type": "test_group_id"}
person_properties = {"test_property": "test_value"}
response = client.get_decide(distinct_id, groups, person_properties)
response = client.get_flags_decision(distinct_id, groups, person_properties)
assert response == {
"flags": {
@@ -1263,3 +1271,61 @@ class TestClient(unittest.TestCase):
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_get_flags_decision_rollout(self, patch_flags, patch_decide):
# 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 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, 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))
+2 -2
View File
@@ -69,8 +69,8 @@ class TestFeatureFlag(unittest.TestCase):
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
self.assertIsNone(reason.condition_index) # default value
self.assertEqual(reason.description, "")
# Test with None
self.assertIsNone(FlagReason.from_json(None))
+402
View File
@@ -0,0 +1,402 @@
import unittest
import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
class TestFeatureFlagResult(unittest.TestCase):
def test_from_bool_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, [1, 2, 3])
def test_from_false_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}')
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_variant_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, True)
def test_from_none_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}')
self.assertIsNone(result)
def test_from_boolean_flag_details(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, "Some string")
def test_from_boolean_flag_details_with_override_variant_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, "Some string")
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_boolean_flag_details_with_override_false_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_variant_flag_details(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, {"some": "value"})
def test_from_none_flag_details(self):
result = FeatureFlagResult.from_flag_details(None)
self.assertIsNone(result)
def test_from_flag_details_with_none_payload(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertIsNone(result.payload)
class TestGetFeatureFlagResult(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.capture_patch = mock.patch.object(Client, "capture")
cls.capture_patch.start()
@classmethod
def tearDownClass(cls):
cls.capture_patch.stop()
def set_fail(self, e, batch):
"""Mark the failure handler"""
print("FAIL", e, batch) # noqa: T201
self.failed = True
def setUp(self):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": "300"},
},
}
self.client.feature_flags = [basic_flag]
flag_result = self.client.get_feature_flag_result(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
)
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/person-flag": True,
"$feature_flag_payload": 300,
},
groups={},
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"multivariate": {
"variants": [
{"key": "variant-1", "rollout_percentage": 50},
{"key": "variant-2", "rollout_percentage": 50},
]
},
"payloads": {"variant-1": '{"some": "value"}'},
},
}
self.client.feature_flags = [basic_flag]
flag_result = self.client.get_feature_flag_result(
"person-flag", "distinct_id", person_properties={"region": "USA"}
)
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, "variant-1")
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, {"some": "value"})
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": True,
"$feature/person-flag": "variant-1",
"$feature_flag_payload": {"some": "value"},
},
groups={},
disable_geoip=None,
)
another_flag_result = self.client.get_feature_flag_result(
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
)
self.assertEqual(another_flag_result.enabled, True)
self.assertEqual(another_flag_result.variant, "variant-2")
self.assertEqual(another_flag_result.get_value(), "variant-2")
self.assertIsNone(another_flag_result.payload)
patch_capture.assert_called_with(
"another-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-2",
"locally_evaluated": True,
"$feature/person-flag": "variant-2",
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": None,
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
"payload": "300",
},
},
},
}
flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id")
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
"$feature/person-flag": True,
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 23,
"$feature_flag_version": 42,
"$feature_flag_payload": 300,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": "variant-1",
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 1,
"version": 2,
"payload": "[1, 2, 3]",
},
},
},
}
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, "variant-1")
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, [1, 2, 3])
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": False,
"$feature/person-flag": "variant-1",
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 1,
"$feature_flag_version": 2,
"$feature_flag_payload": [1, 2, 3],
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
patch_flags.return_value = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": None,
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
"payload": "300",
},
},
},
}
flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "no-person-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/no-person-flag": None,
},
groups={},
disable_geoip=None,
)
+190 -177
View File
@@ -121,9 +121,9 @@ class TestLocalEvaluation(unittest.TestCase):
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "sun"})
)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_group_properties(self, patch_get, patch_decide):
def test_flag_group_properties(self, patch_get, patch_flags):
self.client.feature_flags = [
{
"id": 1,
@@ -193,10 +193,10 @@ class TestLocalEvaluation(unittest.TestCase):
group_properties={"company": {"name": "Project Name 2"}},
)
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
# Now group type mappings are gone, so fall back to /decide/
patch_decide.return_value = {"featureFlags": {"group-flag": "decide-fallback-value"}}
patch_flags.return_value = {"featureFlags": {"group-flag": "decide-fallback-value"}}
self.client.group_type_mapping = {}
self.assertEqual(
@@ -209,12 +209,12 @@ class TestLocalEvaluation(unittest.TestCase):
"decide-fallback-value",
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_with_complex_definition(self, patch_get, patch_decide):
patch_decide.return_value = {"featureFlags": {"complex-flag": "decide-fallback-value"}}
def test_flag_with_complex_definition(self, patch_get, patch_flags):
patch_flags.return_value = {"featureFlags": {"complex-flag": "decide-fallback-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -273,7 +273,7 @@ class TestLocalEvaluation(unittest.TestCase):
"complex-flag", "some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
)
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
# this distinctIDs hash is < rollout %
self.assertTrue(
@@ -283,7 +283,7 @@ class TestLocalEvaluation(unittest.TestCase):
person_properties={"region": "USA", "email": "a@b.com"},
)
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
# will fall back on `/decide`, as all properties present for second group, but that group resolves to false
self.assertEqual(
@@ -294,27 +294,27 @@ class TestLocalEvaluation(unittest.TestCase):
),
"decide-fallback-value",
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
patch_decide.reset_mock()
patch_flags.reset_mock()
# same as above
self.assertEqual(
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"doesnt_matter": "1"}),
"decide-fallback-value",
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
patch_decide.reset_mock()
patch_flags.reset_mock()
# this one will need to fall back
self.assertEqual(
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"region": "USA"}),
"decide-fallback-value",
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
patch_decide.reset_mock()
patch_flags.reset_mock()
# won't need to fall back when all values are present
self.assertFalse(
@@ -324,12 +324,12 @@ class TestLocalEvaluation(unittest.TestCase):
person_properties={"region": "USA", "email": "a@b.com", "name": "X", "doesnt_matter": "1"},
)
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_fallback_to_decide(self, patch_get, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
def test_feature_flags_fallback_to_decide(self, patch_get, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -373,18 +373,18 @@ class TestLocalEvaluation(unittest.TestCase):
feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id")
self.assertEqual(feature_flag_match, "alakazam")
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
# beta-feature2 fallbacks to decide because region property not given with call
feature_flag_match = client.get_feature_flag("beta-feature2", "some-distinct-id")
self.assertEqual(feature_flag_match, "alakazam2")
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_dont_fallback_to_decide_when_only_local_evaluation_is_true(self, patch_get, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
def test_feature_flags_dont_fallback_to_decide_when_only_local_evaluation_is_true(self, patch_get, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -429,12 +429,12 @@ class TestLocalEvaluation(unittest.TestCase):
feature_flag_match = client.get_feature_flag("beta-feature", "some-distinct-id", only_evaluate_locally=True)
self.assertEqual(feature_flag_match, None)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
feature_flag_match = client.feature_enabled("beta-feature", "some-distinct-id", only_evaluate_locally=True)
self.assertEqual(feature_flag_match, None)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
# beta-feature2 should fallback to decide because region property not given with call
# but doesn't because only_evaluate_locally is true
@@ -444,12 +444,12 @@ class TestLocalEvaluation(unittest.TestCase):
feature_flag_match = client.feature_enabled("beta-feature2", "some-distinct-id", only_evaluate_locally=True)
self.assertEqual(feature_flag_match, None)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flag_never_returns_undefined_during_regular_evaluation(self, patch_get, patch_decide):
patch_decide.return_value = {"featureFlags": {}}
def test_feature_flag_never_returns_undefined_during_regular_evaluation(self, patch_get, patch_flags):
patch_flags.return_value = {"featureFlags": {}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -474,28 +474,28 @@ class TestLocalEvaluation(unittest.TestCase):
# beta-feature2 falls back to decide, and whatever decide returns is the value
self.assertFalse(client.get_feature_flag("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertFalse(client.feature_enabled("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flag_return_none_when_decide_errors_out(self, patch_get, patch_decide):
patch_decide.side_effect = APIError(400, "Decide error")
def test_feature_flag_return_none_when_decide_errors_out(self, patch_get, patch_flags):
patch_flags.side_effect = APIError(400, "Decide error")
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = []
# beta-feature2 falls back to decide, which on error returns None
self.assertIsNone(client.get_feature_flag("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertIsNone(client.feature_enabled("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("posthog.client.decide")
def test_experience_continuity_flag_not_evaluated_locally(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "decide-fallback-value"}}
@mock.patch("posthog.client.flags")
def test_experience_continuity_flag_not_evaluated_locally(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "decide-fallback-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -517,12 +517,12 @@ class TestLocalEvaluation(unittest.TestCase):
]
# decide called always because experience_continuity is set
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2", "disabled-feature": False}
} # decide should return the same flags
client = self.client
@@ -576,13 +576,13 @@ class TestLocalEvaluation(unittest.TestCase):
client.get_all_flags("distinct_id"),
{"beta-feature": "variant-1", "beta-feature2": "variant-2", "disabled-feature": False},
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
@@ -649,26 +649,26 @@ class TestLocalEvaluation(unittest.TestCase):
"beta-feature2": 300,
},
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback_empty_local_flags(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
client = self.client
client.feature_flags = []
# beta-feature value overridden by /decide
self.assertEqual(
client.get_all_flags("distinct_id"), {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(self, patch_decide, patch_capture):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
@@ -679,13 +679,13 @@ class TestLocalEvaluation(unittest.TestCase):
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
{"beta-feature": 100, "beta-feature2": 300},
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_no_fallback(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_no_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
client = self.client
client.feature_flags = [
{
@@ -720,12 +720,12 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": True, "disabled-feature": False})
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@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):
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_no_fallback(self, patch_flags, patch_capture):
client = self.client
basic_flag = {
"id": 1,
@@ -770,13 +770,13 @@ class TestLocalEvaluation(unittest.TestCase):
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"], {"beta-feature": "new"}
)
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}}
client = self.client
client.feature_flags = [
{
@@ -828,13 +828,13 @@ class TestLocalEvaluation(unittest.TestCase):
client.get_all_flags("distinct_id", only_evaluate_locally=True),
{"beta-feature": True, "disabled-feature": False},
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(self, patch_decide, patch_capture):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
"featureFlagPayloads": {"beta-feature": 100, "beta-feature2": 300},
}
@@ -901,12 +901,12 @@ class TestLocalEvaluation(unittest.TestCase):
client.get_all_flags_and_payloads("distinct_id", only_evaluate_locally=True)["featureFlagPayloads"],
{"beta-feature": "some-payload"},
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_compute_inactive_flags_locally(self, patch_decide, patch_capture):
@mock.patch("posthog.client.flags")
def test_compute_inactive_flags_locally(self, patch_flags, patch_capture):
client = self.client
client.feature_flags = [
{
@@ -941,7 +941,7 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": True, "disabled-feature": False})
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
# Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%.
@@ -978,12 +978,12 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertEqual(client.get_all_flags("distinct_id"), {"beta-feature": False, "disabled-feature": True})
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_decide):
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1020,7 +1020,7 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
@@ -1036,9 +1036,9 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(feature_flag_match, True)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1088,7 +1088,7 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
@@ -1096,19 +1096,19 @@ class TestLocalEvaluation(unittest.TestCase):
)
# even though 'other' property is not present, the cohort should still match since it's an OR condition
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
)
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_negated_cohorts(self, patch_get, patch_decide):
def test_feature_flags_local_evaluation_for_negated_cohorts(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1160,7 +1160,7 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(feature_flag_match, False)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
@@ -1168,23 +1168,23 @@ class TestLocalEvaluation(unittest.TestCase):
)
# even though 'other' property is not present, the cohort should still match since it's an OR condition
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing"}
)
# since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_get.call_count, 0)
patch_decide.reset_mock()
patch_flags.reset_mock()
feature_flag_match = client.get_feature_flag(
"beta-feature", "some-distinct-id", person_properties={"region": "USA", "other": "thing2"}
)
self.assertEqual(feature_flag_match, True)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("posthog.client.Poller")
@@ -1218,9 +1218,9 @@ class TestLocalEvaluation(unittest.TestCase):
client.debug = True
self.assertRaises(APIError, client.load_feature_flags)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple(self, patch_get, patch_decide):
def test_feature_enabled_simple(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1240,11 +1240,11 @@ class TestLocalEvaluation(unittest.TestCase):
}
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_is_false(self, patch_get, patch_decide):
def test_feature_enabled_simple_is_false(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1264,11 +1264,11 @@ class TestLocalEvaluation(unittest.TestCase):
}
]
self.assertFalse(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(self, patch_get, patch_decide):
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -1288,7 +1288,7 @@ class TestLocalEvaluation(unittest.TestCase):
}
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_with_project_api_key(self, patch_get):
@@ -1312,9 +1312,9 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_feature_enabled_request_multi_variate(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_feature_enabled_request_multi_variate(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1335,7 +1335,7 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
@@ -1357,9 +1357,9 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_get_feature_flag(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_get_feature_flag(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1386,27 +1386,27 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "variant-1")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.decide")
def test_feature_enabled_doesnt_exist(self, patch_decide, patch_poll):
@mock.patch("posthog.client.flags")
def test_feature_enabled_doesnt_exist(self, patch_flags, patch_poll):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = []
patch_decide.return_value = {"featureFlags": {}}
patch_flags.return_value = {"featureFlags": {}}
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
patch_decide.side_effect = APIError(401, "decide error")
patch_flags.side_effect = APIError(401, "decide error")
self.assertIsNone(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.decide")
def test_personal_api_key_doesnt_exist(self, patch_decide, patch_poll):
@mock.patch("posthog.client.flags")
def test_personal_api_key_doesnt_exist(self, patch_flags, patch_poll):
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = []
patch_decide.return_value = {"featureFlags": {"feature-flag": True}}
patch_flags.return_value = {"featureFlags": {"feature-flag": True}}
self.assertTrue(client.feature_enabled("feature-flag", "distinct_id"))
@@ -1422,9 +1422,9 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch("posthog.client.decide")
def test_get_feature_flag_with_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_get_feature_flag_with_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1460,11 +1460,11 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "first-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_clashing_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_flag_with_clashing_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1511,11 +1511,11 @@ class TestLocalEvaluation(unittest.TestCase):
"second-variant",
)
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_invalid_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_flag_with_invalid_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1551,11 +1551,11 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "second-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
def test_flag_with_multiple_variant_overrides(self, patch_decide):
patch_decide.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
@mock.patch("posthog.client.flags")
def test_flag_with_multiple_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = [
{
@@ -1596,10 +1596,10 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(client.get_feature_flag("beta-feature", "example_id"), "third-variant")
self.assertEqual(client.get_feature_flag("beta-feature", "another_id"), "second-variant")
# decide not called because this can be evaluated locally
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payloads_local(self, patch_decide):
@mock.patch("posthog.client.flags")
def test_boolean_feature_flag_payloads_local(self, patch_flags):
basic_flag = {
"id": 1,
"name": "Beta Feature",
@@ -1637,12 +1637,12 @@ class TestLocalEvaluation(unittest.TestCase):
),
300,
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payload_decide(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
@mock.patch("posthog.client.flags")
def test_boolean_feature_flag_payload_decide(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
@@ -1656,12 +1656,12 @@ class TestLocalEvaluation(unittest.TestCase):
),
300,
)
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_flags.call_count, 2)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.reset_mock()
@mock.patch("posthog.client.decide")
def test_multivariate_feature_flag_payloads(self, patch_decide):
@mock.patch("posthog.client.flags")
def test_multivariate_feature_flag_payloads(self, patch_flags):
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
@@ -1686,7 +1686,7 @@ class TestLocalEvaluation(unittest.TestCase):
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
"payloads": {"first-variant": '"some-payload"', "third-variant": '{"a": "json"}'},
},
}
self.client.feature_flags = [multivariate_flag]
@@ -1711,7 +1711,7 @@ class TestLocalEvaluation(unittest.TestCase):
),
"some-payload",
)
self.assertEqual(patch_decide.call_count, 0)
self.assertEqual(patch_flags.call_count, 0)
class TestMatchProperties(unittest.TestCase):
@@ -1801,15 +1801,16 @@ class TestMatchProperties(unittest.TestCase):
self.assertFalse(match_property(property_b, {"key": "three"}))
def test_match_properties_regex(self):
property_a = self.property(key="key", value="\.com$", operator="regex") # noqa: W605
property_a = self.property(key="key", value=r"\.com$", operator="regex")
self.assertTrue(match_property(property_a, {"key": "value.com"}))
self.assertTrue(match_property(property_a, {"key": "value2.com"}))
self.assertFalse(match_property(property_a, {"key": "value2com"}))
self.assertFalse(match_property(property_a, {"key": ".com343tfvalue5"}))
self.assertFalse(match_property(property_a, {"key": "Alakazam"}))
self.assertFalse(match_property(property_a, {"key": 123}))
self.assertFalse(match_property(property_a, {"key": "valuecom"}))
self.assertFalse(match_property(property_a, {"key": "value\com"})) # noqa: W605
self.assertFalse(match_property(property_a, {"key": r"value\com"}))
property_b = self.property(key="key", value="3", operator="regex")
self.assertTrue(match_property(property_b, {"key": "3"}))
@@ -2233,9 +2234,9 @@ class TestRelativeDateParsing(unittest.TestCase):
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
@mock.patch("posthog.client.flags")
def test_capture_is_called(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -2326,7 +2327,7 @@ class TestCaptureCalls(unittest.TestCase):
),
"decide-value",
)
self.assertEqual(patch_decide.call_count, 1)
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
@@ -2342,9 +2343,9 @@ class TestCaptureCalls(unittest.TestCase):
)
@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 = {
@mock.patch("posthog.client.flags")
def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture):
patch_flags.return_value = {
"flags": {
"decide-flag": {
"key": "decide-flag",
@@ -2357,7 +2358,21 @@ class TestCaptureCalls(unittest.TestCase):
"id": 23,
"version": 42,
},
}
},
"false-flag": {
"key": "false-flag",
"enabled": False,
"variant": None,
"reason": {
"code": "no_matching_condition",
"description": "No matching condition",
"condition_index": None,
},
"metadata": {
"id": 1,
"version": 2,
},
},
},
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
}
@@ -2383,9 +2398,9 @@ class TestCaptureCalls(unittest.TestCase):
)
@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 = {
@mock.patch("posthog.client.flags")
def test_capture_is_called_with_flag_details_and_payload(self, patch_flags, patch_capture):
patch_flags.return_value = {
"flags": {
"decide-flag-with-payload": {
"key": "decide-flag-with-payload",
@@ -2408,7 +2423,7 @@ class TestCaptureCalls(unittest.TestCase):
client = Client(FAKE_TEST_API_KEY)
self.assertEqual(
client.get_feature_flag_payload("decide-flag-with-payload", "some-distinct-id"), '{"foo": "bar"}'
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(
@@ -2423,15 +2438,15 @@ class TestCaptureCalls(unittest.TestCase):
"$feature_flag_id": 23,
"$feature_flag_version": 42,
"$feature_flag_request_id": "18043bf7-9cf6-44cd-b959-9662ee20d371",
"$feature_flag_payload": '{"foo": "bar"}',
"$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"}}
@mock.patch("posthog.client.flags")
def test_capture_is_called_but_does_not_add_all_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -2479,9 +2494,9 @@ class TestCaptureCalls(unittest.TestCase):
assert "$active_feature_flags" not in msg["properties"]
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called_in_get_feature_flag_payload(self, patch_decide, patch_capture):
patch_decide.return_value = {
@mock.patch("posthog.client.flags")
def test_capture_is_called_in_get_feature_flag_payload(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
}
@@ -2517,8 +2532,7 @@ class TestCaptureCalls(unittest.TestCase):
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"locally_evaluated": True,
"$feature/person-flag": True,
},
groups={},
@@ -2527,7 +2541,7 @@ class TestCaptureCalls(unittest.TestCase):
# Reset mocks for further tests
patch_capture.reset_mock()
patch_decide.reset_mock()
patch_flags.reset_mock()
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
client.get_feature_flag_payload(
@@ -2549,8 +2563,7 @@ class TestCaptureCalls(unittest.TestCase):
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"locally_evaluated": True,
"$feature/person-flag": True,
},
groups={},
@@ -2560,9 +2573,9 @@ class TestCaptureCalls(unittest.TestCase):
patch_capture.reset_mock()
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
@mock.patch("posthog.client.flags")
def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True)
client.feature_flags = [
{
@@ -2603,8 +2616,8 @@ class TestCaptureCalls(unittest.TestCase):
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_multiple_users_doesnt_out_of_memory(self, patch_decide, patch_capture):
@mock.patch("posthog.client.flags")
def test_capture_multiple_users_doesnt_out_of_memory(self, patch_flags, patch_capture):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
@@ -4724,7 +4737,7 @@ class TestConsistency(unittest.TestCase):
else:
self.assertFalse(feature_flag_match)
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_feature_flag_case_sensitive(self, mock_decide):
mock_decide.return_value = {"featureFlags": {}} # Ensure decide returns empty flags
@@ -4745,7 +4758,7 @@ class TestConsistency(unittest.TestCase):
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_feature_flag_payload_case_sensitive(self, mock_decide):
mock_decide.return_value = {
"featureFlags": {"Beta-Feature": True},
@@ -4772,7 +4785,7 @@ class TestConsistency(unittest.TestCase):
self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
mock_decide.return_value = {
"featureFlags": {"Beta-Feature": True},
+5 -5
View File
@@ -7,7 +7,7 @@ from posthog.types import (
FlagMetadata,
FlagReason,
LegacyFlagMetadata,
normalize_decide_response,
normalize_flags_response,
to_flags_and_payloads,
)
@@ -31,7 +31,7 @@ class TestTypes(unittest.TestCase):
"requestId": "test-id",
}
result = normalize_decide_response(resp)
result = normalize_flags_response(resp)
flag = result["flags"]["my-flag"]
self.assertEqual(flag.key, "my-flag")
@@ -56,7 +56,7 @@ class TestTypes(unittest.TestCase):
"requestId": "test-id",
}
result = normalize_decide_response(resp)
result = normalize_flags_response(resp)
flag = result["flags"]["my-flag"]
self.assertEqual(flag.key, "my-flag")
@@ -75,7 +75,7 @@ class TestTypes(unittest.TestCase):
# Test legacy response with boolean flag
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
result = normalize_decide_response(resp)
result = normalize_flags_response(resp)
self.assertIn("requestId", result)
self.assertIsNone(result["requestId"])
@@ -168,7 +168,7 @@ class TestTypes(unittest.TestCase):
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
}
normalized = normalize_decide_response(resp)
normalized = normalize_flags_response(resp)
result = to_flags_and_payloads(normalized)
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
+1 -1
View File
@@ -65,7 +65,7 @@ class TestUtils(unittest.TestCase):
def test_clean_with_dates(self):
dict_with_dates = {
"birthdate": date(1980, 1, 1),
"registration": datetime.utcnow(),
"registration": datetime.now(tz=tzutc()),
}
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
+108 -13
View File
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass
from typing import Any, List, Optional, TypedDict, Union, cast
@@ -7,7 +8,7 @@ FlagValue = Union[bool, str]
@dataclass(frozen=True)
class FlagReason:
code: str
condition_index: int
condition_index: Optional[int]
description: str
@classmethod
@@ -16,7 +17,7 @@ class FlagReason:
return None
return cls(
code=resp.get("code", ""),
condition_index=resp.get("condition_index", 0),
condition_index=resp.get("condition_index"),
description=resp.get("description", ""),
)
@@ -90,7 +91,7 @@ class FeatureFlag:
)
class DecideResponse(TypedDict, total=False):
class FlagsResponse(TypedDict, total=False):
flags: dict[str, FeatureFlag]
errorsWhileComputingFlags: bool
requestId: str
@@ -102,15 +103,109 @@ class FlagsAndPayloads(TypedDict, total=True):
featureFlagPayloads: Optional[dict[str, Any]]
def normalize_decide_response(resp: Any) -> DecideResponse:
@dataclass(frozen=True)
class FeatureFlagResult:
"""
Normalize the response from the decide API endpoint into a v4 DecideResponse.
The result of calling a feature flag which includes the flag result, variant, and payload.
Attributes:
key (str): The unique identifier of the feature flag.
enabled (bool): Whether the feature flag is enabled for the current context.
variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise.
payload (Optional[Any]): Additional data associated with the feature flag, if any.
reason (Optional[str]): A description of why the flag was enabled or disabled, if available.
"""
key: str
enabled: bool
variant: Optional[str]
payload: Optional[Any]
reason: Optional[str]
def get_value(self) -> FlagValue:
"""
Returns the value of the flag. This is the variant if it exists, otherwise the enabled value.
This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event.
Returns:
FlagValue: Either a string variant or boolean value representing the flag's state.
"""
return self.variant or self.enabled
@classmethod
def from_value_and_payload(
cls, key: str, value: Union[FlagValue, None], payload: Any
) -> Union["FeatureFlagResult", None]:
"""
Creates a FeatureFlagResult from a flag value and payload.
Args:
key (str): The unique identifier of the feature flag.
value (Union[FlagValue, None]): The value of the flag (string variant or boolean).
payload (Any): Additional data associated with the feature flag.
Returns:
Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None.
"""
if value is None:
return None
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
return cls(
key=key,
enabled=enabled,
variant=variant,
payload=json.loads(payload) if isinstance(payload, str) else payload,
reason=None,
)
@classmethod
def from_flag_details(
cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None
) -> "FeatureFlagResult | None":
"""
Create a FeatureFlagResult from a FeatureFlag object.
Args:
details (Union[FeatureFlag, None]): The FeatureFlag object to convert.
override_match_value (Optional[FlagValue]): If provided, this value will be used to populate
the enabled and variant fields instead of the values from the FeatureFlag.
Returns:
FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None.
"""
if details is None:
return None
if override_match_value is not None:
enabled, variant = (
(True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None)
)
else:
enabled, variant = (details.enabled, details.variant)
return cls(
key=details.key,
enabled=enabled,
variant=variant,
payload=(
json.loads(details.metadata.payload)
if isinstance(details.metadata.payload, str)
else details.metadata.payload
),
reason=details.reason.description if details.reason else None,
)
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 API endpoint.
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
Returns:
A DecideResponse containing feature flags and their details.
A FlagsResponse containing feature flags and their details.
"""
if "requestId" not in resp:
resp["requestId"] = None
@@ -133,16 +228,16 @@ def normalize_decide_response(resp: Any) -> DecideResponse:
for key, value in featureFlags.items():
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
resp["flags"] = flags
return cast(DecideResponse, resp)
return cast(FlagsResponse, resp)
def to_flags_and_payloads(resp: DecideResponse) -> FlagsAndPayloads:
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
"""
Convert a DecideResponse into a FlagsAndPayloads object which is a
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 DecideResponse containing feature flags and their payloads.
resp: A FlagsResponse containing feature flags and their payloads.
Returns:
A tuple containing:
@@ -152,7 +247,7 @@ def to_flags_and_payloads(resp: DecideResponse) -> FlagsAndPayloads:
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
def to_values(response: DecideResponse) -> Optional[dict[str, FlagValue]]:
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
if "flags" not in response:
return None
@@ -160,7 +255,7 @@ def to_values(response: DecideResponse) -> Optional[dict[str, FlagValue]]:
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
def to_payloads(response: DecideResponse) -> Optional[dict[str, str]]:
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
if "flags" not in response:
return None
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.22.0"
VERSION = "4.0.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+3 -2
View File
@@ -12,14 +12,15 @@ from version import VERSION
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"python-dateutil>2.1",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
+5 -13
View File
@@ -12,14 +12,15 @@ from version import VERSION
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"python-dateutil>2.1",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
@@ -58,19 +59,10 @@ setup(
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
)