Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aea6b72d3 | ||
|
|
7bb7c90a49 | ||
|
|
c1f668e8bb | ||
|
|
a1b81ee3d9 | ||
|
|
a6fb39902d | ||
|
|
a1583f6627 | ||
|
|
dfa7f70a04 | ||
|
|
d00d69e448 | ||
|
|
a833955ee0 | ||
|
|
58fbe05cb0 | ||
|
|
7a6e185902 | ||
|
|
e9c72e7f8c | ||
|
|
51380ac207 | ||
|
|
53ed80366b | ||
|
|
18729e33b8 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]"
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.22.0"
|
||||
VERSION = "4.0.1"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user