Skip to content
Closed
10 changes: 5 additions & 5 deletions bin/fmt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ set_source_and_root_dir
ensure_virtual_env

if [[ "$1" == "--check" ]]; then
black --check .
isort --check-only .
ruff format --check .
else
black .
isort .
fi
ruff format .
fi

mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
65 changes: 65 additions & 0 deletions bin/run_integration_tests
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#/ Usage: bin/run_integration_tests [--debug]
#/ Description: Runs integration tests for PostHog Python SDK
#/ Options:
#/ --debug Enable debug mode for verbose output
source bin/helpers/_utils.sh
set_source_and_root_dir

ensure_virtual_env

# Parse arguments
DEBUG_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--debug)
DEBUG_MODE=true
shift
;;
--help|-h)
grep '^#/' "$0" | cut -c4-
exit 0
;;
*)
error "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done

echo "🚀 PostHog Python SDK Integration Tests"
echo "=" | head -c 40 | tr '\n' '='
echo

# Load .env file if it exists
if [ -f ".env" ]; then
echo "📝 Loading environment variables from .env file..."
set -a # automatically export all variables
source .env
set +a # disable auto-export
fi

# Check if the integration test file exists
INTEGRATION_TEST_FILE="posthog/test/manual_integration/test_flag_dependencies.py"
if [ ! -f "$INTEGRATION_TEST_FILE" ]; then
fatal "❌ Integration test file not found: $INTEGRATION_TEST_FILE"
fi

echo "📋 Running flag dependencies integration test..."
if [ "$DEBUG_MODE" = true ]; then
echo "🔍 Debug mode enabled"
export POSTHOG_DEBUG=true
fi
echo

# Run the integration test
if python "$INTEGRATION_TEST_FILE"; then
echo
echo "🎉 All integration tests passed!"
exit 0
else
echo
error "💥 Integration tests failed!"
exit 1
fi
97 changes: 82 additions & 15 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@
exception_is_already_captured,
mark_exception_as_captured,
)
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
from posthog.feature_flags import (
InconclusiveMatchError,
match_feature_flag_properties,
build_dependency_graph,
evaluate_flags_with_dependencies,
)
from posthog.poller import Poller
from posthog.request import (
DEFAULT_HOST,
Expand Down Expand Up @@ -180,6 +185,8 @@ def __init__(
self.timeout = timeout
self._feature_flags = None # private variable to store flags
self.feature_flags_by_key = None
self.dependency_graph = None # for flag dependencies
self.id_to_key_mapping = None # maps flag ID to flag key
self.group_type_mapping = None
self.cohorts = None
self.poll_interval = poll_interval
Expand Down Expand Up @@ -305,6 +312,20 @@ def feature_flags(self, flags):
"feature_flags_by_key should be initialized when feature_flags is set"
)

# Build dependency graph for flag dependencies
try:
self.dependency_graph, self.id_to_key_mapping = build_dependency_graph(
self._feature_flags
)
self.log.debug(
f"Built dependency graph with {len(self.dependency_graph.flags)} flags"
)
self.log.debug(f"ID to key mapping: {self.id_to_key_mapping}")
except Exception as e:
self.log.warning(f"Failed to build dependency graph: {e}")
self.dependency_graph = None
self.id_to_key_mapping = None

def get_feature_variants(
self,
distinct_id,
Expand Down Expand Up @@ -516,7 +537,7 @@ def capture(
if personless and "$process_person_profile" not in properties:
properties["$process_person_profile"] = False

msg = {
msg: Dict[str, Any] = {
"properties": properties,
"timestamp": timestamp,
"distinct_id": distinct_id,
Expand Down Expand Up @@ -1032,13 +1053,14 @@ def join(self):
posthog.join()
```
"""
for consumer in self.consumers:
consumer.pause()
try:
consumer.join()
except RuntimeError:
# consumer thread has not started
pass
if self.consumers:
for consumer in self.consumers:
consumer.pause()
try:
consumer.join()
except RuntimeError:
# consumer thread has not started
pass

if self.poller:
self.poller.stop()
Expand Down Expand Up @@ -1183,12 +1205,12 @@ def _compute_flag_locally(
self.log.warning(
f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
)
# failover to `/decide/`
# failover to `/flags/`
raise InconclusiveMatchError("Flag has unknown group type index")

if group_name not in groups:
# Group flags are never enabled in `groups` aren't passed in
# don't failover to `/decide/`, since response will be the same
# don't failover to `/flags/`, since response will be the same
if warn_on_unknown_groups:
self.log.warning(
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
Expand All @@ -1201,11 +1223,21 @@ def _compute_flag_locally(

focused_group_properties = group_properties[group_name]
return match_feature_flag_properties(
feature_flag, groups[group_name], focused_group_properties
feature_flag,
groups[group_name],
focused_group_properties,
self.cohorts,
self.dependency_graph,
self.id_to_key_mapping,
)
else:
return match_feature_flag_properties(
feature_flag, distinct_id, person_properties, self.cohorts
feature_flag,
distinct_id,
person_properties,
self.cohorts,
self.dependency_graph,
self.id_to_key_mapping,
)

def feature_enabled(
Expand Down Expand Up @@ -1481,8 +1513,43 @@ def _locally_evaluate_flag(
assert self.feature_flags_by_key is not None, (
"feature_flags_by_key should be initialized when feature_flags is set"
)
# Local evaluation

flag = self.feature_flags_by_key.get(key)
if flag and flag.get("ensure_experience_continuity", False):
# Experience continuity flags cannot be evaluated locally
self.log.debug(
f"Flag {key} has experience continuity enabled, skipping local evaluation"
)
return None

# Check if any flags have dependencies
if self.dependency_graph and len(self.dependency_graph.flags) > 0:
# If we have dependencies, use the dependency-aware evaluation
try:
# Evaluate all flags with dependencies to ensure dependencies are available
all_results = evaluate_flags_with_dependencies(
self.feature_flags,
distinct_id,
person_properties,
self.cohorts,
requested_flag_keys={
key
}, # Only evaluate the requested flag and its dependencies
groups=groups,
group_properties=group_properties,
group_type_mapping=self.group_type_mapping,
)
response = all_results.get(key)
if response is not None:
self.log.debug(
f"Successfully computed flag with dependencies: {key} -> {response}"
)
return response
except Exception as e:
self.log.warning(f"Failed to evaluate flag with dependencies: {e}")
# Fall back to individual evaluation

# Fall back to individual flag evaluation
if flag:
try:
response = self._compute_flag_locally(
Expand Down Expand Up @@ -1661,7 +1728,7 @@ def _compute_payload_locally(
if self.feature_flags_by_key is None:
return payload

flag_definition = self.feature_flags_by_key.get(key)
flag_definition = self.feature_flags_by_key.get(key) # type: ignore[unreachable]
if flag_definition:
flag_filters = flag_definition.get("filters") or {}
flag_payloads = flag_filters.get("payloads") or {}
Expand Down
Loading
Loading