diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71f9a692..6f34154a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,63 @@ jobs: - name: Check import produces no warnings run: python -W error -c "import posthog" + openfeature-provider: + name: OpenFeature provider Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + + steps: + - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + with: + fetch-depth: 1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + # openfeature-provider-posthog is a uv workspace member (declared in + # the root pyproject), so commands are scoped to it with --package and + # builds are pinned to its own dist/ with --out-dir. + - name: Install provider dependencies + shell: bash + working-directory: openfeature-provider + run: | + UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --package openfeature-provider-posthog --extra dev + + - name: Run provider tests + working-directory: openfeature-provider + run: | + uv run --package openfeature-provider-posthog pytest --verbose + + - name: Lint and type-check provider + working-directory: openfeature-provider + run: | + uv run --package openfeature-provider-posthog ruff format --check . + uv run --package openfeature-provider-posthog ruff check . + uv run --package openfeature-provider-posthog mypy . + + - name: Build and verify provider distribution + working-directory: openfeature-provider + run: | + uv build --package openfeature-provider-posthog --out-dir dist + uv run --with twine twine check dist/* + + - name: Smoke test built wheel in a clean environment + working-directory: openfeature-provider + shell: bash + run: | + uv venv /tmp/of-smoke + uv pip install --python /tmp/of-smoke/bin/python dist/*.whl + /tmp/of-smoke/bin/python -c "from openfeature.contrib.provider.posthog import PostHogProvider; print(PostHogProvider)" + django5-integration: name: Django 5 integration tests runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a3f2459..91e4b286 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,8 +53,12 @@ jobs: slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - release: - name: Release and publish + # Bump versions, commit to main, and regenerate references. This is the single + # approval-gated job (environment "Release"). It does NOT publish — publishing + # happens in the `publish` matrix job below, which has no environment so the + # matrix never multiplies approval prompts. + version-bump: + name: Bump versions and commit to main needs: [check-changesets, notify-approval-needed] runs-on: ubuntu-latest # Use `always()` to ensure the job runs even if notify-approval-needed is skipped, @@ -63,7 +67,9 @@ jobs: environment: "Release" # This will require an approval from a maintainer, they are notified in Slack above permissions: contents: write - id-token: write + outputs: + commit-hash: ${{ steps.commit-release.outputs.commit-hash }} + new_version: ${{ steps.sampo-release.outputs.new_version }} steps: - name: Notify Slack - Approved if: needs.notify-approval-needed.outputs.slack_ts != '' @@ -119,6 +125,10 @@ jobs: - name: Install dependencies run: uv sync --extra dev + # `sampo release` bumps every workspace package that has a pending changeset + # (posthog at the root, and openfeature-provider-posthog as a workspace + # member). new_version is the posthog version, used for the version.py sync + # and the posthog tag. - name: Prepare release with Sampo id: sampo-release env: @@ -152,52 +162,6 @@ jobs: git fetch origin main git reset --hard "$COMMIT_HASH" - # 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.commit-hash != '' - run: uv run make build_release - - - name: Publish posthog to PyPI - if: steps.commit-release.outputs.commit-hash != '' - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - - # 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.commit-hash != '' - run: uv run make build_release_analytics - - - name: Publish posthoganalytics to PyPI - if: steps.commit-release.outputs.commit-hash != '' - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - - # 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.commit-hash != '' - env: - GH_TOKEN: ${{ steps.releaser.outputs.token }} - NEW_VERSION: ${{ steps.sampo-release.outputs.new_version }} - COMMIT_HASH: ${{ steps.commit-release.outputs.commit-hash }} - run: | - gh api "repos/${{ github.repository }}/git/refs" \ - -f "ref=refs/tags/${NEW_VERSION}" \ - -f "sha=${COMMIT_HASH}" - - - name: Create GitHub Release - if: steps.commit-release.outputs.commit-hash != '' - env: - GH_TOKEN: ${{ steps.releaser.outputs.token }} - NEW_VERSION: ${{ steps.sampo-release.outputs.new_version }} - run: | - CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1; next} flag; END{if (!flag) print defText}' CHANGELOG.md | sed '/[^[:space:]]/,$!d' | tac | sed '/[^[:space:]]/,$!d' | tac) - gh release create "$NEW_VERSION" --notes "$CHANGELOG_ENTRY" - - name: Generate references if: steps.commit-release.outputs.commit-hash != '' run: | @@ -249,14 +213,162 @@ jobs: 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@${{ steps.sampo-release.outputs.new_version }}`! " + message: "❌ Failed to bump versions for `posthog-python@${{ steps.sampo-release.outputs.new_version }}`! " + emoji_reaction: "x" + + # Build, publish, and tag each package whose version was bumped in this release. + # A matrix over the publishable packages (N packages, like the posthog-ruby and + # JS monorepos). The approval gate lives on `version-bump`, so this job has no + # environment and the matrix never re-triggers approval. `max-parallel: 1` + # publishes sequentially (posthog before its posthoganalytics mirror). + # + # Each package uses PyPI OIDC trusted publishing (no API tokens). A trusted + # publisher must be registered for every package name (this workflow, + # `publish` job) before its first release. + publish: + name: Publish ${{ matrix.package.name }} + needs: [check-changesets, notify-approval-needed, version-bump] + runs-on: ubuntu-latest + if: always() && needs.version-bump.outputs.commit-hash != '' + permissions: + contents: write + id-token: write + strategy: + fail-fast: true + max-parallel: 1 + matrix: + package: + - name: posthog + version_file: pyproject.toml + build: uv run make build_release + packages_dir: dist + tag_prefix: "v" + changelog: CHANGELOG.md + github_release: true + # posthoganalytics is a build-time mirror of posthog (same version, no + # separate tag/release); it always ships alongside posthog. + - name: posthoganalytics + version_file: pyproject.toml + build: uv run make build_release_analytics + packages_dir: dist + tag_prefix: "" + changelog: "" + github_release: false + - name: openfeature-provider-posthog + version_file: openfeature-provider/pyproject.toml + build: uv build --package openfeature-provider-posthog --out-dir openfeature-provider/dist + packages_dir: openfeature-provider/dist + tag_prefix: "openfeature-provider-posthog-v" + changelog: openfeature-provider/CHANGELOG.md + github_release: true + steps: + - name: Get GitHub App token + id: releaser + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_APP_ID }} + private-key: ${{ secrets.GH_APP_POSTHOG_PYTHON_RELEASER_PRIVATE_KEY }} + + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.version-bump.outputs.commit-hash }} + fetch-depth: 0 + token: ${{ steps.releaser.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: 3.11.11 + + - name: Install uv + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --extra dev + + # Publish a package only if this release actually changed its version. The + # release commit (HEAD) is compared to its parent: posthog/posthoganalytics + # gate on the root pyproject, the provider on its own pyproject. + - name: Detect ${{ matrix.package.name }} version change + id: detect + env: + VERSION_FILE: ${{ matrix.package.version_file }} + run: | + if git diff --quiet HEAD~1 HEAD -- "$VERSION_FILE"; then + echo "has-new-version=false" >> "$GITHUB_OUTPUT" + echo "${{ matrix.package.name }}: no version change in this release; skipping." + else + version=$(python3 -c "import tomllib; print(tomllib.load(open('$VERSION_FILE','rb'))['project']['version'])") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "has-new-version=true" >> "$GITHUB_OUTPUT" + echo "${{ matrix.package.name }}: releasing $version" + fi + + - name: Build ${{ matrix.package.name }} + if: steps.detect.outputs.has-new-version == 'true' + run: ${{ matrix.package.build }} + + - name: Publish ${{ matrix.package.name }} to PyPI + if: steps.detect.outputs.has-new-version == 'true' + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: ${{ matrix.package.packages_dir }} + + - name: Tag ${{ matrix.package.name }} release + if: steps.detect.outputs.has-new-version == 'true' && matrix.package.tag_prefix != '' + env: + GH_TOKEN: ${{ steps.releaser.outputs.token }} + TAG: ${{ matrix.package.tag_prefix }}${{ steps.detect.outputs.version }} + COMMIT_HASH: ${{ needs.version-bump.outputs.commit-hash }} + run: | + gh api "repos/${{ github.repository }}/git/refs" \ + -f "ref=refs/tags/${TAG}" \ + -f "sha=${COMMIT_HASH}" + + - name: Create ${{ matrix.package.name }} GitHub Release + if: steps.detect.outputs.has-new-version == 'true' && matrix.package.github_release + env: + GH_TOKEN: ${{ steps.releaser.outputs.token }} + TAG: ${{ matrix.package.tag_prefix }}${{ steps.detect.outputs.version }} + CHANGELOG_FILE: ${{ matrix.package.changelog }} + run: | + CHANGELOG_ENTRY=$(awk -v defText="see ${CHANGELOG_FILE}" '/^## /{if (flag) exit; flag=1; next} flag; END{if (!flag) print defText}' "${CHANGELOG_FILE}" | sed '/[^[:space:]]/,$!d' | tac | sed '/[^[:space:]]/,$!d' | tac) + gh release create "$TAG" --notes "$CHANGELOG_ENTRY" + + # Notify in case of a failure + - name: Send failure event to PostHog + if: ${{ failure() }} + uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0 + 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 }}", + "package": "${{ matrix.package.name }}", + "version": "${{ needs.version-bump.outputs.new_version }}" + } + + - name: Notify Slack - Failed + if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }} + uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c + 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 publish `${{ matrix.package.name }}@${{ needs.version-bump.outputs.new_version }}`! " emoji_reaction: "x" notify-released: name: Notify Slack - Released - needs: [check-changesets, notify-approval-needed, release] + needs: [check-changesets, notify-approval-needed, version-bump, publish] runs-on: ubuntu-latest - if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' + if: always() && needs.publish.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/README.md b/README.md index e8e2f708..364101f0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ SDK usage examples and code snippets live in the official documentation so they - [Python library docs](https://posthog.com/docs/libraries/python) - [Django framework docs](https://posthog.com/docs/libraries/django) - [Flask framework docs](https://posthog.com/docs/libraries/flask) +- [OpenFeature provider docs](https://posthog.com/docs/feature-flags/installation/openfeature) — use PostHog flags through the [OpenFeature](https://openfeature.dev) Python SDK ## Contributing diff --git a/mypy.ini b/mypy.ini index bcfb1540..12f03d5f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,7 +10,7 @@ check_untyped_defs = True warn_unreachable = True strict_equality = True ignore_missing_imports = True -exclude = env/.*|venv/.*|build/.*|examples/example-.* +exclude = env/.*|venv/.*|build/.*|examples/example-.*|openfeature-provider/.* [mypy-django.*] ignore_missing_imports = True diff --git a/openfeature-provider/.gitignore b/openfeature-provider/.gitignore new file mode 100644 index 00000000..af3ba250 --- /dev/null +++ b/openfeature-provider/.gitignore @@ -0,0 +1,5 @@ +*.pyc +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ diff --git a/openfeature-provider/CHANGELOG.md b/openfeature-provider/CHANGELOG.md new file mode 100644 index 00000000..5aa24d29 --- /dev/null +++ b/openfeature-provider/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to `openfeature-provider-posthog` are documented here. This +file is maintained by [Sampo](https://github.com/bruits/sampo) from changesets in +`.sampo/changesets/` that target `pypi/openfeature-provider-posthog`. diff --git a/openfeature-provider/CONTRIBUTING.md b/openfeature-provider/CONTRIBUTING.md new file mode 100644 index 00000000..74e3721d --- /dev/null +++ b/openfeature-provider/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to openfeature-provider-posthog + +This package is a [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) +member of the [posthog-python](https://github.com/PostHog/posthog-python) repo +(declared in the root `pyproject.toml`'s `[tool.uv.workspace]`), so it is always +developed and tested against the in-repo `posthog`. + +## Local development + +From this directory (`openfeature-provider/`): + +```bash +uv sync --package openfeature-provider-posthog --extra dev +uv run --package openfeature-provider-posthog pytest +uv run --package openfeature-provider-posthog ruff format --check . +uv run --package openfeature-provider-posthog ruff check . +uv run --package openfeature-provider-posthog mypy . +``` + +## Build + +Build the distribution into this package's own `dist/` (kept separate from the +`posthog` dist): + +```bash +uv build --package openfeature-provider-posthog --out-dir dist +``` + +## Releasing + +Versioning and publishing are handled by the repo's Sampo-based release flow. +Add a changeset targeting this package and the release workflow builds, publishes, +and tags it: + +```bash +sampo add -p pypi/openfeature-provider-posthog -b patch -m "your change" +``` diff --git a/openfeature-provider/LICENSE b/openfeature-provider/LICENSE new file mode 100644 index 00000000..1c36d051 --- /dev/null +++ b/openfeature-provider/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 PostHog (part of Hiberly Inc) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/__init__.py b/openfeature-provider/openfeature/contrib/provider/posthog/__init__.py new file mode 100644 index 00000000..c755e6e3 --- /dev/null +++ b/openfeature-provider/openfeature/contrib/provider/posthog/__init__.py @@ -0,0 +1,3 @@ +from openfeature.contrib.provider.posthog.provider import PostHogProvider + +__all__ = ["PostHogProvider"] diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/provider.py b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py new file mode 100644 index 00000000..0061f1fd --- /dev/null +++ b/openfeature-provider/openfeature/contrib/provider/posthog/provider.py @@ -0,0 +1,285 @@ +"""Official PostHog provider for the OpenFeature Python SDK. + +This wraps a configured :class:`posthog.Posthog` client and exposes flag +evaluation through OpenFeature's :class:`~openfeature.provider.AbstractProvider` +contract, using the modern, single-call ``Client.get_feature_flag_result`` API. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + TargetingKeyMissingError, + TypeMismatchError, +) +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata + +import posthog +from posthog.types import FeatureFlagResult + +# Reserved evaluation-context attribute keys. Every other attribute in +# ``evaluation_context.attributes`` is forwarded as a PostHog person property. +GROUPS_KEY = "groups" +GROUP_PROPERTIES_KEY = "group_properties" +_RESERVED_KEYS = frozenset({GROUPS_KEY, GROUP_PROPERTIES_KEY}) + +ObjectValue = Union[Sequence[Any], Mapping[str, Any]] + +_T = TypeVar("_T") +_N = TypeVar("_N", int, float) + +_logger = logging.getLogger(__name__) + + +class PostHogProvider(AbstractProvider): + """OpenFeature provider backed by a configured :class:`posthog.Posthog` client. + + The caller owns the PostHog client lifecycle: construct and configure the + client yourself (project key, ``personal_api_key`` for local evaluation, + ``host``, ...), then hand it to this provider. + + Evaluation-context mapping: + * ``targeting_key`` -> PostHog ``distinct_id`` + * reserved attr ``groups`` -> PostHog ``groups`` + * reserved attr ``group_properties`` -> PostHog ``group_properties`` + * every other attribute -> PostHog ``person_properties`` + + Flag-type mapping (all via ``get_feature_flag_result``): + * boolean -> ``enabled`` + * string -> the multivariate ``variant`` key + * int/float -> the ``variant`` parsed to a number + * object -> the flag's JSON ``payload`` + + Args: + client: A configured :class:`posthog.Posthog` instance. + default_distinct_id: Distinct ID to use when the evaluation context has + no ``targeting_key``. If ``None`` (default), a missing targeting key + raises :class:`~openfeature.exception.TargetingKeyMissingError`, + which is OpenFeature-idiomatic. Set a value (e.g. ``"anonymous"``) + to opt into anonymous evaluation. + send_feature_flag_events: Forwarded to ``get_feature_flag_result`` to + control ``$feature_flag_called`` capture. Defaults to ``True`` so + PostHog flag analytics (and experiments) keep working. + """ + + def __init__( + self, + client: posthog.Posthog, + *, + default_distinct_id: Optional[str] = None, + send_feature_flag_events: bool = True, + ) -> None: + super().__init__() + self._client = client + self._default_distinct_id = default_distinct_id + self._send_feature_flag_events = send_feature_flag_events + + # -- metadata / lifecycle ------------------------------------------------- + + def get_metadata(self) -> Metadata: + return Metadata(name="PostHogProvider") + + def initialize(self, evaluation_context: EvaluationContext) -> None: + # Preload locally-evaluated flag definitions only when the injected + # client is configured for local evaluation. We do not otherwise mutate + # the caller-owned client, and a preload failure must not make the + # OpenFeature client un-ready (remote evaluation still works). + if getattr(self._client, "personal_api_key", None): + try: + self._client.load_feature_flags() + except Exception: + # Don't block the OpenFeature client on a preload failure + # (remote evaluation still works), but surface it: an invalid + # personal_api_key, unreachable host, or missing permissions + # would otherwise silently disable local evaluation. + _logger.warning( + "PostHogProvider: failed to preload feature flag " + "definitions for local evaluation; falling back to remote " + "evaluation.", + exc_info=True, + ) + + def shutdown(self) -> None: + # The provider does not own the injected client's lifecycle, so this is + # deliberately a no-op. Callers shut down their own ``Posthog`` client. + return None + + # -- core resolution ------------------------------------------------------ + + def _resolve( + self, + flag_key: str, + evaluation_context: Optional[EvaluationContext], + ) -> FeatureFlagResult: + distinct_id = self._distinct_id(evaluation_context) + person_properties, groups, group_properties = self._split_context( + evaluation_context + ) + result = self._client.get_feature_flag_result( + flag_key, + distinct_id, + groups=groups or None, + person_properties=person_properties or None, + group_properties=group_properties or None, + send_feature_flag_events=self._send_feature_flag_events, + ) + if result is None: + raise FlagNotFoundError(f"Flag '{flag_key}' not found or disabled.") + return result + + # -- typed resolvers ------------------------------------------------------ + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[bool]: + result = self._resolve(flag_key, evaluation_context) + return self._details(result.enabled, result) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[str]: + result = self._resolve(flag_key, evaluation_context) + if result.variant is None: + if not result.enabled: + # The user matched no condition / the flag is off. This is not a + # type error: return the caller's default with a normal reason. + return self._details(default_value, result) + # Enabled but no variant => a boolean flag read as a string. Surface + # a type mismatch so the caller gets its default per the OF spec + # rather than a surprising "True"/"False". + raise TypeMismatchError( + f"Flag '{flag_key}' has no string variant (boolean flag)." + ) + return self._details(result.variant, result) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[int]: + return self._resolve_number(flag_key, default_value, evaluation_context, int) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[float]: + return self._resolve_number(flag_key, default_value, evaluation_context, float) + + def resolve_object_details( + self, + flag_key: str, + default_value: ObjectValue, + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[ObjectValue]: + result = self._resolve(flag_key, evaluation_context) + payload = result.payload # already JSON-deserialized by posthog + if not isinstance(payload, (dict, list)): + if not result.enabled: + # Non-enrolled / disabled flag: return the default with a normal + # reason rather than flagging it as a type error. + return self._details(default_value, result) + # Matched flag with no object payload => a genuine type mismatch. + raise TypeMismatchError(f"Flag '{flag_key}' has no object/JSON payload.") + return self._details(payload, result) + + def _resolve_number( + self, + flag_key: str, + default_value: _N, + evaluation_context: Optional[EvaluationContext], + ctor: Callable[[str], _N], + ) -> FlagResolutionDetails[_N]: + result = self._resolve(flag_key, evaluation_context) + if result.variant is None: + if not result.enabled: + # Non-enrolled / disabled flag: not a type error. + return self._details(default_value, result) + raise TypeMismatchError( + f"Flag '{flag_key}' has no variant to parse as {ctor.__name__}." + ) + try: + value = ctor(result.variant) + except (TypeError, ValueError) as exc: + raise TypeMismatchError( + f"Flag '{flag_key}' variant '{result.variant}' is not a valid " + f"{ctor.__name__}." + ) from exc + return self._details(value, result) + + def _details( + self, value: _T, result: FeatureFlagResult + ) -> FlagResolutionDetails[_T]: + """Build resolution details with our shared reason/metadata wiring.""" + return FlagResolutionDetails( + value=value, + variant=result.variant, + reason=self._map_reason(result), + flag_metadata=self._flag_metadata(result), + ) + + # -- helpers -------------------------------------------------------------- + + def _distinct_id(self, evaluation_context: Optional[EvaluationContext]) -> str: + if evaluation_context is not None and evaluation_context.targeting_key: + return evaluation_context.targeting_key + if self._default_distinct_id is not None: + return self._default_distinct_id + raise TargetingKeyMissingError( + "No targeting_key in evaluation context and no default_distinct_id " + "configured." + ) + + @staticmethod + def _split_context( + evaluation_context: Optional[EvaluationContext], + ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + if evaluation_context is None or not evaluation_context.attributes: + return {}, {}, {} + attrs = evaluation_context.attributes + groups = attrs.get(GROUPS_KEY) or {} + group_properties = attrs.get(GROUP_PROPERTIES_KEY) or {} + person_properties = {k: v for k, v in attrs.items() if k not in _RESERVED_KEYS} + groups = groups if isinstance(groups, dict) else {} + group_properties = ( + group_properties if isinstance(group_properties, dict) else {} + ) + return person_properties, groups, group_properties + + @staticmethod + def _map_reason(result: FeatureFlagResult) -> Reason: + """Map PostHog's free-text reason / enabled state to an OpenFeature Reason.""" + if result.enabled: + # Enabled: the user matched a targeting condition (or was assigned a + # variant). PostHog has no distinct OpenFeature-style reason here. + return Reason.TARGETING_MATCH + # Not enabled. ``get_feature_flag_result`` returns ``None`` (surfaced as + # ``FlagNotFoundError`` one level up) for archived/non-existent flags, so + # a ``False`` result overwhelmingly means the flag is active but no + # targeting condition matched -> ``DEFAULT``. Only report ``DISABLED`` + # (the flag itself is turned off) when the reason text says so. + text = (result.reason or "").lower() + if "disabled" in text: + return Reason.DISABLED + return Reason.DEFAULT + + @staticmethod + def _flag_metadata(result: FeatureFlagResult) -> Mapping[str, Any]: + meta: dict[str, Any] = {} + if result.reason is not None: + meta["posthog_reason"] = result.reason + return meta diff --git a/openfeature-provider/openfeature/contrib/provider/posthog/py.typed b/openfeature-provider/openfeature/contrib/provider/posthog/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/openfeature-provider/pyproject.toml b/openfeature-provider/pyproject.toml new file mode 100644 index 00000000..8bb680c6 --- /dev/null +++ b/openfeature-provider/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openfeature-provider-posthog" +version = "0.1.0" +description = "Official PostHog provider for the OpenFeature Python SDK." +authors = [{ name = "PostHog", email = "engineering@posthog.com" }] +maintainers = [{ name = "PostHog", email = "engineering@posthog.com" }] +license = { text = "MIT" } +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + # FeatureFlagResult / get_feature_flag_result are the modern, non-deprecated + # single-call API. Pinned to the posthog major this provider is released and + # tested against (both ship from this repo); bump the upper bound when moving + # the provider to a new posthog major. + "posthog>=7.0.0,<8.0.0", + "openfeature-sdk>=0.8.0", +] + +[project.urls] +Homepage = "https://github.com/posthog/posthog-python" +Repository = "https://github.com/posthog/posthog-python" +Documentation = "https://posthog.com/docs/feature-flags/installation/openfeature" + +[project.optional-dependencies] +dev = [ + "mypy", + "ruff", + "pytest", +] + +[tool.setuptools] +packages = ["openfeature.contrib.provider.posthog"] + +[tool.setuptools.package-data] +"openfeature.contrib.provider.posthog" = ["py.typed"] + +# Local development: resolve posthog from the uv workspace (the repo root), +# declared in the root pyproject's [tool.uv.workspace]. This guarantees the +# provider is always built/tested against the in-repo posthog. +[tool.uv.sources] +posthog = { workspace = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# This sub-project runs its own mypy (with openfeature-sdk installed in its env). +[tool.mypy] +python_version = "3.10" +namespace_packages = true +explicit_package_bases = true +mypy_path = "." +ignore_missing_imports = true +strict_optional = true +check_untyped_defs = true diff --git a/openfeature-provider/tests/__init__.py b/openfeature-provider/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openfeature-provider/tests/conftest.py b/openfeature-provider/tests/conftest.py new file mode 100644 index 00000000..05fe871f --- /dev/null +++ b/openfeature-provider/tests/conftest.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock + +import pytest +from openfeature import api + +from posthog.types import FeatureFlagResult + + +def make_result( + key: str = "flag", + enabled: bool = True, + variant=None, + payload=None, + reason: str = "matched condition set 1", +) -> FeatureFlagResult: + return FeatureFlagResult( + key=key, + enabled=enabled, + variant=variant, + payload=payload, + reason=reason, + ) + + +@pytest.fixture +def fake_client(): + """A stand-in Posthog client exposing only ``get_feature_flag_result``.""" + client = MagicMock() + # No personal API key -> provider.initialize() skips load_feature_flags(). + client.personal_api_key = None + return client + + +@pytest.fixture(autouse=True) +def _reset_openfeature(): + """Reset global OpenFeature provider state between tests.""" + yield + clear = getattr(api, "clear_providers", None) + if callable(clear): + clear() diff --git a/openfeature-provider/tests/test_provider_e2e.py b/openfeature-provider/tests/test_provider_e2e.py new file mode 100644 index 00000000..620e60a8 --- /dev/null +++ b/openfeature-provider/tests/test_provider_e2e.py @@ -0,0 +1,56 @@ +"""End-to-end tests through the OpenFeature public evaluation API.""" + +from openfeature import api +from openfeature.evaluation_context import EvaluationContext + +from openfeature.contrib.provider.posthog import PostHogProvider + +from tests.conftest import make_result + + +def _register(fake_client): + api.set_provider(PostHogProvider(fake_client, default_distinct_id="anon")) + return api.get_client() + + +def test_end_to_end_boolean_true(fake_client): + fake_client.get_feature_flag_result.return_value = make_result(enabled=True) + client = _register(fake_client) + ctx = EvaluationContext(targeting_key="user-123") + assert client.get_boolean_value("flag", False, ctx) is True + + +def test_end_to_end_string_variant(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="blue" + ) + client = _register(fake_client) + ctx = EvaluationContext(targeting_key="user-123") + assert client.get_string_value("exp", "control", ctx) == "blue" + + +def test_end_to_end_object_payload(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="v1", payload={"hex": "#00f"} + ) + client = _register(fake_client) + ctx = EvaluationContext(targeting_key="user-123") + assert client.get_object_value("cfg", {}, ctx) == {"hex": "#00f"} + + +def test_end_to_end_default_on_missing_flag(fake_client): + fake_client.get_feature_flag_result.return_value = None + client = _register(fake_client) + ctx = EvaluationContext(targeting_key="user-123") + # FlagNotFoundError inside the provider -> SDK returns the caller's default. + assert client.get_boolean_value("missing", True, ctx) is True + + +def test_end_to_end_default_on_type_mismatch(fake_client): + # Boolean flag (no variant) read as a string -> TYPE_MISMATCH -> default. + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant=None + ) + client = _register(fake_client) + ctx = EvaluationContext(targeting_key="user-123") + assert client.get_string_value("flag", "fallback", ctx) == "fallback" diff --git a/openfeature-provider/tests/test_provider_unit.py b/openfeature-provider/tests/test_provider_unit.py new file mode 100644 index 00000000..80269020 --- /dev/null +++ b/openfeature-provider/tests/test_provider_unit.py @@ -0,0 +1,250 @@ +import pytest +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + TargetingKeyMissingError, + TypeMismatchError, +) +from openfeature.flag_evaluation import Reason + +from openfeature.contrib.provider.posthog import PostHogProvider + +from tests.conftest import make_result + + +def _provider(fake_client, **kwargs): + return PostHogProvider(fake_client, default_distinct_id="anon", **kwargs) + + +def test_metadata(fake_client): + assert _provider(fake_client).get_metadata().name == "PostHogProvider" + + +@pytest.mark.parametrize( + ("enabled", "reason", "expected_value", "expected_reason"), + [ + (True, "matched condition set 1", True, Reason.TARGETING_MATCH), + # Active flag, user matched nothing -> DEFAULT (not DISABLED). + (False, "no condition set matched", False, Reason.DEFAULT), + # Only an explicitly-disabled flag maps to DISABLED. + (False, "flag is disabled", False, Reason.DISABLED), + ], +) +def test_boolean_reason_mapping( + fake_client, enabled, reason, expected_value, expected_reason +): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=enabled, variant=None, reason=reason + ) + details = _provider(fake_client).resolve_boolean_details( + "flag", not expected_value, EvaluationContext("user-1") + ) + assert details.value is expected_value + assert details.reason == expected_reason + assert details.flag_metadata["posthog_reason"] == reason + fake_client.get_feature_flag_result.assert_called_once() + + +def test_string_variant(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="control" + ) + details = _provider(fake_client).resolve_string_details( + "exp", "x", EvaluationContext("user-1") + ) + assert details.value == "control" + assert details.variant == "control" + + +def test_string_on_boolean_flag_is_type_mismatch(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant=None + ) + with pytest.raises(TypeMismatchError): + _provider(fake_client).resolve_string_details( + "flag", "x", EvaluationContext("user-1") + ) + + +@pytest.mark.parametrize( + ("resolver", "variant", "expected"), + [ + ("resolve_integer_details", "42", 42), + ("resolve_integer_details", "3", 3), + ("resolve_float_details", "3.5", 3.5), + ("resolve_float_details", "3", 3.0), + ], +) +def test_number_variant_parse(fake_client, resolver, variant, expected): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant=variant + ) + details = getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) + assert details.value == expected + + +@pytest.mark.parametrize( + ("resolver", "variant"), + [ + ("resolve_integer_details", "not-an-int"), + ("resolve_integer_details", None), + ("resolve_float_details", "abc"), + ("resolve_float_details", None), + ], +) +def test_number_variant_parse_failure(fake_client, resolver, variant): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant=variant + ) + with pytest.raises(TypeMismatchError): + getattr(_provider(fake_client), resolver)("n", 0, EvaluationContext("u")) + + +def test_object_payload(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="v1", payload={"color": "blue"} + ) + details = _provider(fake_client).resolve_object_details( + "cfg", {}, EvaluationContext("u") + ) + assert details.value == {"color": "blue"} + + +def test_object_payload_list(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="v1", payload=[1, 2, 3] + ) + details = _provider(fake_client).resolve_object_details( + "cfg", {}, EvaluationContext("u") + ) + assert details.value == [1, 2, 3] + + +def test_object_missing_payload_is_type_mismatch(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=True, variant="v1", payload=None + ) + with pytest.raises(TypeMismatchError): + _provider(fake_client).resolve_object_details("cfg", {}, EvaluationContext("u")) + + +# A user who matches no condition / a disabled flag (enabled=False, variant/payload +# absent) is NOT a type error: the default is returned with a normal reason and no +# error_code, instead of TYPE_MISMATCH / Reason.ERROR. +def test_string_unmatched_returns_default_without_error(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=False, variant=None, reason="no condition set matched" + ) + details = _provider(fake_client).resolve_string_details( + "exp", "fallback", EvaluationContext("user-1") + ) + assert details.value == "fallback" + assert details.reason == Reason.DEFAULT + assert details.error_code is None + + +@pytest.mark.parametrize( + "resolver", ["resolve_integer_details", "resolve_float_details"] +) +def test_number_unmatched_returns_default_without_error(fake_client, resolver): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=False, variant=None, reason="no condition set matched" + ) + details = getattr(_provider(fake_client), resolver)("n", 7, EvaluationContext("u")) + assert details.value == 7 + assert details.reason == Reason.DEFAULT + assert details.error_code is None + + +def test_object_unmatched_returns_default_without_error(fake_client): + fake_client.get_feature_flag_result.return_value = make_result( + enabled=False, variant=None, payload=None, reason="no condition set matched" + ) + default = {"fallback": True} + details = _provider(fake_client).resolve_object_details( + "cfg", default, EvaluationContext("u") + ) + assert details.value == default + assert details.reason == Reason.DEFAULT + assert details.error_code is None + + +def test_flag_not_found_raises(fake_client): + fake_client.get_feature_flag_result.return_value = None + with pytest.raises(FlagNotFoundError): + _provider(fake_client).resolve_boolean_details( + "missing", False, EvaluationContext("u") + ) + + +def test_missing_targeting_key_no_default(fake_client): + provider = PostHogProvider(fake_client) # no default_distinct_id + with pytest.raises(TargetingKeyMissingError): + provider.resolve_boolean_details("flag", False, EvaluationContext()) + + +def test_default_distinct_id_used_when_no_targeting_key(fake_client): + fake_client.get_feature_flag_result.return_value = make_result() + _provider(fake_client).resolve_boolean_details("flag", False, EvaluationContext()) + args, _ = fake_client.get_feature_flag_result.call_args + assert args[1] == "anon" + + +def test_context_split(fake_client): + fake_client.get_feature_flag_result.return_value = make_result() + ctx = EvaluationContext( + "u", + { + "plan": "pro", + "groups": {"org": "acme"}, + "group_properties": {"org": {"tier": "ent"}}, + }, + ) + _provider(fake_client).resolve_boolean_details("flag", False, ctx) + kwargs = fake_client.get_feature_flag_result.call_args.kwargs + assert kwargs["groups"] == {"org": "acme"} + assert kwargs["group_properties"] == {"org": {"tier": "ent"}} + assert kwargs["person_properties"] == {"plan": "pro"} + + +@pytest.mark.parametrize("bad_value", ["acme", ["a", "b"], 42]) +def test_context_split_non_dict_groups_coerced_to_none(fake_client, bad_value): + # A non-dict `groups` / `group_properties` is coerced to {} and forwarded as + # None, never passed straight through to get_feature_flag_result. + fake_client.get_feature_flag_result.return_value = make_result() + ctx = EvaluationContext("u", {"groups": bad_value, "group_properties": bad_value}) + _provider(fake_client).resolve_boolean_details("flag", False, ctx) + kwargs = fake_client.get_feature_flag_result.call_args.kwargs + assert kwargs["groups"] is None + assert kwargs["group_properties"] is None + + +def test_send_feature_flag_events_forwarded(fake_client): + fake_client.get_feature_flag_result.return_value = make_result() + _provider(fake_client, send_feature_flag_events=False).resolve_boolean_details( + "flag", False, EvaluationContext("u") + ) + assert ( + fake_client.get_feature_flag_result.call_args.kwargs["send_feature_flag_events"] + is False + ) + + +def test_initialize_skips_preload_without_personal_api_key(fake_client): + # fake_client.personal_api_key is None by default. + PostHogProvider(fake_client).initialize(EvaluationContext()) + fake_client.load_feature_flags.assert_not_called() + + +def test_initialize_logs_warning_on_preload_failure(fake_client, caplog): + fake_client.personal_api_key = "phx_test" + fake_client.load_feature_flags.side_effect = RuntimeError("bad key") + with caplog.at_level("WARNING"): + PostHogProvider(fake_client).initialize(EvaluationContext()) + fake_client.load_feature_flags.assert_called_once() + assert "failed to preload" in caplog.text + + +def test_shutdown_does_not_touch_client(fake_client): + PostHogProvider(fake_client).shutdown() + fake_client.shutdown.assert_not_called() diff --git a/pyproject.toml b/pyproject.toml index b120e34e..480a4b0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,12 @@ packages = [ exclude-newer = "7 days" required-version = ">=0.8.0" +# Declares a uv workspace so tooling (incl. Sampo's release flow) discovers the +# in-repo openfeature-provider-posthog package. The main `posthog` package and +# its build are unaffected (packages list above is explicit). +[tool.uv.workspace] +members = ["openfeature-provider"] + [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/uv.lock b/uv.lock index 6ea1fe0b..efc57de5 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,12 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[manifest] +members = [ + "openfeature-provider-posthog", + "posthog", +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -172,7 +178,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version != '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/68/fb4fb78c9eac59d5e819108a57664737f855c5a8e9b76aec1738bb137f9e/asgiref-3.9.0.tar.gz", hash = "sha256:3dd2556d0f08c4fab8a010d9ab05ef8c34565f6bf32381d17505f7ca5b273767", size = 36772, upload-time = "2025-07-03T13:25:01.491Z" } wheels = [ @@ -2138,6 +2144,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/96/d7dfe1cc0be2df22d7a97ffb0f8bb00b10d92749aa6e64ffa7cc9a041580/openapi_spec_validator-0.8.5-py3-none-any.whl", hash = "sha256:3669106361856934153991e30714616a294865a33f6411a4c25d1dc2d08cfbc2", size = 50334, upload-time = "2026-04-24T15:25:19.65Z" }, ] +[[package]] +name = "openfeature-provider-posthog" +version = "0.1.0" +source = { editable = "openfeature-provider" } +dependencies = [ + { name = "openfeature-sdk" }, + { name = "posthog" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'" }, + { name = "openfeature-sdk", specifier = ">=0.8.0" }, + { name = "posthog", editable = "." }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "openfeature-sdk" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/cfc684b7d8314398d476ae8ed515c10db99c4d7f950989db464b4ded12ce/openfeature_sdk-0.10.0.tar.gz", hash = "sha256:938c2540bdea4da3b01ef507517ee636f223a35abaaca845c5587e594151b052", size = 33516, upload-time = "2026-06-01T19:45:35.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/44/8a4f5225e930ff0d999fd43f5d743a4babeb6c7e76dddc00f0e118878ef3/openfeature_sdk-0.10.0-py3-none-any.whl", hash = "sha256:75497ea75d73f684eef509a25f79ad6386368862e050af80ab70a44ae49b33e4", size = 38941, upload-time = "2026-06-01T19:45:33.011Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.40.0"