From abba01ebb22b9784701ce820635234057afa8d0b Mon Sep 17 00:00:00 2001 From: Alex Sharp Date: Thu, 6 Jun 2024 19:53:47 +0200 Subject: [PATCH 1/2] add posthog local eval support --- feature_gate/adapters/memory.py | 2 +- feature_gate/adapters/posthog.py | 4 +-- feature_gate/client.py | 4 +-- feature_gate/clients/posthog_api_client.py | 41 +++++++++++++++++----- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/feature_gate/adapters/memory.py b/feature_gate/adapters/memory.py index 37ce807..abb3d64 100644 --- a/feature_gate/adapters/memory.py +++ b/feature_gate/adapters/memory.py @@ -57,7 +57,7 @@ def features(self): self._logger.info("Lists features {features}") return features - def is_enabled(self, feature_key): + def is_enabled(self, feature_key, **kwargs): for feature in self._features: if feature["key"] == feature_key: is_enabled = feature["gates"]["boolean"]["enabled"] diff --git a/feature_gate/adapters/posthog.py b/feature_gate/adapters/posthog.py index dd7b245..a535eb3 100644 --- a/feature_gate/adapters/posthog.py +++ b/feature_gate/adapters/posthog.py @@ -21,8 +21,8 @@ def features(self): key='key' return [item[key] for item in resp["data"] if key in item] - def is_enabled(self, feature_key): - return self.client.is_enabled(feature_key) + def is_enabled(self, feature_key, **kwargs): + return self.client.is_enabled(feature_key, **kwargs) def enable(self, feature_key): resp = self.client.enable_feature(feature_key) diff --git a/feature_gate/client.py b/feature_gate/client.py index 43b78f5..56e103d 100644 --- a/feature_gate/client.py +++ b/feature_gate/client.py @@ -17,8 +17,8 @@ def remove(self, feature): def features(self): return self.adapter.features() - def is_enabled(self, feature): - return self.adapter.is_enabled(feature) + def is_enabled(self, feature, **kwargs): + return self.adapter.is_enabled(feature, **kwargs) def enable(self, feature): return self.adapter.enable(feature) diff --git a/feature_gate/clients/posthog_api_client.py b/feature_gate/clients/posthog_api_client.py index ff2c356..7640f15 100644 --- a/feature_gate/clients/posthog_api_client.py +++ b/feature_gate/clients/posthog_api_client.py @@ -2,6 +2,7 @@ import os import requests import structlog +from posthog import Posthog from pathlib import Path from feature_gate.client import FeatureNotFound @@ -16,7 +17,7 @@ class PosthogAPIClientError(Exception): pass class PosthogAPIClient: - def __init__(self, api_base=None, api_key=None, project_id=None): + def __init__(self, api_base=None, api_key=None, project_id=None, poll_interval=30): if api_base is None: self.api_base = os.environ.get("POSTHOG_API_BASE", "https://app.posthog.com") else: @@ -31,6 +32,12 @@ def __init__(self, api_base=None, api_key=None, project_id=None): self.project_id = os.environ.get('POSTHOG_PROJECT_ID') else: self.project_id = project_id + + self.posthog_client = Posthog(project_id, + host=self.api_base, + poll_interval=poll_interval, # local eval refresh interval + personal_api_key=api_key + ) bind_contextvars(klass="PosthogAPIClient", project_id=project_id) project_root = os.path.abspath(os.getenv('PROJECT_ROOT', '.')) @@ -58,6 +65,31 @@ def api_key(self): def project_id(self): return self.project_id + + def is_enabled(self, key, user_id=None, person_properties=None, only_evaluate_locally=False, **kwargs): + """ + This uses posthog local evaluation to check if a feature flag is enabled + for a user without making a server request. + See local eval docs here: https://posthog.com/docs/feature-flags/local-evaluation + + Args: + name (String): the name/key of the feature flag + user (User): the user object + + Returns: + bool: true or false + """ + return self.posthog_client.get_feature_flag( + key, + user_id, + # Include any person properties, groups, or group properties required to evaluate the flag + person_properties=person_properties, + # Optional. Defaults to False. Set to True if you don't want PostHog to make a server request if it can't evaluate locally + only_evaluate_locally=only_evaluate_locally, + **kwargs + ) + + def list_features(self): path = f'/api/projects/{self.project_id}/feature_flags' with bound_contextvars(method="list_features"): @@ -96,13 +128,6 @@ def delete_feature(self, key): response = self._patch(path, payload) return self._map_single_response("PATCH", path, response) - def is_enabled(self, key): - feature = self.fetch_feature(key) - if feature == None: - raise FeatureNotFound(f"Feature {key} not found") - else: - return feature["active"] - def enable_feature(self, key): feature = self.fetch_feature(key) if feature == None: From 43ffc458cc8c6db855b5926154e316c391483d20 Mon Sep 17 00:00:00 2001 From: Alex Sharp Date: Thu, 6 Jun 2024 19:54:01 +0200 Subject: [PATCH 2/2] add posthog to poetry deps --- poetry.lock | 72 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index bbc6aaa..5dbcd3a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +[[package]] +name = "backoff" +version = "1.11.1" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "backoff-1.11.1-py2.py3-none-any.whl", hash = "sha256:61928f8fa48d52e4faa81875eecf308eccfb1016b018bb6bd21e05b5d90a96c5"}, + {file = "backoff-1.11.1.tar.gz", hash = "sha256:ccb962a2378418c667b3c979b504fdeb7d9e0d29c0579e3b13b86467177728cb"}, +] + [[package]] name = "certifi" version = "2024.2.2" @@ -157,6 +168,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "monotonic" +version = "1.6" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +optional = false +python-versions = "*" +files = [ + {file = "monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c"}, + {file = "monotonic-1.6.tar.gz", hash = "sha256:3a55207bcfed53ddd5c5bae174524062935efed17792e9de2ad0205ce9ad63f7"}, +] + [[package]] name = "packaging" version = "23.2" @@ -183,6 +205,29 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "posthog" +version = "3.5.0" +description = "Integrate PostHog into any python application." +optional = false +python-versions = "*" +files = [ + {file = "posthog-3.5.0-py2.py3-none-any.whl", hash = "sha256:3c672be7ba6f95d555ea207d4486c171d06657eb34b3ce25eb043bfe7b6b5b76"}, + {file = "posthog-3.5.0.tar.gz", hash = "sha256:8f7e3b2c6e8714d0c0c542a2109b83a7549f63b7113a133ab2763a89245ef2ef"}, +] + +[package.dependencies] +backoff = ">=1.10.0" +monotonic = ">=1.5" +python-dateutil = ">2.1" +requests = ">=2.7,<3.0" +six = ">=1.5" + +[package.extras] +dev = ["black", "flake8", "flake8-print", "isort", "pre-commit"] +sentry = ["django", "sentry-sdk"] +test = ["coverage", "flake8", "freezegun (==0.3.15)", "mock (>=2.0.0)", "pylint", "pytest", "pytest-timeout"] + [[package]] name = "pytest" version = "8.0.1" @@ -222,6 +267,20 @@ pytest = ">=5.0" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "requests" version = "2.31.0" @@ -243,6 +302,17 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "structlog" version = "24.1.0" @@ -291,4 +361,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = ">= 3.10" -content-hash = "8f6996cbf18ec66e93b3b235da0eca6883b8e404d3d2e0181cc46ced247ef255" +content-hash = "f843a29ac2e23e1c9359be8a9a572b466f536a3f5adf1a3a34e1004182dc1a71" diff --git a/pyproject.toml b/pyproject.toml index 87b7098..5cc9475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ keywords = ["feature flags", "feature gate"] python = ">= 3.10" structlog = "^24.1.0" requests = "^2.31.0" +posthog = "^3.5.0" [tool.poetry.group.test.dependencies] pytest = "^8.0.0"