Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b9b5dc88 | ||
|
|
5a52af66a9 | ||
|
|
722c88701b | ||
|
|
6ab2856f8d | ||
|
|
7a8b09123c | ||
|
|
da09639428 | ||
|
|
6a271026d1 | ||
|
|
6d9247960f | ||
|
|
c4e09cdd40 | ||
|
|
c61236b26a |
@@ -18,3 +18,4 @@ posthog-analytics
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
# 6.3.2 - 2025-07-31
|
||||
|
||||
- fix: Anthropic's tool calls are now handled properly
|
||||
|
||||
# 6.3.0 - 2025-07-22
|
||||
|
||||
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
|
||||
|
||||
# 6.2.1 - 2025-07-21
|
||||
|
||||
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
|
||||
|
||||
# 6.1.1 - 2025-07-16
|
||||
|
||||
- fix: correctly capture exceptions processed by Django from views or middleware
|
||||
|
||||
# 6.1.0 - 2025-07-10
|
||||
|
||||
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
|
||||
|
||||
# 6.0.4 - 2025-07-09
|
||||
|
||||
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
|
||||
|
||||
# 6.0.3 - 2025-07-07
|
||||
|
||||
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
|
||||
@@ -13,12 +37,14 @@
|
||||
# 6.0.0
|
||||
|
||||
This release contains a number of major breaking changes:
|
||||
|
||||
- feat: make distinct_id an optional parameter in posthog.capture and related functions
|
||||
- feat: make capture and related functions return `Optional[str]`, which is the UUID of the sent event, if it was sent
|
||||
- fix: remove `identify` (prefer `posthog.set()`), and `page` and `screen` (prefer `posthog.capture()`)
|
||||
- fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django `Integration`.
|
||||
|
||||
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
|
||||
|
||||
```python
|
||||
# Old calling convention
|
||||
posthog.capture("user123", "button_clicked", {"button_id": "123"})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/docs
|
||||
#/ Description: Generate documentation for the PostHog Python SDK
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
exec python3 "$(dirname "$0")/docs_scripts/generate_json_schemas.py" "$@"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Constants for PostHog Python SDK documentation generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Union
|
||||
|
||||
# Types that are built-in to Python and don't need to be documented
|
||||
NO_DOCS_TYPES = [
|
||||
"Client",
|
||||
"any",
|
||||
"int",
|
||||
"float",
|
||||
"bool",
|
||||
"dict",
|
||||
"list",
|
||||
"str",
|
||||
"tuple",
|
||||
"set",
|
||||
"frozenset",
|
||||
"bytes",
|
||||
"bytearray",
|
||||
"memoryview",
|
||||
"range",
|
||||
"slice",
|
||||
"complex",
|
||||
"Union",
|
||||
"Optional",
|
||||
"Any",
|
||||
"Callable",
|
||||
"Type",
|
||||
"TypeVar",
|
||||
"Generic",
|
||||
"Literal",
|
||||
"ClassVar",
|
||||
"Final",
|
||||
"Annotated",
|
||||
"NotRequired",
|
||||
"Required",
|
||||
"None",
|
||||
"NoneType",
|
||||
"object",
|
||||
"Unpack",
|
||||
"BaseException",
|
||||
"Exception",
|
||||
]
|
||||
|
||||
# Documentation generation metadata
|
||||
DOCUMENTATION_METADATA = {
|
||||
"hogRef": "0.1",
|
||||
"slugPrefix": "posthog-python",
|
||||
"specUrl": "https://github.com/PostHog/posthog-python",
|
||||
}
|
||||
|
||||
# Docstring parsing patterns for new format
|
||||
DOCSTRING_PATTERNS = {
|
||||
"examples_section": r"Examples:\s*\n(.*?)(?=\n\s*\n\s*Category:|\Z)",
|
||||
"args_section": r"Args:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Details:|\n\s*\n\s*Category:|\Z)",
|
||||
"details_section": r"Details:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Category:|\Z)",
|
||||
"category_section": r"Category:\s*\n\s*(.+?)\s*(?:\n|$)",
|
||||
"code_block": r"```(?:python)?\n(.*?)```",
|
||||
"param_description": r"^\s*{param_name}:\s*(.+?)(?=\n\s*\w+:|\Z)",
|
||||
"args_marker": r"\n\s*Args:\s*\n",
|
||||
"examples_marker": r"\n\s*Examples:\s*\n",
|
||||
"details_marker": r"\n\s*Details:\s*\n",
|
||||
"category_marker": r"\n\s*Category:\s*\n",
|
||||
}
|
||||
|
||||
# Output file configuration
|
||||
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
|
||||
"output_dir": ".",
|
||||
"filename": "posthog-python-references.json",
|
||||
"indent": 2,
|
||||
}
|
||||
|
||||
# Documentation structure defaults
|
||||
DOC_DEFAULTS = {
|
||||
"showDocs": True,
|
||||
"releaseTag": "public",
|
||||
"return_type_void": "None",
|
||||
"max_optional_params": 3,
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate comprehensive SDK documentation JSON from PostHog Python SDK.
|
||||
This script inspects the code and docstrings to create documentation in the specified format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import inspect
|
||||
import re
|
||||
from dataclasses import is_dataclass, fields
|
||||
from typing import get_origin, get_args, Union
|
||||
from textwrap import dedent
|
||||
from doc_constant import (
|
||||
NO_DOCS_TYPES,
|
||||
DOCUMENTATION_METADATA,
|
||||
DOCSTRING_PATTERNS,
|
||||
OUTPUT_CONFIG,
|
||||
DOC_DEFAULTS,
|
||||
)
|
||||
import os
|
||||
|
||||
|
||||
def extract_examples_from_docstring(docstring: str) -> list:
|
||||
"""Extract code examples from docstring."""
|
||||
if not docstring:
|
||||
return []
|
||||
|
||||
examples = []
|
||||
|
||||
# Look for Examples section in the new format
|
||||
examples_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["examples_section"], docstring, re.DOTALL
|
||||
)
|
||||
if examples_section_match:
|
||||
examples_content = examples_section_match.group(1).strip()
|
||||
# Extract code blocks from the Examples section
|
||||
code_blocks = re.findall(
|
||||
DOCSTRING_PATTERNS["code_block"], examples_content, re.DOTALL
|
||||
)
|
||||
for i, code_block in enumerate(code_blocks):
|
||||
# Remove common leading whitespace while preserving relative indentation
|
||||
code = dedent(code_block).strip()
|
||||
|
||||
# Extract name from first comment line if present
|
||||
lines = code.split("\n")
|
||||
name = f"Example {i + 1}" # Default fallback
|
||||
|
||||
if lines and lines[0].strip().startswith("#"):
|
||||
# Extract name from first comment, keep the comment in the code
|
||||
comment_text = lines[0].strip()[1:].strip()
|
||||
if comment_text:
|
||||
name = comment_text
|
||||
|
||||
examples.append({"id": f"example_{i + 1}", "name": name, "code": code})
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def extract_details_from_docstring(docstring: str) -> str:
|
||||
"""Extract details section from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Look for Details section
|
||||
details_match = re.search(
|
||||
DOCSTRING_PATTERNS["details_section"], docstring, re.DOTALL
|
||||
)
|
||||
if details_match:
|
||||
details_content = details_match.group(1).strip()
|
||||
# Clean up formatting
|
||||
return details_content.replace("\n", " ")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_docstring_tags(docstring: str) -> dict:
|
||||
"""Parse tags from docstring Category section."""
|
||||
if not docstring:
|
||||
return {}
|
||||
|
||||
tags = {}
|
||||
|
||||
# Extract Category section
|
||||
category_match = re.search(DOCSTRING_PATTERNS["category_section"], docstring)
|
||||
if category_match:
|
||||
category_value = category_match.group(1).strip()
|
||||
tags["category"] = category_value
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def extract_description_from_docstring(docstring: str) -> str:
|
||||
"""Extract main description from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Clean up the docstring
|
||||
cleaned = dedent(docstring).strip()
|
||||
|
||||
# Find the end of the description by looking for first section marker
|
||||
# Check for Args:, Examples:, Details:, or Category: sections
|
||||
section_patterns = [
|
||||
DOCSTRING_PATTERNS["args_marker"],
|
||||
DOCSTRING_PATTERNS["examples_marker"],
|
||||
DOCSTRING_PATTERNS["details_marker"],
|
||||
DOCSTRING_PATTERNS["category_marker"],
|
||||
]
|
||||
|
||||
end_pos = len(cleaned)
|
||||
for pattern in section_patterns:
|
||||
match = re.search(pattern, cleaned)
|
||||
if match:
|
||||
end_pos = min(end_pos, match.start())
|
||||
|
||||
# Extract description up to the first section marker
|
||||
description = cleaned[:end_pos].strip()
|
||||
|
||||
# Remove one level of \n since it will be rendered as markdown
|
||||
# and \n will be padded in later steps
|
||||
description = description.replace("\n", " ")
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def get_type_name(type_annotation) -> str:
|
||||
"""Convert type annotation to string name."""
|
||||
if type_annotation is None or type_annotation is type(None):
|
||||
return "any"
|
||||
|
||||
# Handle typing constructs
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is not None:
|
||||
# Handle Union types (including Optional)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type] - get the non-None type
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
return f"Optional[{get_type_name(non_none_type)}]"
|
||||
else:
|
||||
# Regular Union - list all types
|
||||
type_names = [get_type_name(arg) for arg in args]
|
||||
return f"Union[{', '.join(type_names)}]"
|
||||
|
||||
# Handle other generic types (List, Dict, etc.)
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
args = get_args(type_annotation)
|
||||
if args:
|
||||
arg_names = [get_type_name(arg) for arg in args]
|
||||
return f"{origin_name}[{', '.join(arg_names)}]"
|
||||
else:
|
||||
return origin_name
|
||||
|
||||
# Handle regular types
|
||||
elif hasattr(type_annotation, "__name__"):
|
||||
return type_annotation.__name__
|
||||
else:
|
||||
return str(type_annotation)
|
||||
|
||||
|
||||
def analyze_parameter(param: inspect.Parameter, docstring: str = "") -> dict:
|
||||
"""Analyze a function parameter and return its documentation."""
|
||||
# Determine if parameter is optional (has default value)
|
||||
is_optional = param.default == inspect.Parameter.empty
|
||||
|
||||
# Get the type annotation
|
||||
type_annotation = param.annotation
|
||||
param_type = "any"
|
||||
|
||||
if type_annotation != inspect.Parameter.empty:
|
||||
# Handle Union/Optional types first
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type]
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
param_type = get_type_name(non_none_type)
|
||||
is_optional = True
|
||||
else:
|
||||
# Other Union types, use first type
|
||||
param_type = get_type_name(args[0]) if args else "any"
|
||||
else:
|
||||
param_type = get_type_name(type_annotation)
|
||||
elif param.default != inspect.Parameter.empty:
|
||||
# No type annotation, but has default value - infer type from default
|
||||
param_type = get_type_name(type(param.default))
|
||||
|
||||
# Extract parameter description from Args section
|
||||
param_description = f"Parameter: {param.name}"
|
||||
if docstring:
|
||||
# Look for Args section and extract description for this parameter
|
||||
args_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["args_section"], docstring, re.DOTALL
|
||||
)
|
||||
if args_section_match:
|
||||
args_content = args_section_match.group(1)
|
||||
# Look for the parameter description
|
||||
param_pattern = DOCSTRING_PATTERNS["param_description"].format(
|
||||
param_name=re.escape(param.name)
|
||||
)
|
||||
param_match = re.search(
|
||||
param_pattern, args_content, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if param_match:
|
||||
param_description = param_match.group(1).strip().replace("\n", " ")
|
||||
|
||||
param_info = {
|
||||
"name": param.name,
|
||||
"description": param_description,
|
||||
"isOptional": is_optional,
|
||||
"type": param_type,
|
||||
}
|
||||
|
||||
return param_info
|
||||
|
||||
|
||||
def analyze_function(func, name: str) -> dict:
|
||||
"""Analyze a function and return its documentation."""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
docstring = inspect.getdoc(func) or ""
|
||||
|
||||
# Skip functions with empty docstrings
|
||||
if not docstring.strip():
|
||||
return {}
|
||||
|
||||
# Extract parameters (excluding 'self')
|
||||
params = []
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name != "self":
|
||||
params.append(analyze_parameter(param, docstring))
|
||||
|
||||
# Special handling for constructor
|
||||
display_name = name
|
||||
if name == "__init__":
|
||||
display_name = func.__qualname__.split(".")[0]
|
||||
|
||||
# Parse tags from docstring
|
||||
tags = parse_docstring_tags(docstring)
|
||||
|
||||
category = tags.get("category", None)
|
||||
|
||||
# Extract description
|
||||
description = extract_description_from_docstring(docstring)
|
||||
|
||||
# Skip if no meaningful description
|
||||
if not description.strip():
|
||||
return {}
|
||||
|
||||
# Extract details section (only if it exists)
|
||||
details = extract_details_from_docstring(docstring)
|
||||
|
||||
# Get examples from docstring, do not generate fallback examples
|
||||
examples = extract_examples_from_docstring(docstring)
|
||||
# If no examples, do not include the examples key or set to empty list
|
||||
|
||||
result = {
|
||||
"id": name,
|
||||
"title": display_name,
|
||||
"description": description,
|
||||
"details": details,
|
||||
"category": category,
|
||||
"params": params,
|
||||
"showDocs": DOC_DEFAULTS["showDocs"],
|
||||
"releaseTag": DOC_DEFAULTS["releaseTag"],
|
||||
"returnType": {
|
||||
"id": "return_type",
|
||||
"name": get_type_name(sig.return_annotation)
|
||||
if sig.return_annotation != inspect.Signature.empty
|
||||
else DOC_DEFAULTS["return_type_void"],
|
||||
},
|
||||
}
|
||||
if examples:
|
||||
result["examples"] = examples
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error analyzing function {name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def analyze_class(cls) -> dict:
|
||||
"""Analyze a class and return its documentation."""
|
||||
class_doc = inspect.getdoc(cls) or f"Class: {cls.__name__}"
|
||||
|
||||
# Get all public methods and constructor
|
||||
functions = []
|
||||
for method_name in dir(cls):
|
||||
if method_name.startswith("_") and method_name != "__init__":
|
||||
continue
|
||||
|
||||
method = getattr(cls, method_name)
|
||||
if callable(method):
|
||||
func_info = analyze_function(method, method_name)
|
||||
if func_info: # Only add if not None (empty docstring check)
|
||||
functions.append(func_info)
|
||||
|
||||
return {
|
||||
"id": cls.__name__,
|
||||
"title": cls.__name__,
|
||||
"description": extract_description_from_docstring(class_doc),
|
||||
"functions": functions,
|
||||
}
|
||||
|
||||
|
||||
def analyze_type(cls) -> dict:
|
||||
"""Analyze a type/dataclass and return its documentation."""
|
||||
type_info = {
|
||||
"id": cls.__name__,
|
||||
"name": cls.__name__,
|
||||
"path": f"{cls.__module__}.{cls.__name__}",
|
||||
"properties": [],
|
||||
"example": "",
|
||||
}
|
||||
|
||||
if is_dataclass(cls):
|
||||
# Handle dataclass
|
||||
for field in fields(cls):
|
||||
prop = {
|
||||
"name": field.name,
|
||||
"type": get_type_name(field.type),
|
||||
"description": f"Field: {field.name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
elif hasattr(cls, "__annotations__"):
|
||||
# Handle TypedDict or annotated class
|
||||
for field_name, field_type in cls.__annotations__.items():
|
||||
prop = {
|
||||
"name": field_name,
|
||||
"type": get_type_name(field_type),
|
||||
"description": f"Field: {field_name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
|
||||
return type_info
|
||||
|
||||
|
||||
def generate_sdk_documentation():
|
||||
"""Generate complete SDK documentation in the requested format."""
|
||||
|
||||
# Import PostHog components
|
||||
import posthog
|
||||
from posthog.client import Client
|
||||
import posthog.types as types_module
|
||||
import posthog.args as args_module
|
||||
from posthog.version import VERSION
|
||||
|
||||
# Main SDK info
|
||||
sdk_info = {
|
||||
"version": VERSION,
|
||||
"id": "posthog-python",
|
||||
"title": "PostHog Python SDK",
|
||||
"description": "Integrate PostHog into any python application.",
|
||||
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
|
||||
"specUrl": DOCUMENTATION_METADATA["specUrl"],
|
||||
}
|
||||
|
||||
# Collect types
|
||||
types_list = []
|
||||
|
||||
# Types from posthog.types
|
||||
for name in dir(types_module):
|
||||
obj = getattr(types_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Types from posthog.args
|
||||
for name in dir(args_module):
|
||||
obj = getattr(args_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Collect classes
|
||||
classes_list = []
|
||||
|
||||
# Main PostHog class (renamed from Client)
|
||||
client_class = analyze_class(Client)
|
||||
client_class["id"] = "PostHog"
|
||||
client_class["title"] = "PostHog"
|
||||
classes_list.append(client_class)
|
||||
|
||||
# Global module functions (functions callable as posthog.function_name)
|
||||
global_functions = []
|
||||
for func_name in dir(posthog):
|
||||
# Skip private functions and non-callables
|
||||
if func_name.startswith("_") or not callable(getattr(posthog, func_name)):
|
||||
continue
|
||||
|
||||
func = getattr(posthog, func_name)
|
||||
# Only include functions actually defined in the posthog module (not imported)
|
||||
# and exclude class references
|
||||
if (
|
||||
func_name not in ["Client", "Posthog"]
|
||||
and hasattr(func, "__module__")
|
||||
and func.__module__ == "posthog"
|
||||
):
|
||||
try:
|
||||
func_info = analyze_function(func, func_name)
|
||||
if func_info: # Only add if not None (has proper docstring)
|
||||
global_functions.append(func_info)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Add global functions as a "class"
|
||||
if global_functions:
|
||||
classes_list.append(
|
||||
{
|
||||
"id": "PostHogModule",
|
||||
"title": "PostHog Module Functions",
|
||||
"description": "Global functions available in the PostHog module",
|
||||
"functions": global_functions,
|
||||
}
|
||||
)
|
||||
|
||||
# Create the final structure
|
||||
result = {
|
||||
"id": "posthog-python",
|
||||
"hogRef": DOCUMENTATION_METADATA["hogRef"],
|
||||
"info": sdk_info,
|
||||
"noDocsTypes": NO_DOCS_TYPES,
|
||||
"types": types_list,
|
||||
"classes": classes_list,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Generating PostHog Python SDK documentation...")
|
||||
|
||||
try:
|
||||
documentation = generate_sdk_documentation()
|
||||
|
||||
# Write to file
|
||||
output_file = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
|
||||
)
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
|
||||
|
||||
print(f"✓ Generated {output_file}")
|
||||
|
||||
# Print summary
|
||||
types_count = len(documentation["types"])
|
||||
classes_count = len(documentation["classes"])
|
||||
|
||||
total_functions = sum(len(cls["functions"]) for cls in documentation["classes"])
|
||||
|
||||
print("📊 Documentation Summary:")
|
||||
print(f" • {types_count} types documented")
|
||||
print(f" • {classes_count} classes documented")
|
||||
print(f" • {total_functions} functions documented")
|
||||
|
||||
no_docs = documentation["noDocsTypes"]
|
||||
if no_docs:
|
||||
print(
|
||||
f" • {len(no_docs)} types without documentation: {', '.join(no_docs[:5])}{'...' if len(no_docs) > 5 else ''}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error generating documentation: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
+351
-114
@@ -20,22 +20,105 @@ __version__ = VERSION
|
||||
|
||||
|
||||
def new_context(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import new_context, tag, capture
|
||||
with new_context():
|
||||
tag("request_id", "123")
|
||||
capture("event_name", properties={"property": "value"})
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
|
||||
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Decorator that creates a new context for the function.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import scoped, tag, capture
|
||||
@scoped()
|
||||
def process_payment(payment_id):
|
||||
tag("payment_id", payment_id)
|
||||
capture("payment_started")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_scoped(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
|
||||
|
||||
def set_context_session(session_id: str):
|
||||
"""
|
||||
Set the session ID for the current context.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import set_context_session
|
||||
set_context_session("session_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_set_context_session(session_id)
|
||||
|
||||
|
||||
def identify_context(distinct_id: str):
|
||||
"""
|
||||
Identify the current context with a distinct ID.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import identify_context
|
||||
identify_context("user_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
return inner_identify_context(distinct_id)
|
||||
|
||||
|
||||
def tag(name: str, value: Any):
|
||||
"""
|
||||
Add a tag to the current context.
|
||||
|
||||
Args:
|
||||
name: The tag key
|
||||
value: The tag value
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import tag
|
||||
tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_tag(name, value)
|
||||
|
||||
|
||||
@@ -60,6 +143,9 @@ log_captured_exceptions = False # type: bool
|
||||
project_root = None # type: Optional[str]
|
||||
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
|
||||
privacy_mode = False # type: bool
|
||||
# Whether to enable feature flag polling for local evaluation by default. Defaults to True.
|
||||
# We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation.
|
||||
enable_local_evaluation = True # type: bool
|
||||
|
||||
default_client = None # type: Optional[Client]
|
||||
|
||||
@@ -70,40 +156,62 @@ default_client = None # type: Optional[Client]
|
||||
# versions, without a breaking change, to get back the type information in function signatures
|
||||
def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
|
||||
"""
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
|
||||
Capture anything a user does within your system.
|
||||
|
||||
A `capture` call requires
|
||||
- `event name` to specify the event
|
||||
- We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on.
|
||||
Args:
|
||||
event: The event name to specify the event
|
||||
**kwargs: Optional arguments including:
|
||||
distinct_id: Unique identifier for the user
|
||||
properties: Dict of event properties
|
||||
timestamp: When the event occurred
|
||||
groups: Dict of group types and IDs
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
|
||||
Details:
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
|
||||
|
||||
For example:
|
||||
```python
|
||||
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
|
||||
with posthog.new_context():
|
||||
# Associate this context with some user, by distinct_id
|
||||
posthog.identify_context('some user')
|
||||
Examples:
|
||||
```python
|
||||
# Context and capture usage
|
||||
from posthog import new_context, identify_context, tag_context, capture
|
||||
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
|
||||
with new_context():
|
||||
# Associate this context with some user, by distinct_id
|
||||
identify_context('some user')
|
||||
|
||||
# Capture an event, associated with the context-level distinct ID ('some user')
|
||||
posthog.capture('movie started')
|
||||
# Capture an event, associated with the context-level distinct ID ('some user')
|
||||
capture('movie started')
|
||||
|
||||
# Capture an event associated with some other user (overriding the context-level distinct ID)
|
||||
posthog.capture('movie joined', distinct_id='some-other-user')
|
||||
# Capture an event associated with some other user (overriding the context-level distinct ID)
|
||||
capture('movie joined', distinct_id='some-other-user')
|
||||
|
||||
# Capture an event with some properties
|
||||
posthog.capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
|
||||
# Capture an event with some properties
|
||||
capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
|
||||
|
||||
# Capture an event with some properties
|
||||
posthog.capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
|
||||
# Capture an event with some associated group
|
||||
posthog.capture('purchase', groups={'company': 'id:5'})
|
||||
# Capture an event with some properties
|
||||
capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
|
||||
# Capture an event with some associated group
|
||||
capture('purchase', groups={'company': 'id:5'})
|
||||
|
||||
# Adding a tag to the current context will cause it to appear on all subsequent events
|
||||
posthog.tag_context('some-tag', 'some-value')
|
||||
# Adding a tag to the current context will cause it to appear on all subsequent events
|
||||
tag_context('some-tag', 'some-value')
|
||||
|
||||
posthog.capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
|
||||
```
|
||||
capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
|
||||
```
|
||||
```python
|
||||
# Set event properties
|
||||
from posthog import capture
|
||||
capture(
|
||||
"user_signed_up",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
"login_type": "email",
|
||||
"is_free_trial": "true"
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
|
||||
return _proxy("capture", event, **kwargs)
|
||||
@@ -112,21 +220,25 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
|
||||
def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a user record.
|
||||
This will overwrite previous people property values. Generally operates similar to `capture`, with
|
||||
distinct_id being an optional argument, defaulting to the current context's distinct ID.
|
||||
|
||||
If there is no context-level distinct ID, and no override distinct_id is passed, this function
|
||||
will do nothing.
|
||||
Details:
|
||||
This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
|
||||
|
||||
Context tags are folded into $set properties, so tagging the current context and then calling `set` will
|
||||
cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.set(distinct_id='distinct id', properties={
|
||||
'current_browser': 'Chrome',
|
||||
})
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Set person properties
|
||||
from posthog import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy("set", **kwargs)
|
||||
@@ -135,10 +247,26 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a user record, only if they do not yet exist.
|
||||
This will not overwrite previous people property values, unlike `set`.
|
||||
|
||||
Otherwise, operates in an identical manner to `set`.
|
||||
```
|
||||
Details:
|
||||
This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Set property once
|
||||
from posthog import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
return _proxy("set_once", **kwargs)
|
||||
|
||||
@@ -153,18 +281,27 @@ def group_identify(
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
Set properties on a group
|
||||
Set properties on a group.
|
||||
|
||||
A `group_identify` call requires
|
||||
- `group_type` type of your group
|
||||
- `group_key` unique identifier of the group
|
||||
Args:
|
||||
group_type: Type of your group
|
||||
group_key: Unique identifier of the group
|
||||
properties: Properties to set on the group
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.group_identify('company', 5, {
|
||||
'employees': 11,
|
||||
})
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Group identify
|
||||
from posthog import group_identify
|
||||
group_identify('company', 'company_id_in_your_db', {
|
||||
'name': 'Awesome Inc.',
|
||||
'employees': 11
|
||||
})
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy(
|
||||
@@ -187,19 +324,26 @@ def alias(
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call.
|
||||
This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or
|
||||
"What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after
|
||||
they e.g. register, login, or perform some other identifying action.
|
||||
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
An `alias` call requires
|
||||
- `previous distinct id` the unique ID of the user before
|
||||
- `distinct id` the current unique id
|
||||
Args:
|
||||
previous_id: The unique ID of the user before
|
||||
distinct_id: The current unique id
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
Details:
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Alias user
|
||||
from posthog import alias
|
||||
alias(previous_id='distinct_id', distinct_id='alias_id')
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy(
|
||||
@@ -217,26 +361,25 @@ def capture_exception(
|
||||
**kwargs: Unpack[OptionalCaptureArgs],
|
||||
):
|
||||
"""
|
||||
capture_exception allows you to capture exceptions that happen in your code.
|
||||
Capture exceptions that happen in your code.
|
||||
|
||||
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog.
|
||||
This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception,
|
||||
if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context
|
||||
boundary (e.g. by existing a `with posthog.new_context():` block already)
|
||||
Args:
|
||||
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
|
||||
|
||||
A `capture_exception` call does not require any fields, but we recommend passing an exception of some kind:
|
||||
- `exception` to specify the exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
|
||||
Details:
|
||||
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
|
||||
|
||||
If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised
|
||||
and the point at which it is captured (the "traceback").
|
||||
|
||||
If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace
|
||||
captured will be the full stack trace at the moment the exception was captured.
|
||||
|
||||
Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently,
|
||||
which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason.
|
||||
|
||||
`capture_exception` takes the same set of optional arguments as `capture`.
|
||||
Examples:
|
||||
```python
|
||||
# Capture exception
|
||||
from posthog import capture_exception
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
|
||||
return _proxy("capture_exception", exception=exception, **kwargs)
|
||||
@@ -256,15 +399,29 @@ def feature_enabled(
|
||||
"""
|
||||
Use feature flags to enable or disable features for users.
|
||||
|
||||
For example:
|
||||
```python
|
||||
if posthog.feature_enabled('beta feature', 'distinct id'):
|
||||
# do something
|
||||
if posthog.feature_enabled('groups feature', 'distinct id', groups={"organization": "5"}):
|
||||
# do something
|
||||
```
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
Details:
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Boolean feature flag
|
||||
from posthog import feature_enabled, get_feature_flag_payload
|
||||
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
|
||||
if is_my_flag_enabled:
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"feature_enabled",
|
||||
@@ -291,25 +448,30 @@ def get_feature_flag(
|
||||
) -> Optional[FeatureFlag]:
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
Example:
|
||||
```python
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'test-variant':
|
||||
# do test variant code
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'control':
|
||||
# do control code
|
||||
```
|
||||
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5",
|
||||
you would pass groups={"organization": "5"}.
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping from group type to group key
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties in format { group_type_name: { group_properties } }
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
`group_properties` take the format: { group_type_name: { group_properties } }
|
||||
Details:
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "PostHog", "employees": 11}}.
|
||||
|
||||
So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count,
|
||||
you'll send these as:
|
||||
|
||||
```python
|
||||
group_properties={"organization": {"name": "PostHog", "employees": 11}}
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Multivariate feature flag
|
||||
from posthog import get_feature_flag, get_feature_flag_payload
|
||||
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
|
||||
if enabled_variant == 'variant-key':
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag",
|
||||
@@ -334,12 +496,26 @@ def get_all_flags(
|
||||
) -> Optional[dict[str, FeatureFlag]]:
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
Example:
|
||||
```python
|
||||
flags = posthog.get_all_flags('distinct_id')
|
||||
```
|
||||
|
||||
flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
Args:
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# All flags for user
|
||||
from posthog import get_all_flags
|
||||
get_all_flags('distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_all_flags",
|
||||
@@ -417,32 +593,90 @@ def get_all_flags_and_payloads(
|
||||
|
||||
|
||||
def feature_flag_definitions():
|
||||
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
|
||||
"""
|
||||
Returns loaded feature flags.
|
||||
|
||||
Details:
|
||||
Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import feature_flag_definitions
|
||||
definitions = feature_flag_definitions()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def load_feature_flags():
|
||||
"""Load feature flag definitions from PostHog."""
|
||||
"""
|
||||
Load feature flag definitions from PostHog.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import load_feature_flags
|
||||
load_feature_flags()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("load_feature_flags")
|
||||
|
||||
|
||||
def flush():
|
||||
"""Tell the client to flush."""
|
||||
"""
|
||||
Tell the client to flush all queued events.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import flush
|
||||
flush()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
|
||||
|
||||
def join():
|
||||
"""Block program until the client clears the queue"""
|
||||
"""
|
||||
Block program until the client clears the queue. Used during program shutdown. You should use `shutdown()` directly in most cases.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import join
|
||||
join()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def shutdown():
|
||||
"""Flush all messages and cleanly shutdown the client"""
|
||||
"""
|
||||
Flush all messages and cleanly shutdown the client.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import shutdown
|
||||
shutdown()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def setup():
|
||||
def setup() -> Client:
|
||||
global default_client
|
||||
if not default_client:
|
||||
if not api_key:
|
||||
@@ -465,12 +699,15 @@ def setup():
|
||||
# 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,
|
||||
enable_local_evaluation=enable_local_evaluation,
|
||||
)
|
||||
|
||||
# always set incase user changes it
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
return default_client
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
|
||||
@@ -8,7 +8,7 @@ except ImportError:
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
@@ -17,6 +17,7 @@ from posthog.ai.utils import (
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class Anthropic(anthropic.Anthropic):
|
||||
@@ -26,14 +27,14 @@ class Anthropic(anthropic.Anthropic):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
posthog_client: PostHog client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
get_model_params,
|
||||
@@ -26,14 +27,14 @@ class AsyncAnthropic(anthropic.AsyncAnthropic):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
posthog_client: PostHog client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ except ImportError:
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from posthog.ai.anthropic.anthropic import WrappedMessages
|
||||
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class AnthropicBedrock(anthropic.AnthropicBedrock):
|
||||
@@ -17,9 +20,9 @@ class AnthropicBedrock(anthropic.AnthropicBedrock):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
@@ -30,9 +33,9 @@ class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
@@ -43,9 +46,9 @@ class AnthropicVertex(anthropic.AnthropicVertex):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
@@ -56,7 +59,7 @@ class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
@@ -10,6 +10,7 @@ except ImportError:
|
||||
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
|
||||
)
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
get_model_params,
|
||||
@@ -36,6 +37,8 @@ class Client:
|
||||
)
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
@@ -56,12 +59,14 @@ class Client:
|
||||
posthog_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self.models = Models(
|
||||
api_key=api_key,
|
||||
posthog_client=posthog_client,
|
||||
posthog_client=self._ph_client,
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_properties=posthog_properties,
|
||||
posthog_privacy_mode=posthog_privacy_mode,
|
||||
@@ -97,10 +102,10 @@ class Models:
|
||||
posthog_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
self._ph_client = posthog_client
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
# Store default PostHog settings
|
||||
self._default_distinct_id = posthog_distinct_id
|
||||
|
||||
@@ -5,6 +5,7 @@ except ImportError:
|
||||
"Please install LangChain to use this feature: 'pip install langchain'"
|
||||
)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -29,11 +30,12 @@ from langchain_core.messages import (
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
ToolCall,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog import default_client
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.client import Client
|
||||
|
||||
@@ -81,7 +83,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
The PostHog LLM observability callback handler for LangChain.
|
||||
"""
|
||||
|
||||
_client: Client
|
||||
_ph_client: Client
|
||||
"""PostHog client instance."""
|
||||
|
||||
_distinct_id: Optional[Union[str, int, UUID]]
|
||||
@@ -127,10 +129,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
privacy_mode: Whether to redact the input and output of the trace.
|
||||
groups: Optional additional PostHog groups to use for the trace.
|
||||
"""
|
||||
posthog_client = client or default_client
|
||||
if posthog_client is None:
|
||||
raise ValueError("PostHog client is required")
|
||||
self._client = posthog_client
|
||||
self._ph_client = client or setup()
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
@@ -481,7 +480,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_input_state": with_privacy_mode(
|
||||
self._client, self._privacy_mode, run.input
|
||||
self._ph_client, self._privacy_mode, run.input
|
||||
),
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_span_name": run.name,
|
||||
@@ -497,13 +496,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_properties["$ai_is_error"] = True
|
||||
elif outputs is not None:
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(
|
||||
self._client, self._privacy_mode, outputs
|
||||
self._ph_client, self._privacy_mode, outputs
|
||||
)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or run_id,
|
||||
event=event_name,
|
||||
properties=event_properties,
|
||||
@@ -550,14 +549,16 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"$ai_provider": run.provider,
|
||||
"$ai_model": run.model,
|
||||
"$ai_model_parameters": run.model_params,
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, run.input
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_base_url": run.base_url,
|
||||
}
|
||||
if run.tools:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client,
|
||||
self._ph_client,
|
||||
self._privacy_mode,
|
||||
run.tools,
|
||||
)
|
||||
@@ -589,7 +590,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
_extract_raw_esponse(generation) for generation in generation_result
|
||||
]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(
|
||||
self._client, self._privacy_mode, completions
|
||||
self._ph_client, self._privacy_mode, completions
|
||||
)
|
||||
|
||||
if self._properties:
|
||||
@@ -598,7 +599,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
@@ -630,12 +631,35 @@ def _extract_raw_esponse(last_response):
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
def _convert_lc_tool_calls_to_oai(
|
||||
tool_calls: list[ToolCall],
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"id": tool_call["id"],
|
||||
"function": {
|
||||
"name": tool_call["name"],
|
||||
"arguments": json.dumps(tool_call["args"]),
|
||||
},
|
||||
}
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
except KeyError:
|
||||
return tool_calls
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
|
||||
# assistant message
|
||||
if isinstance(message, HumanMessage):
|
||||
message_dict = {"role": "user", "content": message.content}
|
||||
elif isinstance(message, AIMessage):
|
||||
message_dict = {"role": "assistant", "content": message.content}
|
||||
if message.tool_calls:
|
||||
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
|
||||
message.tool_calls
|
||||
)
|
||||
elif isinstance(message, SystemMessage):
|
||||
message_dict = {"role": "system", "content": message.content}
|
||||
elif isinstance(message, ToolMessage):
|
||||
@@ -648,6 +672,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
if message.additional_kwargs:
|
||||
message_dict.update(message.additional_kwargs)
|
||||
|
||||
if "content" in message_dict and not message_dict["content"]:
|
||||
message_dict["content"] = ""
|
||||
|
||||
return message_dict
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from posthog.ai.utils import (
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class OpenAI(openai.OpenAI):
|
||||
@@ -24,16 +25,15 @@ class OpenAI(openai.OpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
try:
|
||||
import openai
|
||||
@@ -9,6 +9,7 @@ except ImportError:
|
||||
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
get_model_params,
|
||||
@@ -24,7 +25,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
@@ -33,7 +34,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
|
||||
@@ -15,7 +15,10 @@ from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
|
||||
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
|
||||
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
|
||||
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
|
||||
from typing import Optional
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class AzureOpenAI(openai.AzureOpenAI):
|
||||
@@ -25,7 +28,7 @@ class AzureOpenAI(openai.AzureOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
@@ -34,7 +37,7 @@ class AzureOpenAI(openai.AzureOpenAI):
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
@@ -63,7 +66,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
@@ -72,7 +75,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
|
||||
+22
-3
@@ -118,7 +118,12 @@ def format_response(response, provider: str):
|
||||
def format_response_anthropic(response):
|
||||
output = []
|
||||
for choice in response.content:
|
||||
if choice.text:
|
||||
if (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "text"
|
||||
and hasattr(choice, "text")
|
||||
and choice.text
|
||||
):
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -225,8 +230,21 @@ def format_response_gemini(response):
|
||||
|
||||
def format_tool_calls(response, provider: str):
|
||||
if provider == "anthropic":
|
||||
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
|
||||
return response.tools
|
||||
if hasattr(response, "content") and response.content:
|
||||
tool_calls = []
|
||||
|
||||
for content_item in response.content:
|
||||
if hasattr(content_item, "type") and content_item.type == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"type": content_item.type,
|
||||
"id": content_item.id,
|
||||
"name": content_item.name,
|
||||
"input": content_item.input,
|
||||
}
|
||||
)
|
||||
|
||||
return tool_calls if tool_calls else None
|
||||
elif provider == "openai":
|
||||
# Handle both Chat Completions and Responses API
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
@@ -378,6 +396,7 @@ def call_llm_and_track_usage(
|
||||
}
|
||||
|
||||
tool_calls = format_tool_calls(response, provider)
|
||||
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, tool_calls
|
||||
|
||||
+6
-3
@@ -5,6 +5,8 @@ from datetime import datetime
|
||||
import numbers
|
||||
from uuid import UUID
|
||||
|
||||
from posthog.types import SendFeatureFlagsOptions
|
||||
|
||||
ID_TYPES = Union[numbers.Number, str, UUID, int]
|
||||
|
||||
|
||||
@@ -22,7 +24,8 @@ class OptionalCaptureArgs(TypedDict):
|
||||
error ID if you capture an exception).
|
||||
groups: Group identifiers to associate with this event (format: {group_type: group_key})
|
||||
send_feature_flags: Whether to include currently active feature flags in the event properties.
|
||||
Defaults to False
|
||||
Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration.
|
||||
Defaults to False.
|
||||
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
|
||||
"""
|
||||
|
||||
@@ -32,8 +35,8 @@ class OptionalCaptureArgs(TypedDict):
|
||||
uuid: NotRequired[Optional[str]]
|
||||
groups: NotRequired[Optional[Dict[str, str]]]
|
||||
send_feature_flags: NotRequired[
|
||||
Optional[bool]
|
||||
] # Optional so we can tell if the user is intentionally overriding a client setting or not
|
||||
Optional[Union[bool, SendFeatureFlagsOptions]]
|
||||
] # Updated to support both boolean and options object
|
||||
disable_geoip: NotRequired[
|
||||
Optional[bool]
|
||||
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
|
||||
|
||||
+511
-48
@@ -97,29 +97,20 @@ def add_context_tags(properties):
|
||||
|
||||
|
||||
class Client(object):
|
||||
"""Create a new PostHog client.
|
||||
"""
|
||||
This is the SDK reference for the PostHog Python SDK.
|
||||
You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python).
|
||||
You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django)
|
||||
guides to integrate PostHog into your project.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
>>> client = Client("your-api-key")
|
||||
|
||||
With memory-based feature flag fallback cache:
|
||||
>>> client = Client(
|
||||
... "your-api-key",
|
||||
... flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
|
||||
... )
|
||||
|
||||
With Redis fallback cache for high-scale applications:
|
||||
>>> client = Client(
|
||||
... "your-api-key",
|
||||
... flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
|
||||
... )
|
||||
|
||||
With Redis authentication:
|
||||
>>> client = Client(
|
||||
... "your-api-key",
|
||||
... flag_fallback_cache_url="redis://username:password@localhost:6379/0/?ttl=300"
|
||||
... )
|
||||
```python
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('<ph_project_api_key>', host='<ph_client_api_host>')
|
||||
posthog.debug = True
|
||||
if settings.TEST:
|
||||
posthog.disabled = True
|
||||
```
|
||||
"""
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
@@ -152,7 +143,26 @@ class Client(object):
|
||||
privacy_mode=False,
|
||||
before_send=None,
|
||||
flag_fallback_cache_url=None,
|
||||
enable_local_evaluation=True,
|
||||
):
|
||||
"""
|
||||
Initialize a new PostHog client instance.
|
||||
|
||||
Args:
|
||||
project_api_key: The project API key.
|
||||
host: The host to use for the client.
|
||||
debug: Whether to enable debug mode.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import Posthog
|
||||
|
||||
posthog = Posthog('<ph_project_api_key>', host='<ph_app_host>')
|
||||
```
|
||||
|
||||
Category:
|
||||
Initialization
|
||||
"""
|
||||
self.queue = queue.Queue(max_queue_size)
|
||||
|
||||
# api_key: This should be the Team API Key (token), public
|
||||
@@ -187,6 +197,7 @@ class Client(object):
|
||||
self.log_captured_exceptions = log_captured_exceptions
|
||||
self.exception_capture = None
|
||||
self.privacy_mode = privacy_mode
|
||||
self.enable_local_evaluation = enable_local_evaluation
|
||||
|
||||
if project_root is None:
|
||||
try:
|
||||
@@ -250,6 +261,23 @@ class Client(object):
|
||||
consumer.start()
|
||||
|
||||
def new_context(self, fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Create a new context for managing shared state. Learn more about [contexts](/docs/libraries/python#contexts).
|
||||
|
||||
Args:
|
||||
fresh: Whether to create a fresh context that doesn't inherit from parent.
|
||||
capture_exceptions: Whether to automatically capture exceptions in this context.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
with posthog.new_context():
|
||||
identify_context('<distinct_id>')
|
||||
posthog.capture('event_name')
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return new_context(
|
||||
fresh=fresh, capture_exceptions=capture_exceptions, client=self
|
||||
)
|
||||
@@ -285,7 +313,17 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
) -> dict[str, Union[bool, str]]:
|
||||
"""
|
||||
Get feature flag variants for a distinct_id by calling decide.
|
||||
Get feature flag variants for a user by calling decide.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
resp_data = self.get_flags_decision(
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
@@ -301,7 +339,22 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Get feature flag payloads for a distinct_id by calling decide.
|
||||
Get feature flag payloads for a user by calling decide.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
payloads = posthog.get_feature_payloads('<distinct_id>')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
resp_data = self.get_flags_decision(
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
@@ -317,7 +370,22 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
) -> FlagsAndPayloads:
|
||||
"""
|
||||
Get feature flags and payloads for a distinct_id by calling decide.
|
||||
Get feature flags and payloads for a user by calling decide.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
result = posthog.get_feature_flags_and_payloads('<distinct_id>')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
resp = self.get_flags_decision(
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
@@ -333,7 +401,22 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
) -> FlagsResponse:
|
||||
"""
|
||||
Get feature flags decision, using either flags() or decide() API based on rollout.
|
||||
Get feature flags decision.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
decision = posthog.get_flags_decision('user123')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
|
||||
if distinct_id is None:
|
||||
@@ -365,6 +448,52 @@ class Client(object):
|
||||
def capture(
|
||||
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)
|
||||
|
||||
Args:
|
||||
event: The event name to capture.
|
||||
distinct_id: The distinct ID of the user.
|
||||
properties: A dictionary of properties to include with the event.
|
||||
timestamp: The timestamp of the event.
|
||||
uuid: A unique identifier for the event.
|
||||
groups: A dictionary of group information.
|
||||
send_feature_flags: Whether to send feature flags with the event.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Anonymous event
|
||||
posthog.capture('some-anon-event')
|
||||
```
|
||||
```python
|
||||
# Context usage
|
||||
from posthog import identify_context, new_context
|
||||
with new_context():
|
||||
identify_context('distinct_id_of_the_user')
|
||||
posthog.capture('user_signed_up')
|
||||
posthog.capture('user_logged_in')
|
||||
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
|
||||
```
|
||||
```python
|
||||
# Set event properties
|
||||
posthog.capture(
|
||||
"user_signed_up",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
"login_type": "email",
|
||||
"is_free_trial": "true"
|
||||
}
|
||||
)
|
||||
```
|
||||
```python
|
||||
# Page view event
|
||||
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
|
||||
```
|
||||
|
||||
Category:
|
||||
Capture
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
timestamp = kwargs.get("timestamp", None)
|
||||
@@ -395,11 +524,31 @@ class Client(object):
|
||||
|
||||
extra_properties: dict[str, Any] = {}
|
||||
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
|
||||
if send_feature_flags:
|
||||
|
||||
# Parse and normalize send_feature_flags parameter
|
||||
flag_options = self._parse_send_feature_flags(send_feature_flags)
|
||||
|
||||
if flag_options["should_send"]:
|
||||
try:
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id, groups, disable_geoip=disable_geoip
|
||||
)
|
||||
if flag_options["only_evaluate_locally"] is True:
|
||||
# Only use local evaluation
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id,
|
||||
groups=(groups or {}),
|
||||
person_properties=flag_options["person_properties"],
|
||||
group_properties=flag_options["group_properties"],
|
||||
disable_geoip=disable_geoip,
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
else:
|
||||
# Default behavior - use remote evaluation
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id,
|
||||
groups,
|
||||
person_properties=flag_options["person_properties"],
|
||||
group_properties=flag_options["group_properties"],
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.exception(
|
||||
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
|
||||
@@ -430,7 +579,76 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def _parse_send_feature_flags(self, send_feature_flags) -> dict:
|
||||
"""
|
||||
Parse and normalize send_feature_flags parameter into a standard format.
|
||||
|
||||
Args:
|
||||
send_feature_flags: Either bool or SendFeatureFlagsOptions dict
|
||||
|
||||
Returns:
|
||||
dict: Normalized options with keys: should_send, only_evaluate_locally,
|
||||
person_properties, group_properties
|
||||
|
||||
Raises:
|
||||
TypeError: If send_feature_flags is not bool or dict
|
||||
"""
|
||||
if isinstance(send_feature_flags, dict):
|
||||
return {
|
||||
"should_send": True,
|
||||
"only_evaluate_locally": send_feature_flags.get(
|
||||
"only_evaluate_locally"
|
||||
),
|
||||
"person_properties": send_feature_flags.get("person_properties"),
|
||||
"group_properties": send_feature_flags.get("group_properties"),
|
||||
}
|
||||
elif isinstance(send_feature_flags, bool):
|
||||
return {
|
||||
"should_send": send_feature_flags,
|
||||
"only_evaluate_locally": None,
|
||||
"person_properties": None,
|
||||
"group_properties": None,
|
||||
}
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Invalid type for send_feature_flags: {type(send_feature_flags)}. "
|
||||
f"Expected bool or dict."
|
||||
)
|
||||
|
||||
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a person profile.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
properties: A dictionary of properties to set.
|
||||
timestamp: The timestamp of the event.
|
||||
uuid: A unique identifier for the event.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Set with distinct id
|
||||
posthog.capture(
|
||||
'event_name',
|
||||
distinct_id='user-distinct-id',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
```
|
||||
```python
|
||||
# Set using context
|
||||
from posthog import new_context, identify_context
|
||||
with new_context():
|
||||
identify_context('user-distinct-id')
|
||||
posthog.capture('event_name')
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
timestamp = kwargs.get("timestamp", None)
|
||||
@@ -457,6 +675,24 @@ class Client(object):
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a person profile only if they haven't been set before.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
properties: A dictionary of properties to set once.
|
||||
timestamp: The timestamp of the event.
|
||||
uuid: A unique identifier for the event.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
timestamp = kwargs.get("timestamp", None)
|
||||
@@ -485,18 +721,41 @@ class Client(object):
|
||||
self,
|
||||
group_type: str,
|
||||
group_key: str,
|
||||
properties=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
disable_geoip=None,
|
||||
distinct_id=None,
|
||||
):
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
timestamp: Optional[Union[datetime, str]] = None,
|
||||
uuid: Optional[str] = None,
|
||||
disable_geoip: Optional[bool] = None,
|
||||
distinct_id: Optional[ID_TYPES] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Identify a group and set its properties.
|
||||
|
||||
Args:
|
||||
group_type: The type of group (e.g., 'company', 'team').
|
||||
group_key: The unique identifier for the group.
|
||||
properties: A dictionary of properties to set on the group.
|
||||
timestamp: The timestamp of the event.
|
||||
uuid: A unique identifier for the event.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
distinct_id: The distinct ID of the user performing the action.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.group_identify('company', 'company_id_in_your_db', {
|
||||
'name': 'Awesome Inc.',
|
||||
'employees': 11
|
||||
})
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
properties = properties or {}
|
||||
|
||||
# group_identify is purposefully always personful
|
||||
distinct_id = get_identity_state(distinct_id)[0]
|
||||
|
||||
msg = {
|
||||
msg: Dict[str, Any] = {
|
||||
"event": "$groupidentify",
|
||||
"properties": {
|
||||
"$group_type": group_type,
|
||||
@@ -510,7 +769,7 @@ class Client(object):
|
||||
|
||||
# NOTE - group_identify doesn't generally use context properties - should it?
|
||||
if get_context_session_id():
|
||||
msg["properties"]["$session_id"] = get_context_session_id()
|
||||
msg["properties"]["$session_id"] = str(get_context_session_id())
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@@ -522,6 +781,24 @@ class Client(object):
|
||||
uuid=None,
|
||||
disable_geoip=None,
|
||||
):
|
||||
"""
|
||||
Create an alias between two distinct IDs.
|
||||
|
||||
Args:
|
||||
previous_id: The previous distinct ID.
|
||||
distinct_id: The new distinct ID to alias to.
|
||||
timestamp: The timestamp of the event.
|
||||
uuid: A unique identifier for the event.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
(distinct_id, personless) = get_identity_state(distinct_id)
|
||||
|
||||
if personless:
|
||||
@@ -539,7 +816,7 @@ class Client(object):
|
||||
}
|
||||
|
||||
if get_context_session_id():
|
||||
msg["properties"]["$session_id"] = get_context_session_id()
|
||||
msg["properties"]["$session_id"] = str(get_context_session_id())
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@@ -548,6 +825,28 @@ class Client(object):
|
||||
exception: Optional[ExceptionArg],
|
||||
**kwargs: Unpack[OptionalCaptureArgs],
|
||||
):
|
||||
"""
|
||||
Capture an exception for error tracking.
|
||||
|
||||
Args:
|
||||
exception: The exception to capture.
|
||||
distinct_id: The distinct ID of the user.
|
||||
properties: A dictionary of additional properties.
|
||||
send_feature_flags: Whether to send feature flags with the exception.
|
||||
disable_geoip: Whether to disable GeoIP for this event.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
try:
|
||||
# Some code that might fail
|
||||
pass
|
||||
except Exception as e:
|
||||
posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)
|
||||
```
|
||||
|
||||
Category:
|
||||
Error Tracking
|
||||
"""
|
||||
distinct_id = kwargs.get("distinct_id", None)
|
||||
properties = kwargs.get("properties", None)
|
||||
send_feature_flags = kwargs.get("send_feature_flags", False)
|
||||
@@ -703,7 +1002,15 @@ class Client(object):
|
||||
return None
|
||||
|
||||
def flush(self):
|
||||
"""Forces a flush from the internal queue to the server"""
|
||||
"""
|
||||
Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.capture('event_name')
|
||||
posthog.flush() # Ensures the event is sent immediately
|
||||
```
|
||||
"""
|
||||
queue = self.queue
|
||||
size = queue.qsize()
|
||||
queue.join()
|
||||
@@ -711,8 +1018,13 @@ class Client(object):
|
||||
self.log.debug("successfully flushed about %s items.", size)
|
||||
|
||||
def join(self):
|
||||
"""Ends the consumer thread once the queue is empty.
|
||||
Blocks execution until finished
|
||||
"""
|
||||
End the consumer thread once the queue is empty. Do not use directly, call `shutdown()` instead.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.join()
|
||||
```
|
||||
"""
|
||||
for consumer in self.consumers:
|
||||
consumer.pause()
|
||||
@@ -726,7 +1038,14 @@ class Client(object):
|
||||
self.poller.stop()
|
||||
|
||||
def shutdown(self):
|
||||
"""Flush all messages and cleanly shutdown the client"""
|
||||
"""
|
||||
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.shutdown()
|
||||
```
|
||||
"""
|
||||
self.flush()
|
||||
self.join()
|
||||
|
||||
@@ -799,6 +1118,17 @@ class Client(object):
|
||||
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
||||
|
||||
def load_feature_flags(self):
|
||||
"""
|
||||
Load feature flags for local evaluation.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.load_feature_flags()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
if not self.personal_api_key:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags."
|
||||
@@ -807,7 +1137,11 @@ class Client(object):
|
||||
return
|
||||
|
||||
self._load_feature_flags()
|
||||
if not (self.poller and self.poller.is_alive()):
|
||||
|
||||
# Only start the poller if local evaluation is enabled
|
||||
if self.enable_local_evaluation and not (
|
||||
self.poller and self.poller.is_alive()
|
||||
):
|
||||
self.poller = Poller(
|
||||
interval=timedelta(seconds=self.poll_interval),
|
||||
execute=self._load_feature_flags,
|
||||
@@ -876,6 +1210,31 @@ class Client(object):
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
"""
|
||||
Check if a feature flag is enabled for a user.
|
||||
|
||||
Args:
|
||||
key: The feature flag key.
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
send_feature_flag_events: Whether to send feature flag events.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
|
||||
if is_my_flag_enabled:
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
response = self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
@@ -1005,8 +1364,29 @@ class Client(object):
|
||||
"""
|
||||
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`.
|
||||
|
||||
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
|
||||
Examples:
|
||||
```python
|
||||
flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
|
||||
if flag_result and flag_result.get_value() == 'variant-key':
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = flag_result.payload
|
||||
```
|
||||
|
||||
Args:
|
||||
key: The feature flag key.
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
send_feature_flag_events: Whether to send feature flag events.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Returns:
|
||||
Optional[FeatureFlagResult]: The feature flag result or None if disabled/not found.
|
||||
"""
|
||||
return self._get_feature_flag_result(
|
||||
key,
|
||||
@@ -1032,11 +1412,29 @@ class Client(object):
|
||||
disable_geoip=None,
|
||||
) -> Optional[FlagValue]:
|
||||
"""
|
||||
Get a feature flag value for a key by evaluating locally or remotely
|
||||
depending on whether local evaluation is enabled and the flag can be
|
||||
locally evaluated.
|
||||
Get multivariate feature flag value for a user.
|
||||
|
||||
This also captures the $feature_flag_called event unless send_feature_flag_events is False.
|
||||
Args:
|
||||
key: The feature flag key.
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
send_feature_flag_events: Whether to send feature flag events.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')
|
||||
if enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
feature_flag_result = self.get_feature_flag_result(
|
||||
key,
|
||||
@@ -1101,6 +1499,33 @@ class Client(object):
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
"""
|
||||
Get the payload for a feature flag.
|
||||
|
||||
Args:
|
||||
key: The feature flag key.
|
||||
distinct_id: The distinct ID of the user.
|
||||
match_value: The specific flag value to get payload for.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
send_feature_flag_events: Whether to send feature flag events.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
|
||||
|
||||
if is_my_flag_enabled:
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
feature_flag_result = self._get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
@@ -1243,6 +1668,25 @@ class Client(object):
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None,
|
||||
) -> Optional[dict[str, Union[bool, str]]]:
|
||||
"""
|
||||
Get all feature flags for a user.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.get_all_flags('distinct_id_of_your_user')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
response = self.get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
@@ -1264,6 +1708,25 @@ class Client(object):
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None,
|
||||
) -> FlagsAndPayloads:
|
||||
"""
|
||||
Get all feature flags and their payloads for a user.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID of the user.
|
||||
groups: A dictionary of group information.
|
||||
person_properties: A dictionary of person properties.
|
||||
group_properties: A dictionary of group properties.
|
||||
only_evaluate_locally: Whether to only evaluate locally.
|
||||
disable_geoip: Whether to disable GeoIP for this request.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
posthog.get_all_flags_and_payloads('distinct_id_of_your_user')
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature Flags
|
||||
"""
|
||||
if self.disabled:
|
||||
return {"featureFlags": None, "featureFlagPayloads": None}
|
||||
|
||||
|
||||
+33
-3
@@ -71,7 +71,9 @@ def _get_current_context() -> Optional[ContextScope]:
|
||||
|
||||
@contextmanager
|
||||
def new_context(
|
||||
fresh=False, capture_exceptions=True, client: Optional["Client"] = None
|
||||
fresh: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
client: Optional["Client"] = None,
|
||||
):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
@@ -94,20 +96,25 @@ def new_context(
|
||||
the global one, in the case of `posthog.capture`)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Inherit parent context tags
|
||||
with posthog.new_context():
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
```
|
||||
```python
|
||||
# Start with fresh context (no inherited tags)
|
||||
with posthog.new_context(fresh=True):
|
||||
posthog.tag("request_id", "123")
|
||||
# Both this event and the exception will be tagged with the context tags
|
||||
posthog.capture("event_name", {"property": "value"})
|
||||
raise ValueError("Something went wrong")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
from posthog import capture_exception
|
||||
|
||||
@@ -138,7 +145,12 @@ def tag(key: str, value: Any) -> None:
|
||||
value: The tag value
|
||||
|
||||
Example:
|
||||
```python
|
||||
posthog.tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -152,6 +164,9 @@ def get_tags() -> Dict[str, Any]:
|
||||
|
||||
Returns:
|
||||
Dict of all tags in the current context
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -170,6 +185,9 @@ def identify_context(distinct_id: str) -> None:
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children.
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -184,6 +202,9 @@ def set_context_session(session_id: str) -> None:
|
||||
|
||||
Args:
|
||||
session_id: The session ID to associate with the current context and its children. See https://posthog.com/docs/data/sessions
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -196,6 +217,9 @@ def get_context_session_id() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
The session ID if set, None otherwise
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -209,6 +233,9 @@ def get_context_distinct_id() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
The distinct ID if set, None otherwise
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
@@ -219,7 +246,7 @@ def get_context_distinct_id() -> Optional[str]:
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
def scoped(fresh: bool = False, capture_exceptions: bool = True):
|
||||
"""
|
||||
Decorator that creates a new context for the function. Simply wraps
|
||||
the function in a with posthog.new_context(): block.
|
||||
@@ -239,6 +266,9 @@ def scoped(fresh=False, capture_exceptions=True):
|
||||
# If this raises an exception, it will be captured with tags
|
||||
# and then re-raised
|
||||
some_risky_function()
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
|
||||
@@ -109,6 +109,14 @@ def is_condition_match(
|
||||
property_type = prop.get("type")
|
||||
if property_type == "cohort":
|
||||
matches = match_cohort(prop, properties, cohort_properties)
|
||||
elif property_type == "flag":
|
||||
log.warning(
|
||||
"Flag dependency filters are not supported in local evaluation. "
|
||||
"Skipping condition for flag '%s' with dependency on flag '%s'",
|
||||
feature_flag.get("key", "unknown"),
|
||||
prop.get("key", "unknown"),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
matches = match_property(prop, properties)
|
||||
if not matches:
|
||||
@@ -317,6 +325,13 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
try:
|
||||
if prop.get("type") == "cohort":
|
||||
matches = match_cohort(prop, property_values, cohort_properties)
|
||||
elif prop.get("type") == "flag":
|
||||
log.warning(
|
||||
"Flag dependency filters are not supported in local evaluation. "
|
||||
"Skipping condition with dependency on flag '%s'",
|
||||
prop.get("key", "unknown"),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
matches = match_property(prop, property_values)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from posthog import contexts
|
||||
from posthog import contexts, capture_exception
|
||||
from posthog.client import Client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest, HttpResponse # noqa: F401
|
||||
@@ -16,7 +17,8 @@ class PosthogContextMiddleware:
|
||||
- Request Method as $request_method
|
||||
|
||||
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
|
||||
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
|
||||
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
|
||||
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
|
||||
|
||||
The middleware behaviour is customisable through 3 additional functions:
|
||||
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
|
||||
@@ -74,6 +76,13 @@ class PosthogContextMiddleware:
|
||||
else:
|
||||
self.capture_exceptions = True
|
||||
|
||||
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
|
||||
settings.POSTHOG_MW_CLIENT, Client
|
||||
):
|
||||
self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def extract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
tags = {}
|
||||
@@ -153,8 +162,20 @@ class PosthogContextMiddleware:
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions):
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in self.extract_tags(request).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return
|
||||
|
||||
if not self.capture_exceptions:
|
||||
return
|
||||
|
||||
if self.client:
|
||||
self.client.capture_exception(exception)
|
||||
else:
|
||||
capture_exception(exception)
|
||||
|
||||
@@ -88,6 +88,31 @@ def mock_anthropic_response_with_cached_tokens():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_response_with_tool_use():
|
||||
return Message(
|
||||
id="msg_123",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[
|
||||
{"type": "text", "text": "I'll help you with that."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "New York"},
|
||||
},
|
||||
],
|
||||
model="claude-3-opus-20240229",
|
||||
usage=Usage(
|
||||
input_tokens=20,
|
||||
output_tokens=10,
|
||||
),
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_completion(mock_client, mock_anthropic_response):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
|
||||
@@ -434,3 +459,49 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_tool_use_response(mock_client, mock_anthropic_response_with_tool_use):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create",
|
||||
return_value=mock_anthropic_response_with_tool_use,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "What's the weather like?"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response_with_tool_use
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-opus-20240229"
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "user", "content": "What's the weather like?"}
|
||||
]
|
||||
# Should only include text content, not tool_use content
|
||||
assert props["$ai_output_choices"] == [
|
||||
{"role": "assistant", "content": "I'll help you with that."}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
# Verify that tools are captured separately
|
||||
assert props["$ai_tools"] == [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "New York"},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1727,3 +1727,66 @@ def test_openai_reasoning_tokens(mock_client):
|
||||
assert call["properties"]["$ai_reasoning_tokens"] is not None
|
||||
assert call["properties"]["$ai_input_tokens"] is not None
|
||||
assert call["properties"]["$ai_output_tokens"] is not None
|
||||
|
||||
|
||||
def test_callback_handler_without_client():
|
||||
"""Test that CallbackHandler works properly when no PostHog client is passed."""
|
||||
with patch("posthog.ai.langchain.callbacks.setup") as mock_setup:
|
||||
mock_client = mock_setup.return_value
|
||||
|
||||
callbacks = CallbackHandler()
|
||||
|
||||
# Verify that setup() was called
|
||||
mock_setup.assert_called_once()
|
||||
|
||||
# Verify that the client was set to the result of setup()
|
||||
assert callbacks._ph_client == mock_client
|
||||
|
||||
# Test that the callback handler works with a simple chain
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
|
||||
chain = prompt | model
|
||||
|
||||
# This should work and call the mock client
|
||||
result = chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
assert result.content == "Bar"
|
||||
|
||||
# Verify that the mock client was used for capturing events
|
||||
assert mock_client.capture.call_count == 3
|
||||
|
||||
|
||||
def test_convert_message_to_dict_tool_calls():
|
||||
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
|
||||
from posthog.ai.langchain.callbacks import _convert_message_to_dict
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages.tool import ToolCall
|
||||
|
||||
# Create an AIMessage with tool calls
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id="call_123",
|
||||
name="get_weather",
|
||||
args={"city": "San Francisco", "units": "celsius"},
|
||||
)
|
||||
]
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll check the weather for you.", tool_calls=tool_calls
|
||||
)
|
||||
|
||||
# Convert to dict
|
||||
result = _convert_message_to_dict(ai_message)
|
||||
|
||||
# Verify the conversion
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == "I'll check the weather for you."
|
||||
assert result["tool_calls"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"id": "call_123",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "San Francisco", "units": "celsius"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
+344
-1
@@ -751,6 +751,186 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
|
||||
self, patch_flags
|
||||
):
|
||||
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation"""
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
# Set up local flags
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"key": "local-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [{"key": "region", "value": "US"}],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
send_options = {
|
||||
"only_evaluate_locally": True,
|
||||
"person_properties": {"region": "US"},
|
||||
}
|
||||
|
||||
msg_uuid = client.capture(
|
||||
"test event", distinct_id="distinct_id", send_feature_flags=send_options
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Verify flags() was not called (no remote evaluation)
|
||||
patch_flags.assert_not_called()
|
||||
|
||||
# Check the message includes the local flag
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(msg["properties"]["$feature/local-flag"], True)
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["local-flag"])
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_options_only_evaluate_locally_false(
|
||||
self, patch_flags
|
||||
):
|
||||
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation"""
|
||||
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}}
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
send_options = {
|
||||
"only_evaluate_locally": False,
|
||||
"person_properties": {"plan": "premium"},
|
||||
"group_properties": {"company": {"type": "enterprise"}},
|
||||
}
|
||||
|
||||
msg_uuid = client.capture(
|
||||
"test event",
|
||||
distinct_id="distinct_id",
|
||||
groups={"company": "acme"},
|
||||
send_feature_flags=send_options,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Verify flags() was called with the correct properties
|
||||
patch_flags.assert_called_once()
|
||||
call_args = patch_flags.call_args[1]
|
||||
self.assertEqual(call_args["person_properties"], {"plan": "premium"})
|
||||
self.assertEqual(
|
||||
call_args["group_properties"], {"company": {"type": "enterprise"}}
|
||||
)
|
||||
|
||||
# Check the message includes the remote flag
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value")
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_with_send_feature_flags_options_default_behavior(
|
||||
self, patch_flags
|
||||
):
|
||||
"""Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation"""
|
||||
patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}}
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
send_options = {
|
||||
"person_properties": {"subscription": "pro"},
|
||||
}
|
||||
|
||||
msg_uuid = client.capture(
|
||||
"test event", distinct_id="distinct_id", send_feature_flags=send_options
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Verify flags() was called (default to remote evaluation)
|
||||
patch_flags.assert_called_once()
|
||||
call_args = patch_flags.call_args[1]
|
||||
self.assertEqual(call_args["person_properties"], {"subscription": "pro"})
|
||||
|
||||
# Check the message includes the flag
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(
|
||||
msg["properties"]["$feature/default-flag"], "default-value"
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
def test_capture_exception_with_send_feature_flags_options(self, patch_flags):
|
||||
"""Test that capture_exception also supports SendFeatureFlagsOptions"""
|
||||
patch_flags.return_value = {"featureFlags": {"exception-flag": True}}
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
send_options = {
|
||||
"only_evaluate_locally": False,
|
||||
"person_properties": {"user_type": "admin"},
|
||||
}
|
||||
|
||||
try:
|
||||
raise ValueError("Test exception")
|
||||
except ValueError as e:
|
||||
msg_uuid = client.capture_exception(
|
||||
e, distinct_id="distinct_id", send_feature_flags=send_options
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
# Verify flags() was called with the correct properties
|
||||
patch_flags.assert_called_once()
|
||||
call_args = patch_flags.call_args[1]
|
||||
self.assertEqual(call_args["person_properties"], {"user_type": "admin"})
|
||||
|
||||
# Check the message includes the flag
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
msg = batch_data[0]
|
||||
|
||||
self.assertEqual(msg["event"], "$exception")
|
||||
self.assertEqual(msg["properties"]["$feature/exception-flag"], True)
|
||||
|
||||
def test_stringifies_distinct_id(self):
|
||||
# A large number that loses precision in node:
|
||||
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
|
||||
@@ -1591,7 +1771,7 @@ class TestClient(unittest.TestCase):
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_call_identify_fails(self, patch_get, patch_poll):
|
||||
def test_call_identify_fails(self, patch_get, patch_poller):
|
||||
def raise_effect():
|
||||
raise Exception("http exception")
|
||||
|
||||
@@ -1903,3 +2083,166 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
msg["properties"]["$session_id"], "explicit-session-override"
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_enable_local_evaluation_false_disables_poller(
|
||||
self, patch_get, patch_poller
|
||||
):
|
||||
"""Test that when enable_local_evaluation=False, the poller is not started"""
|
||||
patch_get.return_value = {
|
||||
"flags": [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
enable_local_evaluation=False,
|
||||
)
|
||||
|
||||
# Load feature flags should not start the poller
|
||||
client.load_feature_flags()
|
||||
|
||||
# Assert that the poller was not created/started
|
||||
patch_poller.assert_not_called()
|
||||
# But the feature flags should still be loaded
|
||||
patch_get.assert_called_once()
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
|
||||
"""Test that when enable_local_evaluation=True (default), the poller is started"""
|
||||
patch_get.return_value = {
|
||||
"flags": [
|
||||
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
enable_local_evaluation=True,
|
||||
)
|
||||
|
||||
# Load feature flags should start the poller
|
||||
client.load_feature_flags()
|
||||
|
||||
# Assert that the poller was created and started
|
||||
patch_poller.assert_called_once()
|
||||
patch_get.assert_called_once()
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
|
||||
|
||||
@mock.patch("posthog.client.remote_config")
|
||||
def test_get_remote_config_payload_works_without_poller(self, patch_remote_config):
|
||||
"""Test that get_remote_config_payload works without local evaluation enabled"""
|
||||
patch_remote_config.return_value = {"test": "payload"}
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
enable_local_evaluation=False,
|
||||
)
|
||||
|
||||
# Should work without poller
|
||||
result = client.get_remote_config_payload("test-flag")
|
||||
|
||||
self.assertEqual(result, {"test": "payload"})
|
||||
patch_remote_config.assert_called_once_with(
|
||||
"test-personal-key",
|
||||
client.host,
|
||||
"test-flag",
|
||||
timeout=client.feature_flags_request_timeout_seconds,
|
||||
)
|
||||
|
||||
def test_get_remote_config_payload_requires_personal_api_key(self):
|
||||
"""Test that get_remote_config_payload requires personal API key"""
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
enable_local_evaluation=False,
|
||||
)
|
||||
|
||||
result = client.get_remote_config_payload("test-flag")
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_send_feature_flags_method(self):
|
||||
"""Test the _parse_send_feature_flags helper method"""
|
||||
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
|
||||
|
||||
# Test boolean True
|
||||
result = client._parse_send_feature_flags(True)
|
||||
expected = {
|
||||
"should_send": True,
|
||||
"only_evaluate_locally": None,
|
||||
"person_properties": None,
|
||||
"group_properties": None,
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test boolean False
|
||||
result = client._parse_send_feature_flags(False)
|
||||
expected = {
|
||||
"should_send": False,
|
||||
"only_evaluate_locally": None,
|
||||
"person_properties": None,
|
||||
"group_properties": None,
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test options dict with all fields
|
||||
options = {
|
||||
"only_evaluate_locally": True,
|
||||
"person_properties": {"plan": "premium"},
|
||||
"group_properties": {"company": {"type": "enterprise"}},
|
||||
}
|
||||
result = client._parse_send_feature_flags(options)
|
||||
expected = {
|
||||
"should_send": True,
|
||||
"only_evaluate_locally": True,
|
||||
"person_properties": {"plan": "premium"},
|
||||
"group_properties": {"company": {"type": "enterprise"}},
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test options dict with partial fields
|
||||
options = {"person_properties": {"user_id": "123"}}
|
||||
result = client._parse_send_feature_flags(options)
|
||||
expected = {
|
||||
"should_send": True,
|
||||
"only_evaluate_locally": None,
|
||||
"person_properties": {"user_id": "123"},
|
||||
"group_properties": None,
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test empty dict
|
||||
result = client._parse_send_feature_flags({})
|
||||
expected = {
|
||||
"should_send": True,
|
||||
"only_evaluate_locally": None,
|
||||
"person_properties": None,
|
||||
"group_properties": None,
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test invalid types
|
||||
with self.assertRaises(TypeError) as cm:
|
||||
client._parse_send_feature_flags("invalid")
|
||||
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
|
||||
|
||||
with self.assertRaises(TypeError) as cm:
|
||||
client._parse_send_feature_flags(123)
|
||||
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
|
||||
|
||||
with self.assertRaises(TypeError) as cm:
|
||||
client._parse_send_feature_flags(None)
|
||||
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
|
||||
|
||||
@@ -1355,6 +1355,77 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.feature_flags.log")
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_with_flag_dependencies(
|
||||
self, patch_get, patch_flags, mock_log
|
||||
):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Flag with Dependencies",
|
||||
"key": "flag-with-dependencies",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "beta-feature",
|
||||
"operator": "exact",
|
||||
"value": True,
|
||||
"type": "flag",
|
||||
},
|
||||
{
|
||||
"key": "email",
|
||||
"operator": "icontains",
|
||||
"value": "@example.com",
|
||||
"type": "person",
|
||||
},
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Test that flag evaluation doesn't fail when encountering a flag dependency
|
||||
# The flag should evaluate based on other conditions (email contains @example.com)
|
||||
# Since flag dependencies aren't implemented, it should skip the flag condition
|
||||
# and evaluate based on the email condition only
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"flag-with-dependencies",
|
||||
"test-user",
|
||||
person_properties={"email": "test@example.com"},
|
||||
)
|
||||
self.assertEqual(feature_flag_match, True)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
# Verify warning was logged for flag dependency
|
||||
mock_log.warning.assert_called_with(
|
||||
"Flag dependency filters are not supported in local evaluation. "
|
||||
"Skipping condition for flag '%s' with dependency on flag '%s'",
|
||||
"flag-with-dependencies",
|
||||
"beta-feature",
|
||||
)
|
||||
|
||||
# Test with email that doesn't match
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"flag-with-dependencies",
|
||||
"test-user-2",
|
||||
person_properties={"email": "test@other.com"},
|
||||
)
|
||||
self.assertEqual(feature_flag_match, False)
|
||||
self.assertEqual(patch_flags.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
# Verify warning was logged again for the second evaluation
|
||||
self.assertEqual(mock_log.warning.call_count, 2)
|
||||
|
||||
@mock.patch("posthog.client.Poller")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_load_feature_flags(self, patch_get, patch_poll):
|
||||
|
||||
@@ -9,6 +9,24 @@ FlagValue = Union[bool, str]
|
||||
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
||||
|
||||
|
||||
class SendFeatureFlagsOptions(TypedDict, total=False):
|
||||
"""Options for sending feature flags with capture events.
|
||||
|
||||
Args:
|
||||
only_evaluate_locally: Whether to only use local evaluation for feature flags.
|
||||
If True, only flags that can be evaluated locally will be included.
|
||||
If False, remote evaluation via /flags API will be used when needed.
|
||||
person_properties: Properties to use for feature flag evaluation specific to this event.
|
||||
These properties will be merged with any existing person properties.
|
||||
group_properties: Group properties to use for feature flag evaluation specific to this event.
|
||||
Format: { group_type_name: { group_properties } }
|
||||
"""
|
||||
|
||||
only_evaluate_locally: Optional[bool]
|
||||
person_properties: Optional[dict[str, Any]]
|
||||
group_properties: Optional[dict[str, dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagReason:
|
||||
code: str
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "6.0.3"
|
||||
VERSION = "6.3.2"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -482,6 +482,7 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 },
|
||||
@@ -491,6 +492,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 },
|
||||
@@ -500,14 +504,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1955,7 +1965,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "6.0.0"
|
||||
version = "6.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
@@ -2040,7 +2050,7 @@ requires-dist = [
|
||||
{ name = "pytest-asyncio", marker = "extra == 'test'" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'test'" },
|
||||
{ name = "python-dateutil", specifier = ">=2.2" },
|
||||
{ name = "requests", specifier = ">=2.7,<3.0" },
|
||||
{ name = "requests", specifier = "<3.0,>=2.7" },
|
||||
{ name = "ruff", marker = "extra == 'dev'" },
|
||||
{ name = "setuptools", marker = "extra == 'dev'" },
|
||||
{ name = "six", specifier = ">=1.5" },
|
||||
|
||||
Reference in New Issue
Block a user