Compare commits
15
Commits
no-context
...
v3.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0f9e35c1 | ||
|
|
337f7da7c5 | ||
|
|
31652d5ec3 | ||
|
|
6764c786a4 | ||
|
|
1b57a96509 | ||
|
|
38683e8550 | ||
|
|
a5c8f62a63 | ||
|
|
e480b88dce | ||
|
|
3ff2a8599d | ||
|
|
a3cf4ad5fb | ||
|
|
cec532f241 | ||
|
|
415508087f | ||
|
|
994003fc42 | ||
|
|
319b3807f3 | ||
|
|
5e7314f89d |
@@ -13,10 +13,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
@@ -42,19 +42,26 @@ jobs:
|
||||
run: |
|
||||
isort --check-only .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
|
||||
tests:
|
||||
name: Python tests
|
||||
name: Python ${{ matrix.python-version }} tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
## 3.15.0 - 2025-02-19
|
||||
|
||||
1. Support quota-limited feature flags
|
||||
|
||||
## 3.14.2 - 2025-02-19
|
||||
|
||||
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
|
||||
|
||||
## 3.14.1 - 2025-02-18
|
||||
|
||||
1. Add support for Bedrock Anthropic Usage
|
||||
|
||||
## 3.13.0 - 2025-02-12
|
||||
|
||||
1. Automatically retry connection errors
|
||||
|
||||
## 3.12.1 - 2025-02-11
|
||||
|
||||
1. Fix mypy support for 3.12.0
|
||||
2. Deprecate `is_simple_flag`
|
||||
|
||||
## 3.12.0 - 2025-02-11
|
||||
|
||||
1. Add support for OpenAI beta parse API.
|
||||
2. Deprecate `context` parameter
|
||||
|
||||
## 3.11.1 - 2025-02-06
|
||||
|
||||
1. Fix LangChain callback handler to capture parent run ID.
|
||||
|
||||
## 3.11.0 - 2025-01-28
|
||||
|
||||
1. Add the `$ai_span` event to the LangChain callback handler to capture the input and output of intermediary chains.
|
||||
|
||||
> LLM observability naming change: event property `$ai_trace_name` is now `$ai_span_name`.
|
||||
|
||||
2. Fix serialiazation of Pydantic models in methods.
|
||||
|
||||
+10
-4
@@ -1,10 +1,15 @@
|
||||
# PostHog Python library example
|
||||
|
||||
# Import the library
|
||||
# import time
|
||||
import argparse
|
||||
|
||||
import posthog
|
||||
|
||||
# Add argument parsing
|
||||
parser = argparse.ArgumentParser(description="PostHog Python library example")
|
||||
parser.add_argument(
|
||||
"--flag", default="person-on-events-enabled", help="Feature flag key to check (default: person-on-events-enabled)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
@@ -18,7 +23,7 @@ posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
args.flag, # Use the flag from command line arguments
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
@@ -96,6 +101,7 @@ print(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
)
|
||||
)
|
||||
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
|
||||
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import posthog
|
||||
from posthog.ai.openai import AsyncOpenAI, OpenAI
|
||||
|
||||
# Example credentials - replace these with your own or use environment variables
|
||||
posthog.project_api_key = os.getenv("POSTHOG_PROJECT_API_KEY", "your-project-api-key")
|
||||
posthog.personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "your-personal-api-key")
|
||||
posthog.host = os.getenv("POSTHOG_HOST", "http://localhost:8000") # Or https://app.posthog.com
|
||||
posthog.debug = True
|
||||
# change this to False to see usage events
|
||||
@@ -31,10 +32,11 @@ def main_sync():
|
||||
groups = {"company": "test_company"}
|
||||
|
||||
try:
|
||||
basic_openai_call(distinct_id, trace_id, properties, groups)
|
||||
streaming_openai_call(distinct_id, trace_id, properties, groups)
|
||||
embedding_openai_call(distinct_id, trace_id, properties, groups)
|
||||
image_openai_call()
|
||||
# basic_openai_call(distinct_id, trace_id, properties, groups)
|
||||
# streaming_openai_call(distinct_id, trace_id, properties, groups)
|
||||
# embedding_openai_call(distinct_id, trace_id, properties, groups)
|
||||
# image_openai_call()
|
||||
beta_openai_call(distinct_id, trace_id, properties, groups)
|
||||
except Exception as e:
|
||||
print("Error during OpenAI call:", str(e))
|
||||
|
||||
@@ -187,10 +189,32 @@ async def embedding_async_openai_call(posthog_distinct_id, posthog_trace_id, pos
|
||||
return response
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
participants: list[str]
|
||||
|
||||
|
||||
def beta_openai_call(distinct_id, trace_id, properties, groups):
|
||||
response = openai_client.beta.chat.completions.parse(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "Extract the event information."},
|
||||
{"role": "user", "content": "Alice and Bob are going to a science fair on Friday."},
|
||||
],
|
||||
response_format=CalendarEvent,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
posthog_groups=groups,
|
||||
)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
# HOW TO RUN:
|
||||
# comment out one of these to run the other
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_sync()
|
||||
|
||||
# asyncio.run(main_async())
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/utils.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
|
||||
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
|
||||
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
|
||||
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment]
|
||||
posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef]
|
||||
posthog/consumer.py:0: error: Need type annotation for "items" (hint: "items: list[<type>] = ...") [var-annotated]
|
||||
posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator]
|
||||
posthog/consumer.py:0: note: Right operand is of type "int | str"
|
||||
posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator]
|
||||
posthog/consumer.py:0: note: Left operand is of type "int | str"
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil.relativedelta" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore]
|
||||
posthog/client.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-python-dateutil"
|
||||
posthog/client.py:0: note: (or run "mypy --install-types" to install all missing stub packages)
|
||||
posthog/client.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
|
||||
posthog/client.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-six"
|
||||
posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef]
|
||||
posthog/client.py:0: error: Need type annotation for "queue" [var-annotated]
|
||||
posthog/client.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "capture" [call-arg]
|
||||
posthog/__init__.py:0: note: "capture" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
example.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Unpacked dict entry 11 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai_async.py:0: error: Unpacked dict entry 8 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Unpacked dict entry 11 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/openai/openai.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/openai/openai.py:0: error: Unpacked dict entry 8 has incompatible type "dict[str, Any] | None"; expected "SupportsKeysAndGetItem[str, Any]" [dict-item]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic_async.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
posthog/ai/anthropic/anthropic.py:0: error: Incompatible types in assignment (expression has type "UUID", variable has type "str | None") [assignment]
|
||||
llm_observability_examples.py:0: error: Argument "posthog_client" to "OpenAI" has incompatible type Module; expected "Client" [arg-type]
|
||||
llm_observability_examples.py:0: error: Argument "posthog_client" to "AsyncOpenAI" has incompatible type Module; expected "Client" [arg-type]
|
||||
@@ -0,0 +1,38 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
plugins =
|
||||
pydantic.mypy
|
||||
strict_optional = True
|
||||
no_implicit_optional = True
|
||||
warn_unused_ignores = True
|
||||
check_untyped_defs = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sentry_sdk.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-posthog.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-posthog.*.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-openai.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain_core.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-anthropic.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpx.*]
|
||||
ignore_missing_imports = True
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime # noqa: F401
|
||||
import warnings
|
||||
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
|
||||
|
||||
from posthog.client import Client
|
||||
@@ -64,6 +65,14 @@ def capture(
|
||||
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"capture",
|
||||
distinct_id=distinct_id,
|
||||
@@ -102,6 +111,14 @@ def identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"identify",
|
||||
distinct_id=distinct_id,
|
||||
@@ -137,6 +154,14 @@ def set(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"set",
|
||||
distinct_id=distinct_id,
|
||||
@@ -172,6 +197,14 @@ def set_once(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"set_once",
|
||||
distinct_id=distinct_id,
|
||||
@@ -208,6 +241,14 @@ def group_identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
@@ -245,6 +286,14 @@ def alias(
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
@@ -288,6 +337,14 @@ def capture_exception(
|
||||
|
||||
```
|
||||
"""
|
||||
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return _proxy(
|
||||
"capture_exception",
|
||||
exception=exception,
|
||||
@@ -435,6 +492,26 @@ def get_feature_flag_payload(
|
||||
)
|
||||
|
||||
|
||||
def get_remote_config_payload(
|
||||
key, # type: str
|
||||
):
|
||||
"""Get the payload for a remote config feature flag.
|
||||
|
||||
Args:
|
||||
key: The key of the feature flag
|
||||
|
||||
Returns:
|
||||
The payload associated with the feature flag. If payload is encrypted, the return value will decrypted
|
||||
|
||||
Note:
|
||||
Requires personal_api_key to be set for authentication
|
||||
"""
|
||||
return _proxy(
|
||||
"get_remote_config_payload",
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups={},
|
||||
|
||||
@@ -239,6 +239,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_tool_end(
|
||||
@@ -275,6 +276,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_retriever_end(
|
||||
@@ -595,6 +597,9 @@ def _parse_usage_model(
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
# Bedrock Anthropic
|
||||
("prompt_tokens", "input"),
|
||||
("completion_tokens", "output"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
@@ -625,6 +630,10 @@ def _parse_usage(response: LLMResult):
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"])
|
||||
break
|
||||
|
||||
for generation_chunk in generation:
|
||||
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
|
||||
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
|
||||
|
||||
@@ -31,6 +31,7 @@ class OpenAI(openai.OpenAI):
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.Chat):
|
||||
@@ -249,3 +250,45 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.Beta):
|
||||
_client: OpenAI
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.Chat):
|
||||
_client: OpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions):
|
||||
_client: OpenAI
|
||||
|
||||
def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
self.beta = WrappedBeta(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.AsyncChat):
|
||||
@@ -248,3 +249,45 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta(openai.resources.beta.AsyncBeta):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client)
|
||||
|
||||
|
||||
class WrappedBetaChat(openai.resources.beta.chat.AsyncChat):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncCompletions):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
+100
-24
@@ -3,6 +3,7 @@ import logging
|
||||
import numbers
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -14,7 +15,7 @@ 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
|
||||
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -147,14 +148,19 @@ class Client(object):
|
||||
consumer.start()
|
||||
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$identify",
|
||||
@@ -218,8 +224,14 @@ class Client(object):
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
require("event", event, string_types)
|
||||
@@ -227,7 +239,6 @@ class Client(object):
|
||||
msg = {
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"uuid": uuid,
|
||||
@@ -245,7 +256,7 @@ class Client(object):
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
|
||||
elif self.feature_flags:
|
||||
elif self.feature_flags and event != "$feature_flag_called":
|
||||
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
|
||||
@@ -264,14 +275,19 @@ class Client(object):
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$set",
|
||||
@@ -281,14 +297,19 @@ class Client(object):
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
msg = {
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"$set_once": properties,
|
||||
"event": "$set_once",
|
||||
@@ -308,8 +329,13 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
distinct_id=None,
|
||||
):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("group_type", group_type, ID_TYPES)
|
||||
require("group_key", group_key, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
@@ -328,14 +354,18 @@ class Client(object):
|
||||
},
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
context = context or {}
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -346,7 +376,6 @@ class Client(object):
|
||||
"alias": distinct_id,
|
||||
},
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"event": "$create_alias",
|
||||
"distinct_id": previous_id,
|
||||
}
|
||||
@@ -356,9 +385,14 @@ class Client(object):
|
||||
def page(
|
||||
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
@@ -369,7 +403,6 @@ class Client(object):
|
||||
"event": "$pageview",
|
||||
"properties": properties,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -386,6 +419,13 @@ class Client(object):
|
||||
uuid=None,
|
||||
groups=None,
|
||||
):
|
||||
if context is not None:
|
||||
warnings.warn(
|
||||
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
|
||||
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
|
||||
try:
|
||||
@@ -446,7 +486,6 @@ class Client(object):
|
||||
timestamp = datetime.now(tz=tzutc())
|
||||
|
||||
require("timestamp", timestamp, datetime)
|
||||
require("context", msg["context"], dict)
|
||||
|
||||
# add common
|
||||
timestamp = guess_timezone(timestamp)
|
||||
@@ -561,6 +600,19 @@ class Client(object):
|
||||
"To use feature flags, please set a personal_api_key "
|
||||
"More information: https://posthog.com/docs/api/overview",
|
||||
)
|
||||
elif e.status == 402:
|
||||
self.log.warning("[FEATURE FLAGS] PostHog feature flags quota limited")
|
||||
# Reset all feature flag data when quota limited
|
||||
self.feature_flags = []
|
||||
self.feature_flags_by_key = {}
|
||||
self.group_type_mapping = {}
|
||||
self.cohorts = {}
|
||||
|
||||
if self.debug:
|
||||
raise APIError(
|
||||
status=402,
|
||||
message="PostHog feature flags quota limited",
|
||||
)
|
||||
else:
|
||||
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||||
except Exception as e:
|
||||
@@ -683,7 +735,6 @@ class Client(object):
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
|
||||
# If loading in previous line failed
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
if flag["key"] == key:
|
||||
@@ -702,6 +753,7 @@ class Client(object):
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
|
||||
continue
|
||||
break
|
||||
|
||||
flag_was_locally_evaluated = response is not None
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
@@ -783,7 +835,7 @@ class Client(object):
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
response = responses_and_payloads["featureFlags"].get(key, None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
|
||||
payload = responses_and_payloads["featureFlagPayloads"].get(str(key), None)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
@@ -810,16 +862,40 @@ class Client(object):
|
||||
|
||||
return payload
|
||||
|
||||
def get_remote_config_payload(self, key: str):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
if self.personal_api_key is None:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] You have to specify a personal_api_key to fetch decrypted feature flag payloads."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return remote_config(
|
||||
self.personal_api_key,
|
||||
self.host,
|
||||
key,
|
||||
timeout=self.feature_flags_request_timeout_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}")
|
||||
|
||||
def _compute_payload_locally(self, key, match_value):
|
||||
payload = None
|
||||
|
||||
if self.feature_flags_by_key is None:
|
||||
return payload
|
||||
|
||||
flag_definition = self.feature_flags_by_key.get(key) or {}
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
payload = flag_payloads.get(str(match_value).lower(), None)
|
||||
flag_definition = self.feature_flags_by_key.get(key)
|
||||
if flag_definition:
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
# For boolean flags, convert True to "true"
|
||||
# For multivariate flags, use the variant string as-is
|
||||
lookup_value = "true" if isinstance(match_value, bool) and match_value else str(match_value)
|
||||
payload = flag_payloads.get(lookup_value, None)
|
||||
return payload
|
||||
|
||||
def get_all_flags(
|
||||
|
||||
@@ -793,7 +793,7 @@ def event_from_exception(
|
||||
|
||||
|
||||
def _module_in_list(name, items):
|
||||
# type: (str, Optional[List[str]]) -> bool
|
||||
# type: (str | None, Optional[List[str]]) -> bool
|
||||
if name is None:
|
||||
return False
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from posthog import utils
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
@@ -53,6 +54,9 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
|
||||
flag_conditions = (flag.get("filters") or {}).get("groups") or []
|
||||
is_inconclusive = False
|
||||
cohort_properties = cohort_properties or {}
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
valid_variant_keys = [variant["key"] for variant in flag_variants]
|
||||
|
||||
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
||||
# evaluated first, and the variant override is applied to the first matching condition.
|
||||
@@ -67,9 +71,7 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
|
||||
# the matching variant
|
||||
if is_condition_match(flag, distinct_id, condition, properties, cohort_properties):
|
||||
variant_override = condition.get("variant")
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
|
||||
if variant_override and variant_override in valid_variant_keys:
|
||||
variant = variant_override
|
||||
else:
|
||||
variant = get_matching_variant(flag, distinct_id)
|
||||
@@ -128,8 +130,8 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).lower() in [str(val).lower() for val in value]
|
||||
return str(value).lower() == str(override_value).lower()
|
||||
return str(override_value).casefold() in [str(val).casefold() for val in value]
|
||||
return utils.str_iequals(value, override_value)
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
@@ -140,10 +142,10 @@ def match_property(property, property_values) -> bool:
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return str(value).lower() in str(override_value).lower()
|
||||
return utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "not_icontains":
|
||||
return str(value).lower() not in str(override_value).lower()
|
||||
return not utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
|
||||
+24
-1
@@ -11,7 +11,9 @@ from dateutil.tz import tzutc
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
adapter = requests.adapters.HTTPAdapter(max_retries=2)
|
||||
_session = requests.sessions.Session()
|
||||
_session.mount("https://", adapter)
|
||||
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
@@ -66,7 +68,19 @@ def _process_response(
|
||||
log = logging.getLogger("posthog")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
response = res.json() if return_json else res
|
||||
# Handle quota limited decide responses by raising a specific error
|
||||
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
|
||||
# since most of the other services handle quota limiting in other places in the application.
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "quotaLimited" in response
|
||||
and isinstance(response["quotaLimited"], list)
|
||||
and "feature_flags" in response["quotaLimited"]
|
||||
):
|
||||
log.warning("PostHog feature flags quota limited")
|
||||
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
|
||||
return response
|
||||
try:
|
||||
payload = res.json()
|
||||
log.debug("received response: %s", payload)
|
||||
@@ -81,6 +95,11 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
|
||||
return _process_response(res, success_message="Feature flags decided 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)
|
||||
|
||||
|
||||
def batch_post(
|
||||
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
) -> requests.Response:
|
||||
@@ -105,6 +124,10 @@ class APIError(Exception):
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
|
||||
+31
-28
@@ -7,6 +7,7 @@ import mock
|
||||
import six
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -292,7 +293,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -321,7 +321,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -344,7 +343,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "false-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -387,6 +385,25 @@ class TestClient(unittest.TestCase):
|
||||
assert "$feature/false-flag" not in msg["properties"]
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags_quota_limited(self, patch_get):
|
||||
mock_response = {
|
||||
"type": "quota_limited",
|
||||
"detail": "You have exceeded your feature flag request quota",
|
||||
"code": "payment_required",
|
||||
}
|
||||
patch_get.side_effect = APIError(402, mock_response["detail"])
|
||||
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
with self.assertLogs("posthog", level="WARNING") as logs:
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertEqual(client.feature_flags, [])
|
||||
self.assertEqual(client.feature_flags_by_key, {})
|
||||
self.assertEqual(client.group_type_mapping, {})
|
||||
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"}}
|
||||
@@ -396,7 +413,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -425,7 +441,6 @@ class TestClient(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -581,16 +596,14 @@ class TestClient(unittest.TestCase):
|
||||
"distinct_id",
|
||||
"python test event",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -623,13 +636,12 @@ class TestClient(unittest.TestCase):
|
||||
def test_advanced_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -651,14 +663,11 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test_advanced_set(self):
|
||||
client = self.client
|
||||
success, msg = client.set(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
)
|
||||
success, msg = client.set("distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid")
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -681,13 +690,12 @@ class TestClient(unittest.TestCase):
|
||||
def test_advanced_set_once(self):
|
||||
client = self.client
|
||||
success, msg = client.set_once(
|
||||
"distinct_id", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["$set_once"]["trait"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
@@ -736,7 +744,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
def test_advanced_group_identify(self):
|
||||
success, msg = self.client.group_identify(
|
||||
"organization", "id:5", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
|
||||
"organization", "id:5", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
@@ -754,16 +762,14 @@ class TestClient(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
|
||||
def test_advanced_group_identify_with_distinct_id(self):
|
||||
success, msg = self.client.group_identify(
|
||||
"organization",
|
||||
"id:5",
|
||||
{"trait": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
distinct_id="distinct_id",
|
||||
)
|
||||
|
||||
@@ -783,7 +789,6 @@ class TestClient(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
|
||||
def test_basic_alias(self):
|
||||
client = self.client
|
||||
@@ -819,15 +824,13 @@ class TestClient(unittest.TestCase):
|
||||
"distinct_id",
|
||||
"https://posthog.com/contact",
|
||||
{"property": "value"},
|
||||
{"ip": "192.168.0.1"},
|
||||
datetime(2014, 9, 3),
|
||||
"new-uuid",
|
||||
timestamp=datetime(2014, 9, 3),
|
||||
uuid="new-uuid",
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
|
||||
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
|
||||
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
|
||||
self.assertEqual(msg["properties"]["property"], "value")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
@@ -1051,7 +1054,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
|
||||
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
|
||||
client.feature_flags = [{"key": "example"}]
|
||||
|
||||
self.assertFalse(client.feature_enabled("example", "distinct_id"))
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -69,6 +68,59 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertTrue(feature_flag_match)
|
||||
self.assertFalse(not_feature_flag_match)
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
self.client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "location",
|
||||
"operator": "exact",
|
||||
"value": ["Straße"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "star",
|
||||
"operator": "exact",
|
||||
"value": ["ſun"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"location": "straße"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"location": "strasse"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "ſun"})
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.client.get_feature_flag("person-flag", "some-distinct-id", person_properties={"star": "sun"})
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_flag_group_properties(self, patch_get, patch_decide):
|
||||
@@ -77,7 +129,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "group-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"aggregation_group_type_index": 0,
|
||||
@@ -170,7 +221,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -286,7 +336,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -301,7 +350,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -343,7 +391,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -358,7 +405,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -410,7 +456,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -457,7 +502,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -472,7 +516,7 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
# decide called always because experience_continuity is set
|
||||
self.assertTrue(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
|
||||
self.assertEqual(client.get_feature_flag("beta-feature", "distinct_id"), "decide-fallback-value")
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
@@ -487,7 +531,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -503,7 +546,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -518,7 +560,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -551,7 +592,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -570,7 +610,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -588,7 +627,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -654,7 +692,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -670,7 +707,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -696,7 +732,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -715,7 +750,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -751,7 +785,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -767,7 +800,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -782,7 +814,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -814,7 +845,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -833,7 +863,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -851,7 +880,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 3,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature2",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -888,7 +916,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -904,7 +931,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -927,7 +953,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": False,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -943,7 +968,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "disabled-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -969,7 +993,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
id: 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1007,13 +1030,15 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"beta-feature",
|
||||
"some-distinct-id",
|
||||
person_properties={
|
||||
"latestBuildVersion": "24.32..1",
|
||||
"latestBuildVersion": "24.32.1",
|
||||
"latestBuildVersionMajor": "24",
|
||||
"latestBuildVersionMinor": "32",
|
||||
"latestBuildVersionPatch": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
|
||||
@@ -1023,7 +1048,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1094,7 +1118,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 2,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1207,7 +1230,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1232,7 +1254,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 0,
|
||||
"filters": {
|
||||
@@ -1257,7 +1278,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": None,
|
||||
"filters": {
|
||||
@@ -1281,7 +1301,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1305,7 +1324,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1330,7 +1348,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1352,7 +1369,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1418,7 +1434,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1459,7 +1474,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1511,7 +1525,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1552,7 +1565,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -1595,7 +1607,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -1659,7 +1670,6 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
@@ -2237,7 +2247,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2337,6 +2346,55 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
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"}}
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [{"key": "region", "value": "USA"}],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Gamma Feature",
|
||||
"key": "simple-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
self.assertTrue(
|
||||
client.get_feature_flag("complex-flag", "some-distinct-id", person_properties={"region": "USA"})
|
||||
)
|
||||
|
||||
# Grab the capture message that was just added to the queue
|
||||
msg = client.queue.get(block=False)
|
||||
assert msg["event"] == "$feature_flag_called"
|
||||
assert msg["properties"]["$feature_flag"] == "complex-flag"
|
||||
assert msg["properties"]["$feature_flag_response"] is True
|
||||
assert msg["properties"]["locally_evaluated"] is True
|
||||
assert msg["properties"]["$feature/complex-flag"] is True
|
||||
assert "$feature/simple-flag" not in msg["properties"]
|
||||
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):
|
||||
@@ -2351,7 +2409,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2429,7 +2486,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2472,7 +2528,6 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "complex-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
@@ -2531,7 +2586,6 @@ class TestConsistency(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "simple-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 45}],
|
||||
@@ -3559,7 +3613,6 @@ class TestConsistency(unittest.TestCase):
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "multivariate-flag",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 55}],
|
||||
@@ -4587,3 +4640,84 @@ class TestConsistency(unittest.TestCase):
|
||||
self.assertEqual(feature_flag_match, results[i])
|
||||
else:
|
||||
self.assertFalse(feature_flag_match)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {"featureFlags": {}} # Ensure decide returns empty flags
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation is case-sensitive
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
|
||||
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_feature_flag_payload_case_sensitive(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that payload retrieval is case-sensitive
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
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")
|
||||
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
|
||||
mock_decide.return_value = {
|
||||
"featureFlags": {"Beta-Feature": True},
|
||||
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
|
||||
}
|
||||
|
||||
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "Beta-Feature",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"payloads": {
|
||||
"true": {"some": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation and payload retrieval are consistently case-sensitive
|
||||
# Only exact match should work
|
||||
self.assertTrue(client.feature_enabled("Beta-Feature", "user1"))
|
||||
self.assertEqual(client.get_feature_flag_payload("Beta-Feature", "user1"), {"some": "value"})
|
||||
|
||||
# Different cases should not match
|
||||
test_cases = ["beta-feature", "BETA-FEATURE", "bEtA-FeAtUrE"]
|
||||
for case in test_cases:
|
||||
self.assertFalse(client.feature_enabled(case, "user1"))
|
||||
self.assertIsNone(client.get_feature_flag_payload(case, "user1"))
|
||||
|
||||
@@ -2,10 +2,11 @@ import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
|
||||
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@@ -44,6 +45,36 @@ class TestRequests(unittest.TestCase):
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
|
||||
@@ -125,3 +125,43 @@ def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
return date_obj
|
||||
|
||||
|
||||
def str_icontains(source, search):
|
||||
"""
|
||||
Check if a string contains another string, ignoring case.
|
||||
|
||||
Args:
|
||||
source: The string to search within
|
||||
search: The substring to search for
|
||||
|
||||
Returns:
|
||||
bool: True if search is a substring of source (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_icontains("Hello World", "WORLD")
|
||||
True
|
||||
>>> str_icontains("Hello World", "python")
|
||||
False
|
||||
"""
|
||||
return str(search).casefold() in str(source).casefold()
|
||||
|
||||
|
||||
def str_iequals(value, comparand):
|
||||
"""
|
||||
Check if a string equals another string, ignoring case.
|
||||
|
||||
Args:
|
||||
value: The string to compare
|
||||
comparand: The string to compare with
|
||||
|
||||
Returns:
|
||||
bool: True if value and comparand are equal (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_iequals("Hello World", "hello world")
|
||||
True
|
||||
>>> str_iequals("Hello World", "hello")
|
||||
False
|
||||
"""
|
||||
return str(value).casefold() == str(comparand).casefold()
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.11.0"
|
||||
VERSION = "3.15.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -25,14 +25,24 @@ install_requires = [
|
||||
extras_require = {
|
||||
"dev": [
|
||||
"black",
|
||||
"django-stubs",
|
||||
"isort",
|
||||
"flake8",
|
||||
"flake8-print",
|
||||
"lxml",
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"types-mock",
|
||||
"types-python-dateutil",
|
||||
"types-requests",
|
||||
"types-setuptools",
|
||||
"types-six",
|
||||
"pre-commit",
|
||||
"pydantic",
|
||||
],
|
||||
"test": [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==0.3.15",
|
||||
"freezegun==1.5.1",
|
||||
"pylint",
|
||||
"flake8",
|
||||
"coverage",
|
||||
@@ -82,19 +92,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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ parser.add_argument("--type", help="The posthog message type")
|
||||
|
||||
parser.add_argument("--distinct_id", help="the user id to send the event as")
|
||||
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
|
||||
parser.add_argument("--context", help="additional context for the event (JSON-encoded)")
|
||||
|
||||
parser.add_argument("--event", help="the event name to send with the event")
|
||||
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
|
||||
@@ -48,7 +47,6 @@ def capture():
|
||||
options.event,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +56,6 @@ def page():
|
||||
name=options.name,
|
||||
anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +64,6 @@ def identify():
|
||||
options.distinct_id,
|
||||
anonymous_id=options.anonymousId,
|
||||
traits=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -75,7 +71,6 @@ def set_once():
|
||||
posthog.set_once(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,7 +78,6 @@ def set():
|
||||
posthog.set(
|
||||
options.distinct_id,
|
||||
properties=json_hash(options.traits),
|
||||
context=json_hash(options.context),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user