Compare commits

...
18 Commits
Author SHA1 Message Date
github-actions[bot] 16bc87b646 chore: Release v7.9.2 2026-02-18 22:05:00 +00:00
Rafael AudibertandGitHub f1dc4d7391 chore: Migrate releases to sampo (#398)
* chore: Migrate releases to `sampo`

This is much closer to what we have in `posthog-js`, let's see if it's a good thing!

There's still a lot to do before deploying this:
- updating CI
- updating README with instructions

* Add sampo changeset

* chore: Update  to relase Python via Slack + sampo

* Update release.yml

* fix: Use pyproject.toml version as source of truth
2026-02-18 19:02:19 -03:00
Radu RaiceaandGitHub 23dae56d68 fix(ai): bind prompt reads to project token (#433)
* fix(ai): bind prompt reads to project token

* chore(release): bump version to 7.8.7
2026-02-17 16:58:59 +00:00
73bec043cf chore: release v7.9.0 (#434)
chore: bump version to 7.9.0

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:32:05 +01:00
AndersandGitHub 603ed376dd feat: Support device_id as bucketing identifier for local evaluation (#424)
* feat: Support device_id as bucketing identifier for local evaluation

Add support for `bucketing_identifier` field on feature flags to allow
using `device_id` instead of `distinct_id` for hashing/bucketing in
local evaluation.
2026-02-16 11:05:09 +01:00
AndersandGitHub bb0c7b4fa8 test(flags): make wrong-key load_feature_flags deterministic (#432) 2026-02-13 15:53:22 +01:00
Aleksander BłaszkiewiczandGitHub 499194e0c4 feat: limit max number of items in collection to scan (#430)
* feat: limit max number of items in collection to scan

* feat: changelog

* fix: format

* feat: test

* feat: replace entire collection instead of truncating
2026-02-11 14:59:11 +01:00
Aleksander BłaszkiewiczandGitHub ffb8e9b591 feat: further optimize code variables regex search (#429)
* feat: initial

* fix: ruff
2026-02-09 23:59:03 +01:00
Aleksander BłaszkiewiczandGitHub 7780ca8390 fix: long variables pattern matching (#428) 2026-02-09 17:45:23 +01:00
AndersandGitHub bca175214d fix: Retry on 408 and respect Retry-After header (#426)
* fix: Retry on 408 and respect Retry-After header

408 (Request Timeout) was incorrectly treated as a non-retryable client
error. Retry-After response headers were ignored during backoff. Replace
backoff library usage with a manual retry loop that honours Retry-After
when present and falls back to exponential backoff otherwise.

* fix: Parse HTTP-date Retry-After values

Retry-After can be seconds or an HTTP-date per RFC 7231. Fall back to
email.utils.parsedate_to_datetime when the numeric parse fails.

* fix: Don't retry on unclassifiable APIError status

When APIError.status is "N/A" (no HTTP status), treat it as
non-retryable to avoid unexpected retry loops on errors the SDK
cannot classify.

* test: Add retry delay tests for Retry-After and exponential backoff

Verify time.sleep is called with the Retry-After value when present,
uses exponential backoff (2^attempt) when absent, and that 408 is
retried.
2026-02-09 12:15:49 +00:00
Aleksander BłaszkiewiczandGitHub fe3a9bbf75 fix: openai image sanitization (#425) 2026-02-06 14:15:57 +01:00
b6e66330e5 fix: openAI input image sanitization (#384)
Co-authored-by: Aleksander Błaszkiewicz <kqmdjc8@gmail.com>
2026-02-06 13:53:02 +01:00
Gabriel GrinbergandGitHub 4f32fa4100 Fix feature flag 401 errors causing HTTP request storm (#422)
* Fix feature flag 401 errors causing HTTP request storm

Set feature_flags = [] on 401 error to prevent repeated requests.

* Clear flag_cache, group_type_mapping, cohorts on 401
2026-02-04 10:31:10 -05:00
Radu RaiceaandGitHub f5719f39da fix(llma): default prompts url (#423) 2026-02-04 15:10:00 +00:00
Radu RaiceaandGitHub d4f2d6dfb0 fix(llma): small fixes for prompt management (#420)
* fix(llma): small fixes for prompt management

* fix(llma): tests

* fix(llma): tests
2026-02-04 09:49:19 +02:00
José SequeiraandGitHub 72f448816c feat: SDK Compliance (#397)
* feat: SDK Compliance
2026-01-30 16:12:43 +01:00
Radu RaiceaandGitHub 4350389f93 feat(llma): add prompt management (#417)
* feat(llma): add prompt management

* chore(llma): bump version

* fix(llma): use SDK session with retry logic for prompt fetching

Use _get_session() from posthog/request.py instead of raw requests.get()
to benefit from the SDK's existing retry configuration on transient
network failures.
2026-01-30 08:43:04 -05:00
c32c78312f feat(llma): pass raw provider usage metadata for backend cost calculations (#411)
* feat: pass raw provider usage metadata for backend cost calculations

Add raw_usage field to TokenUsage type to capture raw provider usage metadata (OpenAI, Anthropic, Gemini). This enables the backend to extract modality-specific token counts (text vs image vs audio) for accurate cost calculations.

- Add raw_usage field to TokenUsage TypedDict
- Update all provider converters to capture raw usage:
  - OpenAI: capture response.usage and chunk usage
  - Anthropic: capture usage from message_start and message_delta events
  - Gemini: capture usage_metadata from responses and chunks
- Pass raw usage as $ai_usage property in PostHog events
- Update merge_usage_stats to handle raw_usage in both modes
- Add tests verifying $ai_usage is captured for all providers

Backend will extract provider-specific details and delete $ai_usage after processing to avoid bloating properties.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add serialize_raw_usage helper to ensure JSON serializability

Address PR review feedback from @andrewm4894:

1. **Serialization**: Add serialize_raw_usage() helper with fallback chain:
   - .model_dump() for Pydantic models (OpenAI/Anthropic)
   - .to_dict() for protobuf-like objects
   - vars() for simple objects
   - str() as last resort
   This ensures we never pass unserializable objects to PostHog client.

2. **Data loss prevention**: Change from replacing to merging raw_usage in
   incremental mode. For Anthropic streaming, message_start has input token
   details and message_delta has output token details - merging preserves
   both instead of losing input data.

3. **Test coverage**: Enhanced tests to verify:
   - JSON serializability with json.dumps()
   - Expected structure of raw_usage dicts
   - Coverage for both non-streaming and streaming modes
   - Fixed Gemini test mocks to return proper dicts from model_dump()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: move raw_usage serialization from utils to converters

Address PR feedback from @andrewm4894 - serialize in converters, not utils.

**Problem:**
Utils was receiving raw Pydantic/protobuf objects and serializing them,
which meant provider-specific knowledge leaked into generic code.

**Solution:**
Move serialization into converters where provider context exists:

Converters (NEW):
- OpenAI: serialize_raw_usage(response.usage) → dict
- Anthropic: serialize_raw_usage(event.usage) → dict
- Gemini: serialize_raw_usage(metadata) → dict

Utils (SIMPLIFIED):
- Just passes dicts through, no serialization needed
- Merge operations work with dicts only

**Benefits:**
1. Type correctness: raw_usage is always Dict[str, Any]
2. Separation of concerns: converters handle provider formats
3. Fail fast: serialization errors in converters with context
4. Cleaner abstraction: utils doesn't know about Pydantic/protobuf

**Flow:**
Provider object → Converter serializes → dict → Utils → PostHog

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add type annotation for current_raw to satisfy mypy

Fix mypy error: "Need type annotation for 'current_raw'"

Extract value first, then apply explicit type annotation with ternary
conditional to satisfy mypy's type checker.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 11:51:02 +02:00
37 changed files with 3192 additions and 212 deletions
+226 -35
View File
@@ -1,58 +1,249 @@
name: "Release"
on:
push:
branches:
- master
paths:
- "posthog/version.py"
pull_request:
types: [closed]
branches: [master]
workflow_dispatch:
permissions:
contents: read
# Concurrency control: only one release process can run at a time
# This prevents race conditions if multiple PRs with 'release' label merge simultaneously
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Publish release
check-release-label:
name: Check for release label
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
# Run when PR with 'release' label is merged to master
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release'))
outputs:
should-release: ${{ steps.check.outputs.should-release }}
steps:
- name: Checkout the repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- name: Check release conditions
id: check
run: |
changeset_count=$(find .sampo/changesets -name '*.md' 2>/dev/null | wc -l)
if [ "$changeset_count" -gt 0 ]; then
echo "should-release=true" >> "$GITHUB_OUTPUT"
echo "Found $changeset_count changeset(s), ready to release"
else
echo "should-release=false" >> "$GITHUB_OUTPUT"
echo "No changesets to release"
fi
notify-approval-needed:
name: Notify Slack - Approval Needed
needs: check-release-label
if: needs.check-release-label.outputs.should-release == 'true'
uses: posthog/.github/.github/workflows/notify-approval-needed.yml@main
with:
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
slack_user_group_id: ${{ vars.GROUP_CLIENT_LIBRARIES_SLACK_GROUP_ID }}
secrets:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }}
release:
name: Release and publish
needs: [check-release-label, notify-approval-needed]
runs-on: ubuntu-latest
# Use `always()` to ensure the job runs even if notify-approval-needed is skipped,
# but still depend on it to access `needs.notify-approval-needed.outputs.slack_ts`
if: always() && needs.check-release-label.outputs.should-release == 'true'
environment: "Release" # This will require an approval from a maintainer, they are notified in Slack above
permissions:
contents: write
actions: write
id-token: write
steps:
- name: Notify Slack - Approved
if: needs.notify-approval-needed.outputs.slack_ts != ''
uses: posthog/.github/.github/actions/slack-thread-reply@main
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "✅ Release approved! Version bump in progress..."
emoji_reaction: "white_check_mark"
- name: Get GitHub App token
id: releaser
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
token: ${{ steps.releaser.outputs.token }}
- name: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
uses: actions/setup-python@v5
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
enable-cache: true
pyproject-file: "pyproject.toml"
- name: Prepare for building release
- name: Install Rust
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e
with:
toolchain: 1.91.1
components: cargo
- name: Cache Sampo CLI
id: cache-sampo
uses: actions/cache@v3
with:
path: ~/.cargo/bin/sampo
key: sampo-${{ runner.os }}-${{ runner.arch }}
- name: Install Sampo CLI
if: steps.cache-sampo.outputs.cache-hit != 'true'
run: cargo install sampo
- name: Install dependencies
run: uv sync --extra dev
- name: Push releases to PyPI
env:
TWINE_USERNAME: __token__
run: uv run make release && uv run make release_analytics
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create GitHub release
- name: Prepare release with Sampo
id: sampo-release
env:
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}
run: |
sampo release
new_version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
- name: Sync version to posthog/version.py
run: |
echo 'VERSION = "${{ steps.sampo-release.outputs.new_version }}"' > posthog/version.py
- name: Commit release changes
id: commit-release
env:
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}
run: |
git add -A
if git diff --staged --quiet; then
echo "No changes to commit"
echo "committed=false" >> "$GITHUB_OUTPUT"
else
git commit -m "chore: Release v${{ steps.sampo-release.outputs.new_version }}"
git push origin master
echo "committed=true" >> "$GITHUB_OUTPUT"
fi
# Publishing is done manually (not via `sampo publish`) because we need to
# publish both `posthog` and `posthoganalytics` packages to PyPI.
# Sampo only knows about the `posthog` package, so we handle both here.
# Both packages use PyPI OIDC trusted publishing (no API tokens needed).
- name: Build posthog
if: steps.commit-release.outputs.committed == 'true'
run: uv run make build_release
- name: Publish posthog to PyPI
if: steps.commit-release.outputs.committed == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
# The `posthoganalytics` package is a mirror of `posthog` published under
# a different name for backwards compatibility. The make target handles
# copying, renaming imports, and building the dist automatically.
- name: Build posthoganalytics
if: steps.commit-release.outputs.committed == 'true'
run: uv run make build_release_analytics
- name: Publish posthoganalytics to PyPI
if: steps.commit-release.outputs.committed == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
# We skip `sampo publish` (which normally creates the tag) because we
# need to publish both posthog and posthoganalytics manually, so we
# create the tag ourselves.
- name: Tag release
if: steps.commit-release.outputs.committed == 'true'
run: git tag "v${{ steps.sampo-release.outputs.new_version }}"
- name: Push tags
if: steps.commit-release.outputs.committed == 'true'
run: git push origin --tags
- name: Create GitHub Release
if: steps.commit-release.outputs.committed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "v${{ steps.sampo-release.outputs.new_version }}" --generate-notes
- name: Dispatch generate-references
if: steps.commit-release.outputs.committed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ env.REPO_VERSION }}" \
--title "${{ env.REPO_VERSION }}" \
--generate-notes
- name: Dispatch generate-references for posthog-python
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run generate-references.yml --ref master
run: gh workflow run generate-references.yml --ref master
# Notify in case of a failure
- name: Send failure event to PostHog
if: ${{ failure() }}
uses: PostHog/posthog-github-action@v0.1
with:
posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}"
event: "posthog-python-github-release-workflow-failure"
properties: >-
{
"commitSha": "${{ github.sha }}",
"jobStatus": "${{ job.status }}",
"ref": "${{ github.ref }}",
"version": "v${{ steps.sampo-release.outputs.new_version }}"
}
- name: Notify Slack - Failed
if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }}
uses: posthog/.github/.github/actions/slack-thread-reply@main
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "❌ Failed to release `posthog-python@v${{ steps.sampo-release.outputs.new_version }}`! <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
emoji_reaction: "x"
notify-released:
name: Notify Slack - Released
needs: [check-release-label, notify-approval-needed, release]
runs-on: ubuntu-latest
if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != ''
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Notify Slack - Released
uses: posthog/.github/.github/actions/slack-thread-reply@main
with:
slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }}
slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }}
thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }}
message: "🚀 posthog-python released successfully!"
emoji_reaction: "rocket"
+21
View File
@@ -0,0 +1,21 @@
name: SDK Compliance Tests
permissions:
contents: read
packages: read
pull-requests: write
on:
pull_request:
push:
branches:
- master
jobs:
compliance:
name: PostHog SDK compliance tests
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "latest"
+19
View File
@@ -0,0 +1,19 @@
# Sampo configuration
version = 1
[git]
default_branch = "master"
short_tags = "posthog" # Tag with v1.2.3 rather than posthog-v1.2.3
[github]
repository = "posthog/posthog-python"
[changelog]
# Options for release notes generation.
# show_commit_hash = true (default)
# show_acknowledgments = true (default)
[packages]
# Options for package discovery and filtering.
# ignore_unpublished = false (default)
# ignore = ["internal-*", "examples/*"]
+101 -55
View File
@@ -1,39 +1,85 @@
# 7.7.0 - 2026-01-15
# posthog
## 7.9.2 — 2026-02-18
### Patch changes
- [f1dc4d7](https://github.com/posthog/posthog-python/commit/f1dc4d73914712983a7f715ee4fe1b70e66e770a) Add sampo to the project — Thanks @rafaeelaudibert!
## 7.9.1 - 2026-02-17
fix(llma): make prompt fetches deterministic by requiring project_api_key and sending it as token query param
## 7.9.0 - 2026-02-17
feat: Support device_id as bucketing identifier for local evaluation
## 7.8.6 - 2026-02-09
fix: limit collections scanning in code variables
## 7.8.5 - 2026-02-09
fix: further optimize code variables pattern matching
## 7.8.4 - 2026-02-09
fix: do not pattern match long values in code variables
## 7.8.3 - 2026-02-06
fix: openAI input image sanitization
## 7.8.2 - 2026-02-04
fix(llma): fix prompts default url
## 7.8.1 - 2026-02-03
fix(llma): small fixes for prompt management
## 7.8.0 - 2026-01-28
feat(llma): add prompt management
Adds the Prompt Management feature. At the time of release, this feature is in a closed alpha.
## 7.7.0 - 2026-01-15
feat(ai): Add OpenAI Agents SDK integration
Automatic tracing for agent workflows, handoffs, tool calls, guardrails, and custom spans. Includes `$ai_total_tokens`, `$ai_error_type` categorization, and `$ai_framework` property.
# 7.6.0 - 2026-01-12
## 7.6.0 - 2026-01-12
feat: add device_id to flags request payload
Add device_id parameter to all feature flag methods, allowing the server to track device identifiers for flag evaluation. The device_id can be passed explicitly or set via context using `set_context_device_id()`.
# 7.5.1 - 2026-01-07
## 7.5.1 - 2026-01-07
fix: avoid return from finally block to fix Python 3.14 SyntaxWarning (#361) - thanks @jodal
# 7.5.0 - 2026-01-06
## 7.5.0 - 2026-01-06
feat: Capture Langchain, OpenAI and Anthropic errors as exceptions (if exception autocapture is enabled)
feat: Add reference to exception in LLMA trace and span events
# 7.4.3 - 2026-01-02
## 7.4.3 - 2026-01-02
Fixes cache creation cost for Langchain with Anthropic
# 7.4.2 - 2025-12-22
## 7.4.2 - 2025-12-22
feat: add `in_app_modules` option to control code variables capturing
# 7.4.1 - 2025-12-19
## 7.4.1 - 2025-12-19
fix: extract model from response for OpenAI stored prompts
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
# 7.4.0 - 2025-12-16
## 7.4.0 - 2025-12-16
feat: Add automatic retries for feature flag requests
@@ -45,32 +91,32 @@ Feature flag API requests now automatically retry on transient failures:
Rate limit (429) and quota (402) errors are not retried.
# 7.3.1 - 2025-12-06
## 7.3.1 - 2025-12-06
fix: remove unused $exception_message and $exception_type
# 7.3.0 - 2025-12-05
## 7.3.0 - 2025-12-05
feat: improve code variables capture masking
# 7.2.0 - 2025-12-01
## 7.2.0 - 2025-12-01
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
# 7.1.0 - 2025-11-26
## 7.1.0 - 2025-11-26
Add support for the async version of Gemini.
# 7.0.2 - 2025-11-18
## 7.0.2 - 2025-11-18
Add support for Python 3.14.
Projects upgrading to Python 3.14 should ensure any Pydantic models passed into the SDK use Pydantic v2, as Pydantic v1 is not compatible with Python 3.14.
# 7.0.1 - 2025-11-15
## 7.0.1 - 2025-11-15
Try to use repr() when formatting code variables
# 7.0.0 - 2025-11-11
## 7.0.0 - 2025-11-11
NB Python 3.9 is no longer supported
@@ -84,155 +130,155 @@ NB Python 3.9 is no longer supported
- langchain-community: 0.3.29 → 0.4.1
- langgraph: 0.6.6 → 1.0.2
# 6.9.3 - 2025-11-10
## 6.9.3 - 2025-11-10
- feat(ph-ai): PostHog properties dict in GenerationMetadata
# 6.9.2 - 2025-11-10
## 6.9.2 - 2025-11-10
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
# 6.9.1 - 2025-11-07
## 6.9.1 - 2025-11-07
- fix(error-tracking): pass code variables config from init to client
# 6.9.0 - 2025-11-06
## 6.9.0 - 2025-11-06
- feat(error-tracking): add local variables capture
# 6.8.0 - 2025-11-03
## 6.8.0 - 2025-11-03
- feat(llma): send web search calls to be used for LLM cost calculations
# 6.7.14 - 2025-11-03
## 6.7.14 - 2025-11-03
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
# 6.7.13 - 2025-11-02
## 6.7.13 - 2025-11-02
- fix(llma): cache cost calculation in the LangChain callback
# 6.7.12 - 2025-11-02
## 6.7.12 - 2025-11-02
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
# 6.7.11 - 2025-10-28
## 6.7.11 - 2025-10-28
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
# 6.7.10 - 2025-10-24
## 6.7.10 - 2025-10-24
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
# 6.7.9 - 2025-10-22
## 6.7.9 - 2025-10-22
- fix(flags): multi-condition flags with static cohorts returning wrong variants
# 6.7.8 - 2025-10-16
## 6.7.8 - 2025-10-16
- fix(llma): missing async for OpenAI's streaming implementation
# 6.7.7 - 2025-10-14
## 6.7.7 - 2025-10-14
- fix: remove deprecated attribute $exception_personURL from exception events
# 6.7.6 - 2025-09-16
## 6.7.6 - 2025-09-16
- fix: don't sort condition sets with variant overrides to the top
- fix: Prevent core Client methods from raising exceptions
# 6.7.5 - 2025-09-16
## 6.7.5 - 2025-09-16
- feat: Django middleware now supports async request handling.
# 6.7.4 - 2025-09-05
## 6.7.4 - 2025-09-05
- fix: Missing system prompts for some providers
# 6.7.3 - 2025-09-04
## 6.7.3 - 2025-09-04
- fix: missing usage tokens in Gemini
# 6.7.2 - 2025-09-03
## 6.7.2 - 2025-09-03
- fix: tool call results in streaming providers
# 6.7.1 - 2025-09-01
## 6.7.1 - 2025-09-01
- fix: Add base64 inline image sanitization
# 6.7.0 - 2025-08-26
## 6.7.0 - 2025-08-26
- feat: Add support for feature flag dependencies
# 6.6.1 - 2025-08-21
## 6.6.1 - 2025-08-21
- fix: Prevent `NoneType` error when `group_properties` is `None`
# 6.6.0 - 2025-08-15
## 6.6.0 - 2025-08-15
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
# 6.5.0 - 2025-08-08
## 6.5.0 - 2025-08-08
- feat: Add `$context_tags` to an event to know which properties were included as tags
# 6.4.1 - 2025-08-06
## 6.4.1 - 2025-08-06
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
# 6.4.0 - 2025-08-05
## 6.4.0 - 2025-08-05
- feat: support Vertex AI for Gemini
# 6.3.4 - 2025-08-04
## 6.3.4 - 2025-08-04
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
# 6.3.3 - 2025-08-01
## 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
# 6.3.2 - 2025-07-31
## 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
# 6.3.0 - 2025-07-22
## 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
## 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
## 6.1.1 - 2025-07-16
- fix: correctly capture exceptions processed by Django from views or middleware
# 6.1.0 - 2025-07-10
## 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
## 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
## 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
# 6.0.2 - 2025-07-02
## 6.0.2 - 2025-07-02
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
# 6.0.1
## 6.0.1
- fix: response `$process_person_profile` property when passed to capture
# 6.0.0
## 6.0.0
This release contains a number of major breaking changes:
@@ -259,15 +305,15 @@ with posthog.new_context():
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
# 5.4.0 - 2025-06-20
## 5.4.0 - 2025-06-20
- feat: add support to session_id context on page method
# 5.3.0 - 2025-06-19
## 5.3.0 - 2025-06-19
- fix: safely handle exception values
# 5.2.0 - 2025-06-19
## 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
+18 -5
View File
@@ -5,12 +5,26 @@ test:
coverage run -m pytest
coverage report
release:
build_release:
rm -rf dist/*
python setup.py sdist bdist_wheel
twine upload dist/*
release_analytics:
# Builds the `posthoganalytics` PyPI package, which is a mirror of `posthog`
# published under a different name for internal use by posthog/posthog.
#
# The process works in three phases:
# 1. posthog -> posthoganalytics: Copy the source, rewrite all imports,
# remove the original posthog/ dir, and build the dist.
# 2. posthoganalytics -> posthog: Reverse the import rewrites, copy
# everything back into posthog/, and clean up.
# 3. Restore pyproject.toml from backup (setup_analytics.py modifies it).
#
# This ensures the working tree is left in the same state it started in.
#
# NOTE: This target clears dist/ before building. In the release workflow,
# `build_release` (posthog) must be published BEFORE running this target,
# otherwise the posthog dist artifacts will be lost.
build_release_analytics:
rm -rf dist
rm -rf build
rm -rf posthoganalytics
@@ -21,7 +35,6 @@ release_analytics:
find ./posthoganalytics -name "*.bak" -delete
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
@@ -54,4 +67,4 @@ prep_local:
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"
.PHONY: test lint release e2e_test prep_local
.PHONY: test lint build_release build_release_analytics e2e_test prep_local
+11
View File
@@ -0,0 +1,11 @@
# posthoganalytics
> **Do not use this package.** Use [`posthog`](https://pypi.org/project/posthog/) instead.
```bash
pip install posthog
```
This package exists solely for internal use by [posthog/posthog](https://github.com/posthog/posthog) to avoid import conflicts with the local `posthog` package in that repository. It is an automatically generated mirror of `posthog` — same code, same versions, just published under a different name.
If you are not working on the PostHog main repository, you should never need this package. All documentation, issues, and development happen in [`posthog-python`](https://github.com/posthog/posthog-python).
+3
View File
@@ -0,0 +1,3 @@
from posthog.ai.prompts import Prompts
__all__ = ["Prompts"]
@@ -17,6 +17,7 @@ from posthog.ai.types import (
TokenUsage,
ToolInProgress,
)
from posthog.ai.utils import serialize_raw_usage
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
@@ -221,6 +222,12 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
if web_search_count > 0:
result["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response.usage)
if serialized:
result["raw_usage"] = serialized
return result
@@ -247,6 +254,11 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
usage["cache_read_input_tokens"] = getattr(
event.message.usage, "cache_read_input_tokens", 0
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(event.message.usage)
if serialized:
usage["raw_usage"] = serialized
# Handle usage stats from message_delta event
if hasattr(event, "usage") and event.usage:
@@ -262,6 +274,12 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
if web_search_count > 0:
usage["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(event.usage)
if serialized:
usage["raw_usage"] = serialized
return usage
+7
View File
@@ -12,6 +12,7 @@ from posthog.ai.types import (
FormattedMessage,
TokenUsage,
)
from posthog.ai.utils import serialize_raw_usage
class GeminiPart(TypedDict, total=False):
@@ -487,6 +488,12 @@ def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
if reasoning_tokens and reasoning_tokens > 0:
usage["reasoning_tokens"] = reasoning_tokens
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(metadata)
if serialized:
usage["raw_usage"] = serialized
return usage
+19
View File
@@ -16,6 +16,7 @@ from posthog.ai.types import (
FormattedTextContent,
TokenUsage,
)
from posthog.ai.utils import serialize_raw_usage
def format_openai_response(response: Any) -> List[FormattedMessage]:
@@ -429,6 +430,12 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
if web_search_count > 0:
result["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response.usage)
if serialized:
result["raw_usage"] = serialized
return result
@@ -482,6 +489,12 @@ def extract_openai_usage_from_chunk(
chunk.usage.completion_tokens_details.reasoning_tokens
)
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(chunk.usage)
if serialized:
usage["raw_usage"] = serialized
elif provider_type == "responses":
# For Responses API, usage is only in chunk.response.usage for completed events
if hasattr(chunk, "type") and chunk.type == "response.completed":
@@ -516,6 +529,12 @@ def extract_openai_usage_from_chunk(
if web_search_count > 0:
usage["web_search_count"] = web_search_count
# Capture raw usage metadata for backend processing
# Serialize to dict here in the converter (not in utils)
serialized = serialize_raw_usage(response_usage)
if serialized:
usage["raw_usage"] = serialized
return usage
+286
View File
@@ -0,0 +1,286 @@
"""
Prompt management for PostHog AI SDK.
Fetch and compile LLM prompts from PostHog with caching and fallback support.
"""
import logging
import re
import time
import urllib.parse
from typing import Any, Dict, Optional, Union
from posthog.request import USER_AGENT, _get_session
from posthog.utils import remove_trailing_slash
log = logging.getLogger("posthog")
APP_ENDPOINT = "https://us.posthog.com"
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
PromptVariables = Dict[str, Union[str, int, float, bool]]
class CachedPrompt:
"""Cached prompt with metadata."""
def __init__(self, prompt: str, fetched_at: float):
self.prompt = prompt
self.fetched_at = fetched_at
def _is_prompt_api_response(data: Any) -> bool:
"""Check if the response is a valid prompt API response."""
return (
isinstance(data, dict)
and "prompt" in data
and isinstance(data.get("prompt"), str)
)
class Prompts:
"""
Fetch and compile LLM prompts from PostHog.
Can be initialized with a PostHog client or with direct options.
Examples:
```python
from posthog import Posthog
from posthog.ai.prompts import Prompts
# With PostHog client
posthog = Posthog('phc_xxx', host='https://us.posthog.com', personal_api_key='phx_xxx')
prompts = Prompts(posthog)
# Or with direct options (no PostHog client needed)
prompts = Prompts(
personal_api_key='phx_xxx',
project_api_key='phc_xxx',
host='https://us.posthog.com',
)
# Fetch with caching and fallback
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')
# Compile with variables
system_prompt = prompts.compile(template, {
'company': 'Acme Corp',
'tier': 'premium',
})
```
"""
def __init__(
self,
posthog: Optional[Any] = None,
*,
personal_api_key: Optional[str] = None,
project_api_key: Optional[str] = None,
host: Optional[str] = None,
default_cache_ttl_seconds: Optional[int] = None,
):
"""
Initialize Prompts.
Args:
posthog: PostHog client instance (optional if personal_api_key provided)
personal_api_key: Direct personal API key (optional if posthog provided)
project_api_key: Direct project API key (optional if posthog provided)
host: PostHog host (defaults to app endpoint)
default_cache_ttl_seconds: Default cache TTL (defaults to 300)
"""
self._default_cache_ttl_seconds = (
default_cache_ttl_seconds or DEFAULT_CACHE_TTL_SECONDS
)
self._cache: Dict[str, CachedPrompt] = {}
if posthog is not None:
self._personal_api_key = getattr(posthog, "personal_api_key", None) or ""
self._project_api_key = getattr(posthog, "api_key", None) or ""
self._host = remove_trailing_slash(
getattr(posthog, "raw_host", None) or APP_ENDPOINT
)
else:
self._personal_api_key = personal_api_key or ""
self._project_api_key = project_api_key or ""
self._host = remove_trailing_slash(host or APP_ENDPOINT)
def get(
self,
name: str,
*,
cache_ttl_seconds: Optional[int] = None,
fallback: Optional[str] = None,
) -> str:
"""
Fetch a prompt by name from the PostHog API.
Caching behavior:
1. If cache is fresh, return cached value
2. If fetch fails and cache exists (stale), return stale cache with warning
3. If fetch fails and fallback provided, return fallback with warning
4. If fetch fails with no cache/fallback, raise exception
Args:
name: The name of the prompt to fetch
cache_ttl_seconds: Cache TTL in seconds (defaults to instance default)
fallback: Fallback prompt to use if fetch fails and no cache available
Returns:
The prompt string
Raises:
Exception: If the prompt cannot be fetched and no fallback is available
"""
ttl = (
cache_ttl_seconds
if cache_ttl_seconds is not None
else self._default_cache_ttl_seconds
)
# Check cache first
cached = self._cache.get(name)
now = time.time()
if cached is not None:
is_fresh = (now - cached.fetched_at) < ttl
if is_fresh:
return cached.prompt
# Try to fetch from API
try:
prompt = self._fetch_prompt_from_api(name)
fetched_at = time.time()
# Update cache
self._cache[name] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
return prompt
except Exception as error:
# Fallback order:
# 1. Return stale cache (with warning)
if cached is not None:
log.warning(
'[PostHog Prompts] Failed to fetch prompt "%s", using stale cache: %s',
name,
error,
)
return cached.prompt
# 2. Return fallback (with warning)
if fallback is not None:
log.warning(
'[PostHog Prompts] Failed to fetch prompt "%s", using fallback: %s',
name,
error,
)
return fallback
# 3. Raise error
raise
def compile(self, prompt: str, variables: PromptVariables) -> str:
"""
Replace {{variableName}} placeholders with values.
Unmatched variables are left unchanged.
Supports variable names with hyphens and dots (e.g., user-id, company.name).
Args:
prompt: The prompt template string
variables: Object containing variable values
Returns:
The compiled prompt string
"""
def replace_variable(match: re.Match) -> str:
variable_name = match.group(1)
if variable_name in variables:
return str(variables[variable_name])
return match.group(0)
return re.sub(r"\{\{([\w.-]+)\}\}", replace_variable, prompt)
def clear_cache(self, name: Optional[str] = None) -> None:
"""
Clear cached prompts.
Args:
name: Specific prompt to clear. If None, clears all cached prompts.
"""
if name is not None:
self._cache.pop(name, None)
else:
self._cache.clear()
def _fetch_prompt_from_api(self, name: str) -> str:
"""
Fetch prompt from PostHog API.
Endpoint: {host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}
Auth: Bearer {personal_api_key}
Args:
name: The name of the prompt to fetch
Returns:
The prompt string
Raises:
Exception: If the prompt cannot be fetched
"""
if not self._personal_api_key:
raise Exception(
"[PostHog Prompts] personal_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
if not self._project_api_key:
raise Exception(
"[PostHog Prompts] project_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
encoded_name = urllib.parse.quote(name, safe="")
encoded_project_api_key = urllib.parse.quote(self._project_api_key, safe="")
url = f"{self._host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}"
headers = {
"Authorization": f"Bearer {self._personal_api_key}",
"User-Agent": USER_AGENT,
}
response = _get_session().get(url, headers=headers, timeout=10)
if not response.ok:
if response.status_code == 404:
raise Exception(f'[PostHog Prompts] Prompt "{name}" not found')
if response.status_code == 403:
raise Exception(
f'[PostHog Prompts] Access denied for prompt "{name}". '
"Check that your personal_api_key has the correct permissions and the LLM prompts feature is enabled."
)
raise Exception(
f'[PostHog Prompts] Failed to fetch prompt "{name}": HTTP {response.status_code}'
)
try:
data = response.json()
except Exception:
raise Exception(
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
if not _is_prompt_api_response(data):
raise Exception(
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
return data["prompt"]
+6
View File
@@ -83,6 +83,12 @@ def sanitize_openai_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if item.get("type") == "input_image" and isinstance(item.get("image_url"), str):
return {
**item,
"image_url": redact_base64_data_url(item["image_url"]),
}
if (
item.get("type") == "image_url"
and isinstance(item.get("image_url"), dict)
+1
View File
@@ -64,6 +64,7 @@ class TokenUsage(TypedDict, total=False):
cache_creation_input_tokens: Optional[int]
reasoning_tokens: Optional[int]
web_search_count: Optional[int]
raw_usage: Optional[Any] # Raw provider usage metadata for backend processing
class ProviderResponse(TypedDict, total=False):
+78
View File
@@ -13,6 +13,54 @@ from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from posthog.client import Client as PostHogClient
def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]:
"""
Convert raw provider usage objects to JSON-serializable dicts.
Handles Pydantic models (OpenAI/Anthropic) and protobuf-like objects (Gemini)
with a fallback chain to ensure we never pass unserializable objects to PostHog.
Args:
raw_usage: Raw usage object from provider SDK
Returns:
Plain dict or None if conversion fails
"""
if raw_usage is None:
return None
# Already a dict
if isinstance(raw_usage, dict):
return raw_usage
# Try Pydantic model_dump() (OpenAI/Anthropic)
if hasattr(raw_usage, "model_dump") and callable(raw_usage.model_dump):
try:
return raw_usage.model_dump()
except Exception:
pass
# Try to_dict() (some protobuf objects)
if hasattr(raw_usage, "to_dict") and callable(raw_usage.to_dict):
try:
return raw_usage.to_dict()
except Exception:
pass
# Try __dict__ / vars() for simple objects
try:
return vars(raw_usage)
except Exception:
pass
# Last resort: convert to string representation
# This ensures we always return something rather than failing
try:
return {"_raw": str(raw_usage)}
except Exception:
return None
def merge_usage_stats(
target: TokenUsage, source: TokenUsage, mode: str = "incremental"
) -> None:
@@ -60,6 +108,17 @@ def merge_usage_stats(
current = target.get("web_search_count") or 0
target["web_search_count"] = max(current, source_web_search)
# Merge raw_usage to avoid losing data from earlier events
# For Anthropic streaming: message_start has input tokens, message_delta has output
# Note: raw_usage is already serialized by converters, so it's a dict
source_raw_usage = source.get("raw_usage")
if source_raw_usage is not None and isinstance(source_raw_usage, dict):
current_raw_value = target.get("raw_usage")
current_raw: Dict[str, Any] = (
current_raw_value if isinstance(current_raw_value, dict) else {}
)
target["raw_usage"] = {**current_raw, **source_raw_usage}
elif mode == "cumulative":
# Replace with latest values (already cumulative)
if source.get("input_tokens") is not None:
@@ -76,6 +135,9 @@ def merge_usage_stats(
target["reasoning_tokens"] = source["reasoning_tokens"]
if source.get("web_search_count") is not None:
target["web_search_count"] = source["web_search_count"]
# Note: raw_usage is already serialized by converters, so it's a dict
if source.get("raw_usage") is not None:
target["raw_usage"] = source["raw_usage"]
else:
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
@@ -332,6 +394,11 @@ def call_llm_and_track_usage(
if web_search_count is not None and web_search_count > 0:
tag("$ai_web_search_count", web_search_count)
raw_usage = usage.get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
tag("$ai_usage", raw_usage)
if posthog_distinct_id is None:
tag("$process_person_profile", False)
@@ -457,6 +524,11 @@ async def call_llm_and_track_usage_async(
if web_search_count is not None and web_search_count > 0:
tag("$ai_web_search_count", web_search_count)
raw_usage = usage.get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
tag("$ai_usage", raw_usage)
if posthog_distinct_id is None:
tag("$process_person_profile", False)
@@ -594,6 +666,12 @@ def capture_streaming_event(
):
event_properties["$ai_web_search_count"] = web_search_count
# Add raw usage metadata if present (all providers)
raw_usage = event_data["usage_stats"].get("raw_usage")
if raw_usage is not None:
# Already serialized by converters
event_properties["$ai_usage"] = raw_usage
# Handle provider-specific fields
if (
event_data["provider"] == "openai"
+49 -14
View File
@@ -38,6 +38,7 @@ from posthog.feature_flags import (
InconclusiveMatchError,
RequiresServerEvaluation,
match_feature_flag_properties,
resolve_bucketing_value,
)
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
@@ -1335,6 +1336,13 @@ class Client(object):
self.log.error(
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
)
self.feature_flags = []
self.group_type_mapping = {}
self.cohorts = {}
if self.flag_cache:
self.flag_cache.clear()
if self.debug:
raise APIError(
status=401,
@@ -1411,6 +1419,7 @@ class Client(object):
person_properties=None,
group_properties=None,
warn_on_unknown_groups=True,
device_id=None,
) -> FlagValue:
groups = groups or {}
person_properties = person_properties or {}
@@ -1451,22 +1460,35 @@ class Client(object):
)
return False
if group_name not in group_properties:
raise InconclusiveMatchError(
f"Flag has no group properties for group '{group_name}'"
)
focused_group_properties = group_properties[group_name]
group_key = groups[group_name]
return match_feature_flag_properties(
feature_flag,
groups[group_name],
group_key,
focused_group_properties,
self.feature_flags_by_key,
evaluation_cache,
cohort_properties=self.cohorts,
flags_by_key=self.feature_flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=group_key,
)
else:
bucketing_value = resolve_bucketing_value(
feature_flag, distinct_id, device_id
)
return match_feature_flag_properties(
feature_flag,
distinct_id,
person_properties,
self.cohorts,
self.feature_flags_by_key,
evaluation_cache,
cohort_properties=self.cohorts,
flags_by_key=self.feature_flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=bucketing_value,
)
def feature_enabled(
@@ -1573,8 +1595,12 @@ class Client(object):
evaluated_at = None
feature_flag_error: Optional[str] = None
# Resolve device_id from context if not provided
if device_id is None:
device_id = get_context_device_id()
flag_value = self._locally_evaluate_flag(
key, distinct_id, groups, person_properties, group_properties
key, distinct_id, groups, person_properties, group_properties, device_id
)
flag_was_locally_evaluated = flag_value is not None
@@ -1778,6 +1804,7 @@ class Client(object):
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
device_id: Optional[str] = None,
) -> Optional[FlagValue]:
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
@@ -1797,6 +1824,7 @@ class Client(object):
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
device_id=device_id,
)
self.log.debug(
f"Successfully computed flag locally: {key} -> {response}"
@@ -1921,10 +1949,12 @@ class Client(object):
f"{key}_{'::null::' if response is None else str(response)}"
)
if (
feature_flag_reported_key
not in self.distinct_ids_feature_flags_reported[distinct_id]
):
reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id)
if reported_flags is None:
reported_flags = set()
self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags
if feature_flag_reported_key not in reported_flags:
properties: dict[str, Any] = {
"$feature_flag": key,
"$feature_flag_response": response,
@@ -1960,9 +1990,7 @@ class Client(object):
groups=groups,
disable_geoip=disable_geoip,
)
self.distinct_ids_feature_flags_reported[distinct_id].add(
feature_flag_reported_key
)
reported_flags.add(feature_flag_reported_key)
def get_remote_config_payload(self, key: str):
if self.disabled:
@@ -2099,12 +2127,17 @@ class Client(object):
)
)
# Resolve device_id from context if not provided
if device_id is None:
device_id = get_context_device_id()
response, fallback_to_flags = self._get_all_flags_and_payloads_locally(
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
flag_keys_to_evaluate=flag_keys_to_evaluate,
device_id=device_id,
)
if fallback_to_flags and not only_evaluate_locally:
@@ -2135,6 +2168,7 @@ class Client(object):
group_properties=None,
warn_on_unknown_groups=False,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> tuple[FlagsAndPayloads, bool]:
person_properties = person_properties or {}
group_properties = group_properties or {}
@@ -2164,6 +2198,7 @@ class Client(object):
person_properties=person_properties,
group_properties=group_properties,
warn_on_unknown_groups=warn_on_unknown_groups,
device_id=device_id,
)
matched_payload = self._compute_payload_locally(
flag["key"], flags[flag["key"]]
+29 -19
View File
@@ -3,8 +3,6 @@ import logging
import time
from threading import Thread
import backoff
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
@@ -128,29 +126,41 @@ class Consumer(Thread):
def request(self, batch):
"""Attempt to upload the batch and retry before raising an error"""
def fatal_exception(exc):
def is_retryable(exc):
if isinstance(exc, APIError):
# retry on server errors and client errors
# with 429 status code (rate limited),
# with 408 (request timeout) or 429 (rate limited),
# don't retry on other client errors
if exc.status == "N/A":
return False
return (400 <= exc.status < 500) and exc.status != 429
return not ((400 <= exc.status < 500) and exc.status not in (408, 429))
else:
# retry on all other errors (eg. network)
return False
return True
@backoff.on_exception(
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
)
def send_request():
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
last_exc = None
for attempt in range(self.retries + 1):
try:
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
return
except Exception as e:
last_exc = e
if not is_retryable(e):
raise
if attempt < self.retries:
# Respect Retry-After header if present, otherwise use exponential backoff
retry_after = getattr(e, "retry_after", None)
if retry_after and retry_after > 0:
time.sleep(retry_after)
else:
time.sleep(min(2**attempt, 30))
send_request()
if last_exc:
raise last_exc
+81 -27
View File
@@ -43,26 +43,31 @@ except ImportError:
DEFAULT_MAX_VALUE_LENGTH = 1024
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
r"(?i).*password.*",
r"(?i).*secret.*",
r"(?i).*passwd.*",
r"(?i).*pwd.*",
r"(?i).*api_key.*",
r"(?i).*apikey.*",
r"(?i).*auth.*",
r"(?i).*credentials.*",
r"(?i).*privatekey.*",
r"(?i).*private_key.*",
r"(?i).*token.*",
r"(?i).*aws_access_key_id.*",
r"(?i).*_pass",
r"(?i)sk_.*",
r"(?i).*jwt.*",
r"(?i)password",
r"(?i)secret",
r"(?i)passwd",
r"(?i)pwd",
r"(?i)api_key",
r"(?i)apikey",
r"(?i)auth",
r"(?i)credentials",
r"(?i)privatekey",
r"(?i)private_key",
r"(?i)token",
r"(?i)aws_access_key_id",
r"(?i)_pass",
r"(?i)sk_",
r"(?i)jwt",
]
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
CODE_VARIABLES_TOO_LONG_VALUE = "$$_posthog_value_too_long_$$"
_MAX_VALUE_LENGTH_FOR_PATTERN_MATCH = 5_000
_MAX_COLLECTION_ITEMS_TO_SCAN = 100
_REGEX_METACHARACTERS = frozenset(r"\.^$*+?{}[]|()")
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
@@ -928,40 +933,87 @@ def strip_string(value, max_length=None):
)
def _extract_plain_substring(pattern):
# Matches inline flag groups like (?i), (?ai), (?ims), etc. that include the 'i' flag.
# Python regex flags: a=ASCII, i=IGNORECASE, L=LOCALE, m=MULTILINE, s=DOTALL, u=UNICODE, x=VERBOSE
inline_flags = re.match(r"^\(\?[aiLmsux]*i[aiLmsux]*\)", pattern)
if not inline_flags:
return None
remainder = pattern[inline_flags.end() :]
if not remainder or any(c in _REGEX_METACHARACTERS for c in remainder):
return None
return remainder.lower()
def _compile_patterns(patterns):
compiled = []
if not patterns:
return None
substrings = []
regexes = []
for pattern in patterns:
try:
compiled.append(re.compile(pattern))
except Exception:
pass
return compiled
simple = _extract_plain_substring(pattern)
if simple is not None:
substrings.append(simple)
else:
try:
regexes.append(re.compile(pattern))
except Exception:
pass
if not substrings and not regexes:
return None
return (substrings, regexes)
def _pattern_matches(name, patterns):
for pattern in patterns:
if patterns is None:
return False
substrings, regexes = patterns
if substrings:
name_lower = name.lower()
for s in substrings:
if s in name_lower:
return True
for pattern in regexes:
if pattern.search(name):
return True
return False
def _mask_sensitive_data(value, compiled_mask):
def _mask_sensitive_data(value, compiled_mask, _seen=None):
if not compiled_mask:
return value
if isinstance(value, (dict, list, tuple)):
if _seen is None:
_seen = set()
obj_id = id(value)
if obj_id in _seen:
return "<circular ref>"
_seen.add(obj_id)
if isinstance(value, dict):
if len(value) > _MAX_COLLECTION_ITEMS_TO_SCAN:
return CODE_VARIABLES_TOO_LONG_VALUE
result = {}
for k, v in value.items():
key_str = str(k) if not isinstance(k, str) else k
if _pattern_matches(key_str, compiled_mask):
if len(key_str) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
result[k] = CODE_VARIABLES_TOO_LONG_VALUE
elif _pattern_matches(key_str, compiled_mask):
result[k] = CODE_VARIABLES_REDACTED_VALUE
else:
result[k] = _mask_sensitive_data(v, compiled_mask)
result[k] = _mask_sensitive_data(v, compiled_mask, _seen)
return result
elif isinstance(value, (list, tuple)):
masked_items = [_mask_sensitive_data(item, compiled_mask) for item in value]
if len(value) > _MAX_COLLECTION_ITEMS_TO_SCAN:
return CODE_VARIABLES_TOO_LONG_VALUE
masked_items = [
_mask_sensitive_data(item, compiled_mask, _seen) for item in value
]
return type(value)(masked_items)
elif isinstance(value, str):
if len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
return CODE_VARIABLES_TOO_LONG_VALUE
if _pattern_matches(value, compiled_mask):
return CODE_VARIABLES_REDACTED_VALUE
return value
@@ -982,7 +1034,9 @@ def _serialize_variable_value(value, limiter, max_length=1024, compiled_mask=Non
limiter.add(result_size)
return value
elif isinstance(value, str):
if compiled_mask and _pattern_matches(value, compiled_mask):
if len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
result = CODE_VARIABLES_TOO_LONG_VALUE
elif compiled_mask and _pattern_matches(value, compiled_mask):
result = CODE_VARIABLES_REDACTED_VALUE
else:
result = value
+87 -19
View File
@@ -2,6 +2,7 @@ import datetime
import hashlib
import logging
import re
import warnings
from typing import Optional
from dateutil import parser
@@ -34,18 +35,18 @@ class RequiresServerEvaluation(Exception):
pass
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# This function takes a bucketing value and a feature flag key and returns a float between 0 and 1.
# Given the same bucketing value and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
# we can do _hash(key, distinct_id) < 0.2
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
hash_key = f"{key}.{distinct_id}{salt}"
# we can do _hash(key, bucketing_value) < 0.2
def _hash(key: str, bucketing_value: str, salt: str = "") -> float:
hash_key = f"{key}.{bucketing_value}{salt}"
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
def get_matching_variant(flag, distinct_id):
hash_value = _hash(flag["key"], distinct_id, salt="variant")
def get_matching_variant(flag, bucketing_value):
hash_value = _hash(flag["key"], bucketing_value, salt="variant")
for variant in variant_lookup_table(flag):
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
return variant["key"]
@@ -68,7 +69,13 @@ def variant_lookup_table(feature_flag):
def evaluate_flag_dependency(
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
property,
flags_by_key,
evaluation_cache,
distinct_id,
properties,
cohort_properties,
device_id=None,
):
"""
Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -80,6 +87,7 @@ def evaluate_flag_dependency(
distinct_id: The distinct ID being evaluated
properties: Person properties for evaluation
cohort_properties: Cohort properties for evaluation
device_id: The device ID for bucketing (optional)
Returns:
bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -124,13 +132,27 @@ def evaluate_flag_dependency(
else:
# Recursively evaluate the dependency
try:
dep_flag_filters = dep_flag.get("filters") or {}
dep_aggregation_group_type_index = dep_flag_filters.get(
"aggregation_group_type_index"
)
if dep_aggregation_group_type_index is not None:
# Group flags should continue bucketing by the group key
# from the current evaluation context.
dep_bucketing_value = distinct_id
else:
dep_bucketing_value = resolve_bucketing_value(
dep_flag, distinct_id, device_id
)
dep_result = match_feature_flag_properties(
dep_flag,
distinct_id,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
cohort_properties=cohort_properties,
flags_by_key=flags_by_key,
evaluation_cache=evaluation_cache,
device_id=device_id,
bucketing_value=dep_bucketing_value,
)
evaluation_cache[dep_flag_key] = dep_result
except InconclusiveMatchError as e:
@@ -215,21 +237,54 @@ def matches_dependency_value(expected_value, actual_value):
return False
def resolve_bucketing_value(flag, distinct_id, device_id=None):
"""Resolve the bucketing value for a flag based on its bucketing_identifier setting.
Returns:
The appropriate identifier string to use for hashing/bucketing.
Raises:
InconclusiveMatchError: If the flag requires device_id but none was provided.
"""
flag_filters = flag.get("filters") or {}
bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get(
"bucketing_identifier"
)
if bucketing_identifier == "device_id":
if not device_id:
raise InconclusiveMatchError(
"Flag requires device_id for bucketing but none was provided"
)
return device_id
return distinct_id
def match_feature_flag_properties(
flag,
distinct_id,
properties,
*,
cohort_properties=None,
flags_by_key=None,
evaluation_cache=None,
device_id=None,
bucketing_value=None,
) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
if bucketing_value is None:
warnings.warn(
"Calling match_feature_flag_properties() without bucketing_value is deprecated. "
"Pass bucketing_value explicitly. This fallback will be removed in a future major release.",
DeprecationWarning,
stacklevel=2,
)
bucketing_value = resolve_bucketing_value(flag, distinct_id, device_id)
flag_filters = flag.get("filters") or {}
flag_conditions = flag_filters.get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# Some filters can be explicitly set to null, which require accessing variants like so
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get(
"variants"
) or []
flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
for condition in flag_conditions:
@@ -244,12 +299,14 @@ def match_feature_flag_properties(
cohort_properties,
flags_by_key,
evaluation_cache,
bucketing_value=bucketing_value,
device_id=device_id,
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
variant = variant_override
else:
variant = get_matching_variant(flag, distinct_id)
variant = get_matching_variant(flag, bucketing_value)
return variant or True
except RequiresServerEvaluation:
# Static cohort or other missing server-side data - must fallback to API
@@ -277,6 +334,9 @@ def is_condition_match(
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
*,
bucketing_value,
device_id=None,
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
@@ -290,6 +350,7 @@ def is_condition_match(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif property_type == "flag":
matches = evaluate_flag_dependency(
@@ -299,6 +360,7 @@ def is_condition_match(
distinct_id,
properties,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, properties)
@@ -308,9 +370,9 @@ def is_condition_match(
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
rollout_percentage / 100
):
if rollout_percentage is not None and _hash(
feature_flag["key"], bucketing_value
) > (rollout_percentage / 100):
return False
return True
@@ -454,6 +516,7 @@ def match_cohort(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
# Cohort properties are in the form of property groups like this:
# {
@@ -478,6 +541,7 @@ def match_cohort(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
@@ -488,6 +552,7 @@ def match_property_group(
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
device_id=None,
) -> bool:
if not property_group:
return True
@@ -512,6 +577,7 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
if property_group_type == "AND":
if not matches:
@@ -545,6 +611,7 @@ def match_property_group(
flags_by_key,
evaluation_cache,
distinct_id,
device_id=device_id,
)
elif prop.get("type") == "flag":
matches = evaluate_flag_dependency(
@@ -554,6 +621,7 @@ def match_property_group(
distinct_id,
property_values,
cohort_properties,
device_id=device_id,
)
else:
matches = match_property(prop, property_values)
+26 -4
View File
@@ -3,7 +3,7 @@ import logging
import re
import socket
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date, datetime, timezone
from gzip import GzipFile
from io import BytesIO
from typing import Any, List, Optional, Tuple, Union
@@ -235,12 +235,31 @@ def _process_response(
)
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
return response
retry_after = None
retry_after_header = res.headers.get("Retry-After")
if retry_after_header:
try:
retry_after = float(retry_after_header)
except (ValueError, TypeError):
try:
from email.utils import parsedate_to_datetime
retry_after = max(
0.0,
(
parsedate_to_datetime(retry_after_header)
- datetime.now(timezone.utc)
).total_seconds(),
)
except (ValueError, TypeError):
pass
try:
payload = res.json()
log.debug("received response: %s", payload)
raise APIError(res.status_code, payload["detail"])
raise APIError(res.status_code, payload["detail"], retry_after=retry_after)
except (KeyError, ValueError):
raise APIError(res.status_code, res.text)
raise APIError(res.status_code, res.text, retry_after=retry_after)
def decide(
@@ -348,9 +367,12 @@ def get(
class APIError(Exception):
def __init__(self, status: Union[int, str], message: str):
def __init__(
self, status: Union[int, str], message: str, retry_after: Optional[float] = None
):
self.message = message
self.status = status
self.retry_after = retry_after
def __str__(self):
msg = "[PostHog] {0} ({1})"
@@ -1,3 +1,4 @@
import json
from unittest.mock import patch
import pytest
@@ -306,6 +307,15 @@ def test_basic_completion(mock_client, mock_anthropic_response):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_groups(mock_client, mock_anthropic_response):
@@ -918,6 +928,16 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
assert props["$ai_cache_read_input_tokens"] == 5
assert props["$ai_cache_creation_input_tokens"] == 0
# Verify raw usage is captured in streaming mode (merged from events)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from message_start and message_delta)
assert isinstance(props["$ai_usage"], dict)
assert "input_tokens" in props["$ai_usage"]
assert "output_tokens" in props["$ai_usage"]
def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
"""Test that tool calls are properly captured in async streaming mode."""
+55
View File
@@ -1,3 +1,4 @@
import json
from unittest.mock import MagicMock, patch
import pytest
@@ -34,6 +35,13 @@ def mock_gemini_response():
# Ensure cache and reasoning tokens are not present (not MagicMock)
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 20,
"candidates_token_count": 10,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
@@ -69,6 +77,13 @@ def mock_gemini_response_with_function_calls():
mock_usage.candidates_token_count = 15
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 25,
"candidates_token_count": 15,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -117,6 +132,13 @@ def mock_gemini_response_function_calls_only():
mock_usage.candidates_token_count = 12
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
mock_usage.model_dump.return_value = {
"prompt_token_count": 30,
"candidates_token_count": 12,
"cached_content_token_count": 0,
"thoughts_token_count": 0,
}
mock_response.usage_metadata = mock_usage
# Mock function call
@@ -174,6 +196,15 @@ def test_new_client_basic_generation(
assert props["foo"] == "bar"
assert "$ai_trace_id" in props
assert props["$ai_latency"] > 0
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
def test_new_client_streaming_with_generate_content_stream(
@@ -810,6 +841,13 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk1_usage.candidates_token_count = 5
chunk1_usage.cached_content_token_count = 30 # Cache tokens
chunk1_usage.thoughts_token_count = 0
# Make model_dump() return a proper dict for serialization
chunk1_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 5,
"cached_content_token_count": 30,
"thoughts_token_count": 0,
}
chunk1.usage_metadata = chunk1_usage
chunk2 = MagicMock()
@@ -819,6 +857,13 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
chunk2_usage.candidates_token_count = 10
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
# Make model_dump() return a proper dict for serialization
chunk2_usage.model_dump.return_value = {
"prompt_token_count": 100,
"candidates_token_count": 10,
"cached_content_token_count": 30,
"thoughts_token_count": 5,
}
chunk2.usage_metadata = chunk2_usage
mock_stream = iter([chunk1, chunk2])
@@ -848,6 +893,16 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 5
# Verify raw usage is captured in streaming mode (merged from chunks)
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_token_count" in props["$ai_usage"]
assert "candidates_token_count" in props["$ai_usage"]
def test_web_search_grounding(mock_client, mock_google_genai_client):
"""Test web search detection via grounding_metadata."""
+20
View File
@@ -1,3 +1,4 @@
import json
import time
from unittest.mock import AsyncMock, patch
@@ -496,6 +497,15 @@ def test_basic_completion(mock_client, mock_openai_response):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify raw usage metadata is passed for backend processing
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
def test_embeddings(mock_client, mock_embedding_response):
@@ -922,6 +932,16 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# Verify raw usage is captured in streaming mode
assert "$ai_usage" in props
assert props["$ai_usage"] is not None
# Verify it's JSON-serializable
json.dumps(props["$ai_usage"])
# Verify it has expected structure (merged from chunks)
assert isinstance(props["$ai_usage"], dict)
assert "prompt_tokens" in props["$ai_usage"]
assert "completion_tokens" in props["$ai_usage"]
# test responses api
def test_responses_api(mock_client, mock_openai_response_with_responses_api):
+607
View File
@@ -0,0 +1,607 @@
import unittest
from unittest.mock import MagicMock, patch
from posthog.ai.prompts import Prompts
class MockResponse:
"""Mock HTTP response for testing."""
def __init__(self, json_data=None, status_code=200, ok=True):
self._json_data = json_data
self.status_code = status_code
self.ok = ok
def json(self):
if self._json_data is None:
raise ValueError("No JSON data")
return self._json_data
class TestPrompts(unittest.TestCase):
"""Tests for the Prompts class."""
mock_prompt_response = {
"id": 1,
"name": "test-prompt",
"prompt": "Hello, {{name}}! You are a helpful assistant for {{company}}.",
"version": 1,
"created_by": "user@example.com",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"deleted": False,
}
def create_mock_posthog(
self,
personal_api_key="phx_test_key",
project_api_key="phc_test_key",
host="https://us.posthog.com",
):
"""Create a mock PostHog client."""
mock = MagicMock()
mock.personal_api_key = personal_api_key
mock.api_key = project_api_key
mock.raw_host = host
return mock
class TestPromptsGet(TestPrompts):
"""Tests for the Prompts.get() method."""
@patch("posthog.ai.prompts._get_session")
def test_successfully_fetch_a_prompt(self, mock_get_session):
"""Should successfully fetch a prompt."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.get("test-prompt")
self.assertEqual(result, self.mock_prompt_response["prompt"])
mock_get.assert_called_once()
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key",
)
self.assertIn("Authorization", call_args[1]["headers"])
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_test_key"
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_return_cached_prompt_when_fresh(self, mock_time, mock_get_session):
"""Should return cached prompt when fresh (no API call)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=300)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1)
# Advance time by 60 seconds (still within TTL)
mock_time.return_value = 1060.0
# Second call - should use cache
result2 = prompts.get("test-prompt", cache_ttl_seconds=300)
self.assertEqual(result2, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1) # No additional fetch
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_refetch_when_cache_is_stale(self, mock_time, mock_get_session):
"""Should refetch when cache is stale."""
mock_get = mock_get_session.return_value.get
updated_prompt_response = {
**self.mock_prompt_response,
"prompt": "Updated prompt: Hello, {{name}}!",
}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=updated_prompt_response),
]
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1)
# Advance time past TTL
mock_time.return_value = 1061.0
# Second call - should refetch
result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result2, updated_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
@patch("posthog.ai.prompts.log")
def test_use_stale_cache_on_fetch_failure_with_warning(
self, mock_log, mock_time, mock_get_session
):
"""Should use stale cache on fetch failure with warning."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
Exception("Network error"),
]
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - populates cache
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result1, self.mock_prompt_response["prompt"])
# Advance time past TTL
mock_time.return_value = 1061.0
# Second call - should use stale cache
result2 = prompts.get("test-prompt", cache_ttl_seconds=60)
self.assertEqual(result2, self.mock_prompt_response["prompt"])
# Check warning was logged
mock_log.warning.assert_called()
warning_call = mock_log.warning.call_args
self.assertIn("using stale cache", warning_call[0][0])
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.log")
def test_use_fallback_when_no_cache_and_fetch_fails_with_warning(
self, mock_log, mock_get_session
):
"""Should use fallback when no cache and fetch fails with warning."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
fallback = "Default system prompt."
result = prompts.get("test-prompt", fallback=fallback)
self.assertEqual(result, fallback)
# Check warning was logged
mock_log.warning.assert_called()
warning_call = mock_log.warning.call_args
self.assertIn("using fallback", warning_call[0][0])
@patch("posthog.ai.prompts._get_session")
def test_throw_when_no_cache_no_fallback_and_fetch_fails(self, mock_get_session):
"""Should throw when no cache, no fallback, and fetch fails."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Network error", str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_handle_404_response(self, mock_get_session):
"""Should handle 404 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=404, ok=False)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("nonexistent-prompt")
self.assertIn('Prompt "nonexistent-prompt" not found', str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_handle_403_response(self, mock_get_session):
"""Should handle 403 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=403, ok=False)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("restricted-prompt")
self.assertIn(
'Access denied for prompt "restricted-prompt"', str(context.exception)
)
def test_throw_when_no_personal_api_key_configured(self):
"""Should throw when no personal_api_key is configured."""
posthog = self.create_mock_posthog(personal_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn(
"personal_api_key is required to fetch prompts", str(context.exception)
)
def test_throw_when_no_project_api_key_configured(self):
"""Should throw when no project_api_key is configured."""
posthog = self.create_mock_posthog(project_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn(
"project_api_key is required to fetch prompts", str(context.exception)
)
@patch("posthog.ai.prompts._get_session")
def test_throw_when_api_returns_invalid_response_format(self, mock_get_session):
"""Should throw when API returns invalid response format."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data={"invalid": "response"})
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Invalid response format", str(context.exception))
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_posthog_options(self, mock_get_session):
"""Should use custom host from PostHog options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog(host="https://eu.posthog.com")
prompts = Prompts(posthog)
prompts.get("test-prompt")
call_args = mock_get.call_args
self.assertTrue(
call_args[0][0].startswith(
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key"
),
f"Expected URL to start with 'https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key', got {call_args[0][0]}",
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_default_cache_ttl_5_minutes(self, mock_time, mock_get_session):
"""Should use default cache TTL (5 minutes) when not specified."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time by 4 minutes (within default 5-minute TTL)
mock_time.return_value = 1000.0 + (4 * 60)
# Second call - should use cache
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past 5-minute TTL
mock_time.return_value = 1000.0 + (6 * 60)
# Third call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_constructor(
self, mock_time, mock_get_session
):
"""Should use custom default cache TTL from constructor."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
posthog = self.create_mock_posthog()
prompts = Prompts(posthog, default_cache_ttl_seconds=60)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past custom TTL
mock_time.return_value = 1061.0
# Second call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("posthog.ai.prompts._get_session")
def test_url_encode_prompt_names_with_special_characters(self, mock_get_session):
"""Should URL-encode prompt names with special characters."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
prompts.get("prompt with spaces/and/slashes")
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key",
)
@patch("posthog.ai.prompts._get_session")
def test_work_with_direct_options_no_posthog_client(self, mock_get_session):
"""Should work with direct options (no PostHog client)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
prompts = Prompts(
personal_api_key="phx_direct_key", project_api_key="phc_direct_key"
)
result = prompts.get("test-prompt")
self.assertEqual(result, self.mock_prompt_response["prompt"])
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_direct_key"
)
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_direct_options(self, mock_get_session):
"""Should use custom host from direct options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
prompts = Prompts(
personal_api_key="phx_direct_key",
project_api_key="phc_direct_key",
host="https://eu.posthog.com",
)
prompts.get("test-prompt")
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_direct_options(
self, mock_time, mock_get_session
):
"""Should use custom default cache TTL from direct options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
prompts = Prompts(
personal_api_key="phx_direct_key",
project_api_key="phc_direct_key",
default_cache_ttl_seconds=60,
)
# First call
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 1)
# Advance time past custom TTL
mock_time.return_value = 1061.0
# Second call - should refetch
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
class TestPromptsCompile(TestPrompts):
"""Tests for the Prompts.compile() method."""
def test_replace_a_single_variable(self):
"""Should replace a single variable."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
self.assertEqual(result, "Hello, World!")
def test_replace_multiple_variables(self):
"""Should replace multiple variables."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Welcome to {{company}}. Your tier is {{tier}}.",
{"name": "John", "company": "Acme Corp", "tier": "premium"},
)
self.assertEqual(
result, "Hello, John! Welcome to Acme Corp. Your tier is premium."
)
def test_handle_numbers(self):
"""Should handle numbers."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You have {{count}} items.", {"count": 42})
self.assertEqual(result, "You have 42 items.")
def test_handle_booleans(self):
"""Should handle booleans."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Feature enabled: {{enabled}}", {"enabled": True})
self.assertEqual(result, "Feature enabled: True")
def test_leave_unmatched_variables_unchanged(self):
"""Should leave unmatched variables unchanged."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Your {{unknown}} is ready.", {"name": "World"}
)
self.assertEqual(result, "Hello, World! Your {{unknown}} is ready.")
def test_handle_prompts_with_no_variables(self):
"""Should handle prompts with no variables."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You are a helpful assistant.", {})
self.assertEqual(result, "You are a helpful assistant.")
def test_handle_empty_variables_dict(self):
"""Should handle empty variables dict."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {})
self.assertEqual(result, "Hello, {{name}}!")
def test_handle_multiple_occurrences_of_same_variable(self):
"""Should handle multiple occurrences of the same variable."""
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Goodbye, {{name}}!", {"name": "World"}
)
self.assertEqual(result, "Hello, World! Goodbye, World!")
def test_work_with_direct_options_initialization(self):
"""Should work with direct options initialization."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
self.assertEqual(result, "Hello, World!")
def test_handle_variables_with_hyphens(self):
"""Should handle variables with hyphens."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("User ID: {{user-id}}", {"user-id": "12345"})
self.assertEqual(result, "User ID: 12345")
def test_handle_variables_with_dots(self):
"""Should handle variables with dots."""
prompts = Prompts(
personal_api_key="phx_test_key", project_api_key="phc_test_key"
)
result = prompts.compile("Company: {{company.name}}", {"company.name": "Acme"})
self.assertEqual(result, "Company: Acme")
class TestPromptsClearCache(TestPrompts):
"""Tests for the Prompts.clear_cache() method."""
@patch("posthog.ai.prompts._get_session")
def test_clear_a_specific_prompt_from_cache(self, mock_get_session):
"""Should clear a specific prompt from cache."""
mock_get = mock_get_session.return_value.get
other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
MockResponse(json_data=self.mock_prompt_response),
]
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 2)
# Clear only test-prompt
prompts.clear_cache("test-prompt")
# test-prompt should be refetched
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 3)
# other-prompt should still be cached
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 3)
@patch("posthog.ai.prompts._get_session")
def test_clear_all_prompts_from_cache(self, mock_get_session):
"""Should clear all prompts from cache when no name is provided."""
mock_get = mock_get_session.return_value.get
other_prompt_response = {**self.mock_prompt_response, "name": "other-prompt"}
mock_get.side_effect = [
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
MockResponse(json_data=self.mock_prompt_response),
MockResponse(json_data=other_prompt_response),
]
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 2)
# Clear all cache
prompts.clear_cache()
# Both prompts should be refetched
prompts.get("test-prompt")
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 4)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -69,6 +69,25 @@ class TestSanitization(unittest.TestCase):
)
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
def test_sanitize_openai_input_image(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": self.sample_base64_image,
}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_openai_preserves_regular_urls(self):
input_data = [
{
+14
View File
@@ -479,6 +479,20 @@ class TestClient(unittest.TestCase):
self.assertEqual(client.cohorts, {})
self.assertIn("PostHog feature flags quota limited", logs.output[0])
@mock.patch("posthog.client.get")
def test_load_feature_flags_unauthorized(self, patch_get):
patch_get.side_effect = APIError(401, "Unauthorized")
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
with self.assertLogs("posthog", level="ERROR") as logs:
client._load_feature_flags()
self.assertEqual(client.feature_flags, [])
self.assertEqual(client.feature_flags_by_key, {})
self.assertEqual(client.group_type_mapping, {})
self.assertEqual(client.cohorts, {})
self.assertIn("please set a valid personal_api_key", logs.output[0])
@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
+57
View File
@@ -167,6 +167,63 @@ class TestConsumer(unittest.TestCase):
q.join()
self.assertEqual(mock_post.call_count, 2)
def test_request_sleeps_with_retry_after(self) -> None:
error = APIError(429, "Too Many Requests", retry_after=5.0)
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 1:
raise error
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
mock_sleep.assert_called_once_with(5.0)
def test_request_uses_exponential_backoff_without_retry_after(self) -> None:
error = APIError(503, "Service Unavailable")
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 3:
raise error
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
self.assertEqual(
mock_sleep.call_args_list,
[
mock.call(1), # 2^0
mock.call(2), # 2^1
mock.call(4), # 2^2
],
)
def test_request_retries_on_408(self) -> None:
call_count = [0]
def mock_post(*args: Any, **kwargs: Any) -> None:
call_count[0] += 1
if call_count[0] <= 1:
raise APIError(408, "Request Timeout")
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep"),
):
consumer.request([_track_event()])
self.assertEqual(call_count[0], 2)
@parameterized.expand(
[
("on_error_succeeds", False),
+237
View File
@@ -450,3 +450,240 @@ def test_code_variables_repr_fallback(tmpdir):
assert "<CustomReprClass: custom representation>" in output
assert "<lambda>" in output
assert "<function trigger_error at" in output
def test_code_variables_too_long_string_value_replaced(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
short_value = "I am short"
long_value = "x" * 20000
long_blob = "password_" + "a" * 20000
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
assert "'short_value': 'I am short'" in output
assert "$$_posthog_value_too_long_$$" in output
assert "'long_blob': '$$_posthog_value_too_long_$$'" in output
def test_code_variables_too_long_string_in_nested_dict(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_data = {
"short_key": "short_val",
"long_key": "y" * 20000,
"nested": {
"deep_long": "z" * 20000,
"deep_short": "ok",
},
}
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
assert "short_val" in output
assert "ok" in output
assert "$$_posthog_value_too_long_$$" in output
assert "y" * 1000 not in output
assert "z" * 1000 not in output
def test_mask_sensitive_data_too_long_dict_key():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
result = _mask_sensitive_data(
{
"short": "visible",
"k" * 20000: "hidden_val",
"password": "secret",
},
compiled_mask,
)
assert result["short"] == "visible"
# This then gets shortened by the JSON truncation at 1024 chars anyways so no worries
assert result["k" * 20000] == CODE_VARIABLES_TOO_LONG_VALUE
assert result["password"] == "$$_posthog_redacted_based_on_masking_rules_$$"
def test_mask_sensitive_data_circular_ref():
from posthog.exception_utils import _compile_patterns, _mask_sensitive_data
compiled_mask = _compile_patterns([r"(?i)password"])
# Circular dict
circular_dict = {"key": "value"}
circular_dict["self"] = circular_dict
result = _mask_sensitive_data(circular_dict, compiled_mask)
assert result["key"] == "value"
assert result["self"] == "<circular ref>"
# Circular list
circular_list = ["item"]
circular_list.append(circular_list)
result = _mask_sensitive_data(circular_list, compiled_mask)
assert result[0] == "item"
assert result[1] == "<circular ref>"
def test_compile_patterns_fast_path_and_regex_fallback():
from posthog.exception_utils import _compile_patterns, _pattern_matches
# Simple case-insensitive patterns should become substrings
simple_only = _compile_patterns([r"(?i)password", r"(?i)token", r"(?i)jwt"])
substrings, regexes = simple_only
assert substrings == ["password", "token", "jwt"]
assert regexes == []
assert _pattern_matches("my_password_var", simple_only) is True
assert _pattern_matches("MY_TOKEN", simple_only) is True
assert _pattern_matches("safe_variable", simple_only) is False
# Complex regex patterns should stay as compiled regexes
complex_only = _compile_patterns([r"^__.*", r"\d{3,}", r"^sk_live_"])
substrings, regexes = complex_only
assert substrings == []
assert len(regexes) == 3
assert _pattern_matches("__dunder", complex_only) is True
assert _pattern_matches("has_999_numbers", complex_only) is True
assert _pattern_matches("sk_live_abc123", complex_only) is True
assert _pattern_matches("normal_var", complex_only) is False
# Mixed: simple substrings + complex regexes together
mixed = _compile_patterns(
[
r"(?i)secret", # simple
r"(?i)api_key", # simple
r"^__.*", # regex
r"\btoken_\w+", # regex
]
)
substrings, regexes = mixed
assert substrings == ["secret", "api_key"]
assert len(regexes) == 2
# Substring matches
assert _pattern_matches("my_secret", mixed) is True
assert _pattern_matches("API_KEY_VALUE", mixed) is True
# Regex matches
assert _pattern_matches("__private", mixed) is True
assert _pattern_matches("token_abc", mixed) is True
# No match
assert _pattern_matches("safe_var", mixed) is False
def test_mask_sensitive_data_large_dict_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_dict = {f"key_{i}": f"value_{i}" for i in range(300)}
result = _mask_sensitive_data(large_dict, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
def test_mask_sensitive_data_large_list_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_list = [f"item_{i}" for i in range(300)]
result = _mask_sensitive_data(large_list, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
def test_mask_sensitive_data_large_tuple_replaced():
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
)
compiled_mask = _compile_patterns([r"(?i)password"])
large_tuple = tuple(f"item_{i}" for i in range(300))
result = _mask_sensitive_data(large_tuple, compiled_mask)
assert result == CODE_VARIABLES_TOO_LONG_VALUE
+563 -2
View File
@@ -233,6 +233,27 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
def test_group_flag_is_inconclusive_when_group_properties_missing(self):
feature_flag = {
"id": 1,
"name": "Group Flag Without Property Filters",
"key": "group-flag-no-props",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [{"properties": [], "rollout_percentage": 100}],
},
}
self.client.group_type_mapping = {"0": "company"}
with self.assertRaises(InconclusiveMatchError):
self.client._compute_flag_locally(
feature_flag,
"some-distinct-id",
groups={"company": "acme"},
group_properties={},
)
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_with_complex_definition(self, patch_get, patch_flags):
@@ -2513,7 +2534,10 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertIsNone(client._flags_etag)
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
def test_load_feature_flags_wrong_key(self):
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
patch_get.side_effect = APIError(401, "Unauthorized")
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
with self.assertLogs("posthog", level="ERROR") as logs:
@@ -3220,6 +3244,541 @@ class TestLocalEvaluation(unittest.TestCase):
# Verify API was called (fallback occurred)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags):
"""
When a flag has bucketing_identifier: "device_id", the device_id should be
used for hashing instead of distinct_id.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# This flag uses device_id for bucketing
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# Same distinct_id with different device_ids should produce different results
# (based on rollout percentage, we check consistency)
result1 = client.get_feature_flag(
"device-bucketed-flag", "user-123", device_id="device-A"
)
result2 = client.get_feature_flag(
"device-bucketed-flag", "user-123", device_id="device-A"
)
# Same device_id should give consistent results
self.assertEqual(result1, result2)
# No API fallback should occur
self.assertEqual(patch_flags.call_count, 0)
def test_match_feature_flag_properties_without_bucketing_value_is_deprecated(
self,
):
"""
match_feature_flag_properties should preserve backward compatibility when
bucketing_value is omitted, while warning about deprecation.
"""
from posthog.feature_flags import match_feature_flag_properties
flag = {
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
with self.assertWarnsRegex(
DeprecationWarning, "without bucketing_value is deprecated"
):
result = match_feature_flag_properties(
flag,
"user-123",
{},
device_id="device-123",
)
self.assertTrue(result)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_same_device_different_users_same_result(
self, patch_flags
):
"""
When a flag uses device_id bucketing, different distinct_ids with the same
device_id should get the same result.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 50,
}
],
},
}
]
# Different distinct_ids with the same device_id should get the same result
result1 = client.get_feature_flag(
"device-bucketed-flag", "user-A", device_id="shared-device"
)
result2 = client.get_feature_flag(
"device-bucketed-flag", "user-B", device_id="shared-device"
)
result3 = client.get_feature_flag(
"device-bucketed-flag", "user-C", device_id="shared-device"
)
# All should be the same since device_id is the same
self.assertEqual(result1, result2)
self.assertEqual(result2, result3)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags):
"""
When a flag requires device_id for bucketing but none is provided,
it should fallback to server evaluation.
"""
patch_flags.return_value = {"featureFlags": {"device-bucketed-flag": True}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# No device_id provided - should fallback to API
result = client.get_feature_flag("device-bucketed-flag", "user-123")
self.assertTrue(result)
# API should have been called
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id(
self, patch_flags
):
"""
When only_evaluate_locally=True and device_id is required but missing,
should return None instead of falling back to API.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# No device_id + only_evaluate_locally should return None
result = client.get_feature_flag(
"device-bucketed-flag", "user-123", only_evaluate_locally=True
)
self.assertIsNone(result)
# API should NOT have been called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags):
"""
When bucketing_identifier is not set or is 'distinct_id', should use
distinct_id for hashing (default behavior).
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# Flag without bucketing_identifier (defaults to distinct_id)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 50,
}
],
},
}
]
# Different distinct_ids should potentially produce different results
# but same distinct_id should produce same result
result1 = client.get_feature_flag("normal-flag", "user-A")
result2 = client.get_feature_flag("normal-flag", "user-A")
self.assertEqual(result1, result2)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_with_multivariate_flag(self, patch_flags):
"""
Multivariate flag variant selection should use device_id when
bucketing_identifier is set to device_id.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "multivariate-device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
"multivariate": {
"variants": [
{"key": "control", "rollout_percentage": 50},
{"key": "test", "rollout_percentage": 50},
]
},
},
}
]
# Same device_id should give same variant
result1 = client.get_feature_flag(
"multivariate-device-flag", "user-A", device_id="device-1"
)
result2 = client.get_feature_flag(
"multivariate-device-flag", "user-B", device_id="device-1"
)
# Both should get the same variant because device_id is the same
self.assertEqual(result1, result2)
self.assertIn(result1, ["control", "test"])
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_from_context(self, patch_flags):
"""
When device_id is not passed as a parameter but is set in the context,
it should be resolved from context.
"""
from posthog.contexts import new_context, set_context_device_id
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-bucketed-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
# Set device_id in context
with new_context():
set_context_device_id("context-device-id")
result = client.get_feature_flag("device-bucketed-flag", "user-123")
# Should evaluate locally using the context device_id
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flags_ignore_bucketing_identifier(self, patch_flags):
"""
Group flags should continue to use the group identifier for hashing,
regardless of the bucketing_identifier setting.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"bucketing_identifier": "device_id", # Should be ignored for group flags
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
}
]
client.group_type_mapping = {"0": "company"}
# Even with bucketing_identifier set to device_id, group flag should use group identifier
result = client.get_feature_flag(
"group-flag",
"user-123",
groups={"company": "acme-inc"},
device_id="some-device",
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_receives_device_id(self, patch_flags):
"""
Group flag dependency evaluation should receive device_id so dependent
device_id-bucketed flags can be evaluated locally.
"""
patch_flags.return_value = {"featureFlags": {"group-parent-flag": "from-api"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "device-dependent-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
},
{
"id": 2,
"key": "group-parent-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [
{
"properties": [
{
"key": "device-dependent-flag",
"operator": "flag_evaluates_to",
"value": True,
"type": "flag",
"dependency_chain": ["device-dependent-flag"],
}
],
"rollout_percentage": 100,
}
],
},
},
]
client.group_type_mapping = {"0": "company"}
result = client.get_feature_flag(
"group-parent-flag",
"user-123",
groups={"company": "acme-inc"},
device_id="device-123",
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_ignores_device_id_bucketing_identifier(
self, patch_flags
):
"""
Group flag dependencies should keep bucketing by group key, even when
the dependent group flag has bucketing_identifier set to device_id.
"""
patch_flags.return_value = {"featureFlags": {"parent-group-flag": "from-api"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "child-group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"bucketing_identifier": "device_id",
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
],
},
},
{
"id": 2,
"key": "parent-group-flag",
"active": True,
"filters": {
"aggregation_group_type_index": 0,
"groups": [
{
"properties": [
{
"key": "child-group-flag",
"operator": "flag_evaluates_to",
"value": True,
"type": "flag",
"dependency_chain": ["child-group-flag"],
}
],
"rollout_percentage": 100,
}
],
},
},
]
client.group_type_mapping = {"0": "company"}
result = client.get_feature_flag(
"parent-group-flag",
"user-123",
groups={"company": "acme-inc"},
)
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_device_id_bucketing(self, patch_flags):
"""
get_all_flags_and_payloads should properly handle flags with device_id bucketing.
"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
{
"id": 2,
"key": "device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
]
# With device_id provided, both flags should be evaluated locally
result = client.get_all_flags("user-123", device_id="my-device")
self.assertEqual(result["normal-flag"], True)
self.assertEqual(result["device-flag"], True)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
self, patch_flags
):
"""
When some flags require device_id but it's not provided, those flags
should trigger fallback while others can be evaluated locally.
"""
patch_flags.return_value = {
"featureFlags": {"normal-flag": True, "device-flag": "from-api"}
}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"key": "normal-flag",
"active": True,
"filters": {
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
{
"id": 2,
"key": "device-flag",
"active": True,
"filters": {
"bucketing_identifier": "device_id",
"groups": [{"properties": [], "rollout_percentage": 100}],
},
},
]
# Without device_id, device-flag can't be evaluated locally
client.get_all_flags("user-123")
# Should fallback to API for all flags when any can't be evaluated locally
self.assertEqual(patch_flags.call_count, 1)
class TestMatchProperties(unittest.TestCase):
def property(self, key, value, operator=None):
@@ -4266,13 +4825,15 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=False,
)
@mock.patch("posthog.client.MAX_DICT_SIZE", 100)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_capture_multiple_users_doesnt_out_of_memory(
self, patch_flags, patch_capture
):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
# Set on the instance to avoid relying on module-constant patching behavior
# across Python/runtime implementations.
client.distinct_ids_feature_flags_reported.max_size = 100
client.feature_flags = [
{
"id": 1,
+1 -4
View File
@@ -1,4 +1 @@
VERSION = "7.7.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
VERSION = "7.9.2"
+1 -4
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "posthog"
dynamic = ["version"]
version = "7.9.2"
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
@@ -93,9 +93,6 @@ packages = [
"posthog.integrations",
]
[tool.setuptools.dynamic]
version = { attr = "posthog.version.VERSION" }
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Copy the SDK source code
COPY posthog/ /app/sdk/posthog/
COPY setup.py pyproject.toml README.md LICENSE /app/sdk/
# Install the SDK from source
RUN cd /app/sdk && pip install --no-cache-dir -e .
# Install adapter dependencies
RUN pip install --no-cache-dir flask python-dateutil
# Copy adapter code
COPY sdk_compliance_adapter/adapter.py /app/adapter.py
# Expose port 8080
EXPOSE 8080
# Run the adapter
CMD ["python", "/app/adapter.py"]
+76
View File
@@ -0,0 +1,76 @@
# PostHog Python SDK Test Adapter
This adapter wraps the posthog-python SDK for compliance testing with the [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness).
## What is This?
This is a simple Flask app that:
1. Wraps the posthog-python SDK
2. Exposes a REST API for the test harness to control
3. Tracks internal SDK state for test assertions
## Running Tests
Tests run automatically in CI via GitHub Actions. See the test harness repo for details.
### Locally with Docker Compose
```bash
# From the posthog-python/sdk_compliance_adapter directory
docker-compose up --build --abort-on-container-exit
```
This will:
1. Build the Python SDK adapter
2. Pull the test harness image
3. Run all compliance tests
4. Show results
### Manually with Docker
```bash
# Create network
docker network create test-network
# Build and run adapter
docker build -f sdk_compliance_adapter/Dockerfile -t posthog-python-adapter .
docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-python-adapter
# Run test harness
docker run --rm \
--name test-harness \
--network test-network \
ghcr.io/posthog/sdk-test-harness:latest \
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081
# Cleanup
docker stop sdk-adapter && docker rm sdk-adapter
docker network rm test-network
```
## Adapter Implementation
See [adapter.py](adapter.py) for the implementation.
The adapter implements the standard SDK adapter interface defined in the [test harness CONTRACT](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/CONTRACT.yaml):
- `GET /health` - Return SDK information
- `POST /init` - Initialize SDK with config
- `POST /capture` - Capture an event
- `POST /flush` - Flush pending events
- `GET /state` - Return internal state
- `POST /reset` - Reset SDK state
### Key Implementation Details
**Request Tracking**: The adapter monkey-patches `batch_post` to track all HTTP requests made by the SDK, including retries.
**State Management**: Thread-safe state tracking for events captured vs sent, retry attempts, and errors.
**UUID Tracking**: Extracts and tracks UUIDs from batches to verify deduplication.
## Documentation
For complete documentation on the test harness and how to implement adapters, see:
- [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness)
- [Adapter Implementation Guide](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/ADAPTER_GUIDE.md)
+383
View File
@@ -0,0 +1,383 @@
"""
PostHog Python SDK Test Adapter
This adapter implements the SDK Test Adapter Interface defined in the PostHog Capture API Contract.
It wraps the posthog-python SDK and exposes a REST API for the test harness to exercise.
"""
import logging
import os
import threading
import time
from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request
from posthog import Client
from posthog.request import batch_post as original_batch_post
from posthog.version import VERSION
# Configure logging
logging.basicConfig(
level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
class RequestInfo:
"""Information about an HTTP request made by the SDK"""
def __init__(
self,
timestamp_ms: int,
status_code: int,
retry_attempt: int,
event_count: int,
uuid_list: List[str],
):
self.timestamp_ms = timestamp_ms
self.status_code = status_code
self.retry_attempt = retry_attempt
self.event_count = event_count
self.uuid_list = uuid_list
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp_ms": self.timestamp_ms,
"status_code": self.status_code,
"retry_attempt": self.retry_attempt,
"event_count": self.event_count,
"uuid_list": self.uuid_list,
}
class SDKState:
"""Tracks SDK internal state for test assertions"""
def __init__(self):
self.lock = threading.Lock()
self.pending_events = 0
self.total_events_captured = 0
self.total_events_sent = 0
self.total_retries = 0
self.last_error: Optional[str] = None
self.requests_made: List[RequestInfo] = []
self.client: Optional[Client] = None
self.retry_attempts: Dict[str, int] = {} # Track retry attempts by batch ID
def reset(self):
"""Reset all state"""
with self.lock:
self.pending_events = 0
self.total_events_captured = 0
self.total_events_sent = 0
self.total_retries = 0
self.last_error = None
self.requests_made = []
self.retry_attempts = {}
if self.client:
# Flush and shutdown existing client
try:
self.client.shutdown()
except Exception as e:
logger.warning(f"Error shutting down client: {e}")
self.client = None
def increment_captured(self):
"""Increment total events captured"""
with self.lock:
self.total_events_captured += 1
self.pending_events += 1
def record_request(self, status_code: int, batch: List[Dict], batch_id: str):
"""Record an HTTP request made by the SDK"""
with self.lock:
# Determine retry attempt for this batch
retry_attempt = self.retry_attempts.get(batch_id, 0)
# Extract UUIDs from batch
uuid_list = [event.get("uuid", "") for event in batch]
request_info = RequestInfo(
timestamp_ms=int(time.time() * 1000),
status_code=status_code,
retry_attempt=retry_attempt,
event_count=len(batch),
uuid_list=uuid_list,
)
self.requests_made.append(request_info)
# Update counters
if status_code == 200:
# Success - clear pending events
self.total_events_sent += len(batch)
self.pending_events = max(0, self.pending_events - len(batch))
# Remove batch from retry tracking
self.retry_attempts.pop(batch_id, None)
else:
# Failure - increment retry count
self.retry_attempts[batch_id] = retry_attempt + 1
if retry_attempt > 0:
self.total_retries += 1
def record_error(self, error: str):
"""Record an error"""
with self.lock:
self.last_error = error
def get_state(self) -> Dict[str, Any]:
"""Get current state as dict"""
with self.lock:
return {
"pending_events": self.pending_events,
"total_events_captured": self.total_events_captured,
"total_events_sent": self.total_events_sent,
"total_retries": self.total_retries,
"last_error": self.last_error,
"requests_made": [r.to_dict() for r in self.requests_made],
}
# Global state
state = SDKState()
def create_batch_id(batch: List[Dict]) -> str:
"""Create a unique ID for a batch based on UUIDs"""
uuids = sorted([event.get("uuid", "") for event in batch])
return "-".join(uuids[:3]) # Use first 3 UUIDs as batch ID
def patched_batch_post(
api_key: str,
host: Optional[str] = None,
gzip: bool = False,
timeout: int = 15,
**kwargs,
):
"""Patched version of batch_post that tracks requests"""
batch = kwargs.get("batch", [])
batch_id = create_batch_id(batch)
try:
# Call original batch_post
response = original_batch_post(api_key, host, gzip, timeout, **kwargs)
# Record successful request
state.record_request(200, batch, batch_id)
return response
except Exception as e:
# Record failed request
status_code = (
getattr(e, "status_code", 500) if hasattr(e, "status_code") else 500
)
state.record_request(status_code, batch, batch_id)
state.record_error(str(e))
raise
# Monkey-patch the batch_post function
import posthog.request # noqa: E402
posthog.request.batch_post = patched_batch_post
# Also patch in consumer module
import posthog.consumer # noqa: E402
posthog.consumer.batch_post = patched_batch_post
@app.route("/health", methods=["GET"])
def health():
"""Health check endpoint"""
return jsonify(
{
"sdk_name": "posthog-python",
"sdk_version": VERSION,
"adapter_version": "1.0.0",
}
)
@app.route("/init", methods=["POST"])
def init():
"""Initialize the SDK client"""
try:
data = request.json or {}
# Reset state
state.reset()
# Extract config
api_key = data.get("api_key")
host = data.get("host")
flush_at = data.get("flush_at", 100)
flush_interval_ms = data.get("flush_interval_ms", 500)
max_retries = data.get("max_retries", 3)
enable_compression = data.get("enable_compression", False)
if not api_key:
return jsonify({"error": "api_key is required"}), 400
if not host:
return jsonify({"error": "host is required"}), 400
# Convert flush_interval from ms to seconds
flush_interval = flush_interval_ms / 1000.0
# Create client
client = Client(
project_api_key=api_key,
host=host,
flush_at=flush_at,
flush_interval=flush_interval,
gzip=enable_compression,
max_retries=max_retries,
debug=True,
)
state.client = client
logger.info(
f"Initialized SDK with api_key={api_key[:10]}..., host={host}, "
f"flush_at={flush_at}, flush_interval={flush_interval}, "
f"max_retries={max_retries}, gzip={enable_compression}"
)
return jsonify({"success": True})
except Exception as e:
logger.exception("Error initializing SDK")
return jsonify({"error": str(e)}), 500
@app.route("/capture", methods=["POST"])
def capture():
"""Capture a single event"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
data = request.json or {}
distinct_id = data.get("distinct_id")
event = data.get("event")
properties = data.get("properties")
timestamp = data.get("timestamp")
if not distinct_id:
return jsonify({"error": "distinct_id is required"}), 400
if not event:
return jsonify({"error": "event is required"}), 400
# Capture event
kwargs = {"distinct_id": distinct_id, "properties": properties}
if timestamp:
# Parse ISO8601 timestamp
from dateutil.parser import parse # type: ignore[import-untyped]
kwargs["timestamp"] = parse(timestamp)
uuid = state.client.capture(event, **kwargs)
# Track that we captured an event
state.increment_captured()
logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}")
return jsonify({"success": True, "uuid": uuid})
except Exception as e:
logger.exception("Error capturing event")
state.record_error(str(e))
return jsonify({"error": str(e)}), 500
@app.route("/identify", methods=["POST"])
def identify():
"""Identify a user"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
data = request.json or {}
distinct_id = data.get("distinct_id")
properties = data.get("properties")
properties_set_once = data.get("properties_set_once")
if not distinct_id:
return jsonify({"error": "distinct_id is required"}), 400
# Use the identify pattern - set + set_once
if properties:
state.client.set(distinct_id=distinct_id, properties=properties)
state.increment_captured()
if properties_set_once:
state.client.set_once(
distinct_id=distinct_id, properties=properties_set_once
)
state.increment_captured()
logger.info(f"Identified user: {distinct_id}")
return jsonify({"success": True})
except Exception as e:
logger.exception("Error identifying user")
state.record_error(str(e))
return jsonify({"error": str(e)}), 500
@app.route("/flush", methods=["POST"])
def flush():
"""Force flush all pending events"""
try:
if not state.client:
return jsonify({"error": "SDK not initialized"}), 400
# Flush and wait
state.client.flush()
# Wait a bit for flush to complete
# The flush() method triggers queue.join() which blocks until all items are processed
time.sleep(0.5)
logger.info("Flushed pending events")
return jsonify({"success": True, "events_flushed": state.total_events_sent})
except Exception as e:
logger.exception("Error flushing events")
state.record_error(str(e))
return jsonify({"error": str(e), "errors": [str(e)]}, 500)
@app.route("/state", methods=["GET"])
def get_state():
"""Get internal SDK state"""
try:
return jsonify(state.get_state())
except Exception as e:
logger.exception("Error getting state")
return jsonify({"error": str(e)}), 500
@app.route("/reset", methods=["POST"])
def reset():
"""Reset SDK state"""
try:
state.reset()
logger.info("Reset SDK state")
return jsonify({"success": True})
except Exception as e:
logger.exception("Error resetting state")
return jsonify({"error": str(e)}), 500
def main():
"""Main entry point"""
port = int(os.environ.get("PORT", 8080))
logger.info(f"Starting SDK Test Adapter on port {port}")
app.run(host="0.0.0.0", port=port, debug=False)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
version: "3.8"
services:
# PostHog Python SDK adapter
sdk-adapter:
build:
context: ..
dockerfile: sdk_compliance_adapter/Dockerfile
ports:
- "8080:8080"
networks:
- test-network
# Test harness
test-harness:
image: ghcr.io/posthog/sdk-test-harness:latest
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
networks:
- test-network
depends_on:
- sdk-adapter
networks:
test-network:
driver: bridge
+3
View File
@@ -0,0 +1,3 @@
# SDK Test Adapter dependencies
flask>=3.0.0
python-dateutil>=2.8.0
+1
View File
@@ -23,6 +23,7 @@ with open("pyproject.toml", "rb") as f:
# Override specific values
config["project"]["name"] = "posthoganalytics"
config["project"]["readme"] = "README_ANALYTICS.md"
config["tool"]["setuptools"]["dynamic"]["version"] = {
"attr": "posthoganalytics.version.VERSION"
}
Generated
+2 -24
View File
@@ -278,43 +278,31 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
@@ -540,10 +528,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
@@ -551,10 +537,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
@@ -562,10 +546,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
@@ -672,7 +654,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -863,7 +845,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" },
{ url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" },
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
@@ -874,7 +855,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" },
{ url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
@@ -885,7 +865,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
@@ -896,7 +875,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
@@ -907,7 +885,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
@@ -2152,6 +2129,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.9.2"
source = { editable = "." }
dependencies = [
{ name = "backoff" },