diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py new file mode 100644 index 000000000..f17036812 --- /dev/null +++ b/bin/docs_scripts/generate_markdown.py @@ -0,0 +1,202 @@ +"""Generate the SDK reference Markdown from source with griffe2md, which never imports the SDK. + +A `posthog-capability: ` docstring line puts a symbol under that PostHog/sdk-specs +capability, in the sdk-specs README's order. Untagged symbols go to SDK utils. + + python bin/docs_scripts/generate_markdown.py --sdk-specs DIR [--major 8] +""" + +import argparse +import logging +import re +from pathlib import Path + +import griffe +import griffe2md + +ROOT = Path(__file__).resolve().parents[2] +TYPES = """args.OptionalCaptureArgs feature_flag_evaluations.FeatureFlagEvaluations types.FeatureFlagResult +types.FeatureFlag types.FlagsAndPayloads types.UnresolvedFlagReason feature_flags.InconclusiveMatchError +feature_flags.RequiresServerEvaluation capture_send.CaptureError tracing.span.Span emails.Emails +emails.EmailError emails.SendEmailResult""".split() +UTILS = """integrations.django.PosthogContextMiddleware integrations.flask.PosthogFlaskIntegration +integrations.asgi.PosthogASGIMiddleware integrations.celery.PosthogCeleryIntegration +request.set_socket_options request.enable_keep_alive request.disable_connection_reuse""".split() +FIRST = ["setup", "capture", "identify"] +TAG = re.compile(r"^[ \t]*posthog-capability:[ \t]*([a-z0-9-]+)[ \t]*$\n?", re.M) +ROLE = re.compile( + r":(?:py:)?(?:meth|func|class|attr|obj|data|exc|mod|const):`~?([^`]+)`" +) +CONFIG = { + **griffe2md.default_config, + "docstring_section_style": "table", + "show_root_full_path": False, + "show_signature_annotations": True, + "separate_signature": True, + "heading_level": 3, +} + + +def tag(obj) -> str | None: + """The capability tag. It and any Sphinx roles are stripped from the docstrings rendered.""" + found = TAG.search(obj.docstring.value) if obj.docstring else None + for o in [obj, *(m for m in obj.members.values() if not m.is_alias)]: + if o.docstring: + text = ROLE.sub(r"`\1`", TAG.sub("", o.docstring.value)) + o.docstring.value = re.sub(r"(? str: + md = griffe2md.render_object_docs(obj, CONFIG) + # Annotation links point at anchors this page doesn't have. + md = re.sub(r"\[([^\]]+)\]\(#[\w.]+\)", r"\1", md) + # The site reads Markdown as MDX 1: HTML must be JSX, and braces in are expressions. + md = re.sub(r"(.*?)", r"`\1`", md.replace("
", "
")) + # A parameter documented only in the docstring, like a **kwargs key, isn't required. + md = re.sub(r"^(`\w+` \| \| .*) \| \*required\*$", r"\1 | ", md, flags=re.M) + return re.sub(r"^### .*$", f"### `{name}`", md.strip(), count=1, flags=re.M) + + +def methods(cls) -> list: + """Public methods and properties, and `__init__`.""" + return [ + m + for n, m in cls.members.items() + if not m.is_alias + and (m.is_function or "property" in m.labels) + and (not n.startswith("_") or n == "__init__") + ] + + +def label(prefix: str, m) -> str: + return f"{prefix}(...)" if m.name == "__init__" else f"{prefix}.{m.name}" + + +def capabilities(checkout: str) -> dict[str, str]: + """Each sdk-specs capability's slug and title in sentence case, in the README's order.""" + readme = (Path(checkout) / "README.md").read_text() + rows = re.findall( + r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", readme, re.M + ) + acronyms = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} + titles = { + s: " ".join(acronyms.get(w.lower(), w.lower()) for w in n.split()) + for n, s in rows + } + # Setup, capture and identify come first; the rest follow the README. + order = [*FIRST, *(s for s in titles if s not in FIRST)] + return {s: titles[s][0].upper() + titles[s][1:] for s in order if s in titles} + + +def has(pkg, path: str) -> bool: + try: + pkg[path] + return True + except KeyError: + return False + + +def reference(pkg, caps: dict[str, str]) -> list[str]: + order = {slug: i for i, slug in enumerate(caps)} + utils, types = ([pkg[p] for p in paths if has(pkg, p)] for paths in (UTILS, TYPES)) + client = [(tag(m), m) for m in methods(pkg["client.Client"])] + asyncs = sorted( + ((tag(m), m) for m in methods(pkg["async_client.AsyncClient"])), + key=lambda e: order.get(e[0] or "", len(order)), + ) + module = [ + m + for n, m in pkg.members.items() + if not m.is_alias and m.is_function and not n.startswith("_") + ] + for obj in [*module, *utils, *types]: + tag(obj) + out = [] + for slug, title in caps.items(): + if tagged := [m for t, m in client if t == slug]: + out += [f"## {title}", *(entry(m, label("Client", m)) for m in tagged)] + out += ["## SDK utils", *(entry(m, label("Client", m)) for t, m in client if not t)] + out += [entry(o, f"posthog.{o.path.removeprefix('posthog.')}") for o in utils] + out += ["## AsyncClient", "Needs the `async` extra, `posthog[async]`."] + out += [entry(m, label("AsyncClient", m)) for _, m in asyncs] + out += ["## Module functions", *(entry(m, f"posthog.{m.name}") for m in module)] + return out + ["## Types", *(entry(o, o.name) for o in types)] + + +def front_matter(title: str, sections: list[str]) -> str: + """The docs site's front matter. Its ToC lists the page's H2 sections only.""" + toc = [] + for heading in [s[3:] for s in sections if s.startswith("## ")]: + anchor = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-") + toc.append(f' - value: "{heading}"\n url: {anchor}\n depth: 0') + head = [f'title: "{title}"', "sidebar: Docs", "showTitle: true"] + head += [ + "contentMaxWidthClass: max-w-full", + "platformLogo: python", + "tableOfContents:", + *toc, + ] + return "\n".join(["---", *head, "---"]) + + +def nest(markdown: str) -> str: + """Moves each heading down one level and drops the H1. Code stays as written.""" + out, in_fence = [], False + for line in markdown.strip().split("\n"): + in_fence ^= bool(re.match(r"\s*```", line)) + if in_fence or not re.match(r"#{1,5} ", line): + out.append(line) + elif not line.startswith("# "): + out.append("#" + line) + return "\n".join(out) + + +def write(name: str, sections: list[str]) -> None: + (ROOT / "references" / name).write_text("\n\n".join(sections) + "\n") + print(f"wrote references/{name}") + + +def main() -> None: + args = argparse.ArgumentParser(description="Generate the SDK reference Markdown.") + args.add_argument("--sdk-specs", required=True, help="PostHog/sdk-specs checkout") + args.add_argument( + "--major", type=int, help="target major, defaults to the declared one" + ) + opts = args.parse_args() + logging.getLogger("griffe").setLevel(logging.ERROR) + pkg = griffe.load( + "posthog", + search_paths=[str(ROOT)], + docstring_parser="google", + resolve_aliases=False, + ) + declared = int( + (ROOT / "posthog/version.py").read_text().split('"')[1].split(".")[0] + ) + major = opts.major or declared + started = nest((ROOT / "docs/getting-started.md").read_text()) + body = [ + "## Getting started", + started, + *reference(pkg, capabilities(opts.sdk_specs)), + ] + title = f"PostHog Python SDK {major}.x" + ( + "" if declared >= major else " (experimental)" + ) + write(f"posthog-python-{major}.md", [front_matter(title, body), *body]) + if declared >= major: + write( + "posthog-python-reference.md", + [front_matter("PostHog Python SDK", body), *body], + ) + if guides := sorted((ROOT / "docs").glob("migration-*.md"), reverse=True): + head = "---\ntitle: PostHog Python SDK migration guides\nsidebar: Docs\nshowTitle: true\n---" + write( + "posthog-python-migration.md", + [head, *(g.read_text().strip() for g in guides)], + ) + + +if __name__ == "__main__": + main() diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..15d31c292 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,37 @@ +# Getting started + +The Python SDK captures events, evaluates feature flags and reports errors from Python 3.10 or later. + +## Install + +```bash +pip install posthog +``` + +## Initialize + +Create one client for your app with your project token and host. Your project token and host are in your [project settings](https://app.posthog.com/project/settings). Keep the token in an environment variable rather than in code. + +```python +from posthog import Posthog + +posthog = Posthog("", host="") +``` + +## Capture an event + +```python +posthog.capture("user_signed_up", distinct_id="user_123", properties={"plan": "free"}) +``` + +`capture` queues the event and returns right away. A background thread sends it. + +## Shut down + +Call `shutdown()` before your process exits, so queued events are sent. + +```python +posthog.shutdown() +``` + +In an asyncio app, use `AsyncPosthog` as an async context manager instead. See [AsyncClient](#asyncclient). diff --git a/posthog/__init__.py b/posthog/__init__.py index d9f6d174f..81ee63f27 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -530,8 +530,7 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: } ) ``` - Category: - Events + posthog-capability: capture """ return _proxy("capture", event, **kwargs) @@ -566,8 +565,7 @@ def capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[st ) ``` - Category: - Events + posthog-capability: capture-ai """ return _proxy("capture_ai", event, **kwargs) @@ -598,8 +596,7 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: from posthog import set set(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'}) ``` - Category: - Identification + posthog-capability: identify """ return _proxy("set", **kwargs) @@ -632,8 +629,7 @@ def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: set_once(distinct_id='distinct_id', properties={'initial_url': '/blog'}) ``` - Category: - Identification + posthog-capability: identify """ return _proxy("set_once", **kwargs) @@ -671,8 +667,7 @@ def group_identify( 'employees': 11 }) ``` - Category: - Identification + posthog-capability: group-identify """ return _proxy( @@ -714,8 +709,7 @@ def alias( from posthog import alias alias(previous_id='distinct_id', distinct_id='alias_id') ``` - Category: - Identification + posthog-capability: alias """ return _proxy( @@ -754,8 +748,7 @@ def capture_exception( except Exception as e: capture_exception(e) ``` - Category: - Events + posthog-capability: capture-exception """ return _proxy("capture_exception", exception=exception, **kwargs) @@ -797,8 +790,7 @@ def feature_enabled( if is_my_flag_enabled: matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: is-feature-enabled """ return _proxy( "feature_enabled", @@ -850,8 +842,7 @@ def get_feature_flag( if enabled_variant == 'variant-key': matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flag """ return _proxy( "get_feature_flag", @@ -899,8 +890,7 @@ def get_all_flags( from posthog import get_all_flags get_all_flags('distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flags """ return _proxy( "get_all_flags", @@ -955,6 +945,8 @@ def get_feature_flag_result( print(f"Variant: {result.variant}") print(f"Payload: {result.payload}") ``` + + posthog-capability: get-feature-flag-result """ return _proxy( "get_feature_flag_result", @@ -1005,8 +997,7 @@ def get_feature_flag_payload( This function returns the payload only, not the FeatureFlagResult wrapper used internally to compute it. - Category: - Feature flags + posthog-capability: get-feature-flag-payload """ return _proxy( "get_feature_flag_payload", @@ -1070,8 +1061,7 @@ def get_all_flags_and_payloads( Returns: A dict with ``featureFlags`` and ``featureFlagPayloads`` entries. - Category: - Feature flags + posthog-capability: get-feature-flags-and-payloads """ return _proxy( "get_all_flags_and_payloads", @@ -1134,8 +1124,7 @@ def evaluate_flags( capture("page_viewed", distinct_id="user_123", flags=flags) ``` - Category: - Feature flags + posthog-capability: evaluate-flags """ return _proxy( "evaluate_flags", @@ -1163,8 +1152,7 @@ def feature_flag_definitions(): definitions = feature_flag_definitions() ``` - Category: - Feature flags + posthog-capability: flag-definition-loader """ return _proxy("feature_flag_definitions") @@ -1227,8 +1215,7 @@ def load_feature_flags(): load_feature_flags() ``` - Category: - Feature flags + posthog-capability: flag-definition-loader """ return _proxy("load_feature_flags") @@ -1270,8 +1257,7 @@ def flush(timeout_seconds: Optional[float] = 10) -> None: posthog.flush(timeout_seconds=10) ``` - Category: - Client management + posthog-capability: flush """ _proxy("flush", timeout_seconds=timeout_seconds) @@ -1290,8 +1276,7 @@ def join() -> None: join() ``` - Category: - Client management + posthog-capability: shutdown """ _proxy("join") @@ -1338,8 +1323,7 @@ def start_span( outgoing_headers = {"traceparent": span.traceparent()} ``` - Category: - Tracing + posthog-capability: traces """ return _proxy( "start_span", @@ -1367,8 +1351,7 @@ def get_active_span() -> Optional[Span]: headers["traceparent"] = span.traceparent() ``` - Category: - Tracing + posthog-capability: traces """ return _proxy("get_active_span") @@ -1403,8 +1386,7 @@ def shutdown() -> None: shutdown() ``` - Category: - Client management + posthog-capability: shutdown """ _proxy("shutdown") @@ -1422,8 +1404,7 @@ def setup() -> Client: ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops. - Category: - Initialization + posthog-capability: setup """ global default_client if not default_client: diff --git a/posthog/async_client.py b/posthog/async_client.py index 72f7cd2fe..ce756ffa3 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -138,6 +138,7 @@ def __init__( email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, ) -> None: + """posthog-capability: setup""" if flush_at <= 0: raise ValueError("flush_at must be greater than zero") if flush_interval <= 0: @@ -283,6 +284,8 @@ def emails(self) -> AsyncEmails: distinct_id="user_123", ) ``` + + posthog-capability: emails """ if self._emails is None: self._emails = _client_emails( @@ -567,7 +570,10 @@ def _build_capture_event( def capture( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: - """Queue an event without blocking for network delivery.""" + """Queue an event without blocking for network delivery. + + posthog-capability: capture + """ try: msg, disable_geoip, property_allowlist = self._build_capture_event( event, kwargs @@ -607,7 +613,10 @@ def _finish_inflight_operation(self, completion: asyncio.Future[None]) -> None: async def capture_immediate( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: - """Capture one event and wait until its delivery attempt completes.""" + """Capture one event and wait until its delivery attempt completes. + + posthog-capability: capture + """ current = asyncio.current_task() if current is None: # pragma: no cover - async functions always have a task return None @@ -690,6 +699,7 @@ def _build_person_properties_event( } def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: + """posthog-capability: identify""" try: msg = self._build_person_properties_event("$set", "$set", kwargs) if msg is None: @@ -702,6 +712,7 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: return None def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: + """posthog-capability: identify""" try: msg = self._build_person_properties_event("$set_once", "$set_once", kwargs) if msg is None: @@ -723,6 +734,7 @@ def group_identify( disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None, ) -> Optional[str]: + """posthog-capability: group-identify""" try: if not _stringify_id(group_type): self.log.warning( @@ -765,6 +777,7 @@ def alias( uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, ) -> Optional[str]: + """posthog-capability: alias""" try: resolved_previous_id = _stringify_id(previous_id) if not resolved_previous_id: @@ -812,7 +825,10 @@ def capture_exception( exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs], ) -> Optional[str]: - """Capture an exception. This method never raises, including in debug mode.""" + """Capture an exception. This method never raises, including in debug mode. + + posthog-capability: capture-exception + """ try: if exception is not None and exception_is_already_captured(exception): self.log.debug("Exception already captured, skipping") @@ -957,6 +973,8 @@ async def evaluate_flags( ``capture(flags=...)`` to attach the exact values used for branching. This client does not evaluate flags locally, so the snapshot's ``unresolved_flags`` is always empty. + + posthog-capability: evaluate-flags """ host = self._get_feature_flag_evaluations_host() if distinct_id is None: @@ -1184,6 +1202,7 @@ async def wait_for_workers() -> None: await asyncio.gather(queue_join, workers_finished, return_exceptions=True) async def flush(self, timeout_seconds: Optional[float] = 10) -> None: + """posthog-capability: flush""" if asyncio.current_task() in self._worker_tasks or _is_processing_event(): self._defer_lifecycle_call(self.flush(timeout_seconds)) return @@ -1214,6 +1233,7 @@ async def _close_transport(self) -> None: await http_client.aclose() async def shutdown(self) -> None: + """posthog-capability: shutdown""" current = asyncio.current_task() if ( current in self._worker_tasks @@ -1283,6 +1303,7 @@ async def shutdown(self) -> None: raise errors[0] async def join(self) -> None: + """posthog-capability: shutdown""" await self.shutdown() diff --git a/posthog/capture_compression.py b/posthog/capture_compression.py index 659c12fab..7b6760d06 100644 --- a/posthog/capture_compression.py +++ b/posthog/capture_compression.py @@ -30,6 +30,12 @@ class CaptureCompression(str, Enum): server decodes (``br`` is accepted by the server too but is intentionally left out for now). Inheriting from ``str`` keeps the members comparable to and serializable as their token values. + + Values: + NONE: Sends the body uncompressed. + GZIP: gzip, from the standard library. + DEFLATE: zlib (RFC 1950), from the standard library. + ZSTD: Faster and smaller, but needs ``pip install posthog[zstd]``. """ NONE = "none" diff --git a/posthog/client.py b/posthog/client.py index ce5eb3352..2776fb4a6 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -884,8 +884,7 @@ def __init__( posthog = Posthog('', host='') ``` - Category: - Initialization + posthog-capability: setup """ # api_key: This should be the Team API Key (token), public self.api_key = (project_api_key or "").strip() @@ -1274,6 +1273,8 @@ def emails(self) -> Emails: distinct_id="user_123", ) ``` + + posthog-capability: emails """ if self._emails is None: self._emails = _client_emails( @@ -1518,8 +1519,7 @@ def get_feature_variants( only these flags will be evaluated, improving performance. device_id: The device ID for this request. - Category: - Feature flags + posthog-capability: get-feature-flags """ resp_data = self.get_flags_decision( distinct_id, @@ -1605,8 +1605,7 @@ def get_feature_flags_and_payloads( result = posthog.get_feature_flags_and_payloads('') ``` - Category: - Feature flags + posthog-capability: get-feature-flags-and-payloads """ resp = self.get_flags_decision( distinct_id, @@ -1791,8 +1790,7 @@ def capture( posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'}) ``` - Category: - Capture + posthog-capability: capture """ return self._capture(event, self._analytics_lane, **kwargs) @@ -1812,8 +1810,7 @@ def capture_ai( size cap, posting to the dedicated AI ingestion endpoint. The payload is sent as given — no redaction or truncation is applied here. - Category: - Capture + posthog-capability: capture-ai """ if not event.startswith("$ai_"): self.log.debug( @@ -2016,8 +2013,7 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'}) ``` - Category: - Identification + posthog-capability: identify Note: This method will not raise exceptions. Errors are logged. """ @@ -2066,8 +2062,7 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'}) ``` - Category: - Identification + posthog-capability: identify Note: This method will not raise exceptions. Errors are logged. """ @@ -2131,8 +2126,7 @@ def group_identify( }) ``` - Category: - Identification + posthog-capability: group-identify Note: This method will not raise exceptions. Errors are logged. """ @@ -2201,8 +2195,7 @@ def alias( posthog.alias(previous_id='distinct_id', distinct_id='alias_id') ``` - Category: - Identification + posthog-capability: alias Note: This method will not raise exceptions. Errors are logged. """ @@ -2269,8 +2262,7 @@ def capture_exception( posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties) ``` - Category: - Error Tracking + posthog-capability: capture-exception """ distinct_id = kwargs.get("distinct_id", None) properties = kwargs.get("properties", None) @@ -2844,8 +2836,7 @@ def start_span( outgoing_headers = {"traceparent": span.traceparent()} ``` - Category: - Tracing + posthog-capability: traces """ pipeline = self._traces_pipeline if pipeline is None: @@ -2874,8 +2865,7 @@ def get_active_span(self) -> Optional[Span]: headers["traceparent"] = span.traceparent() ``` - Category: - Tracing + posthog-capability: traces """ return self._active_span_var.get() @@ -2917,6 +2907,8 @@ def flush(self, timeout_seconds: Optional[float] = 10) -> None: # Before a serverless invocation returns; keep the shared client open. posthog.flush(timeout_seconds=10) ``` + + posthog-capability: flush """ include_metrics_autocapture = not self._skip_metrics_autocapture_flush.get() if self._defer_flush_from_callback( @@ -3360,6 +3352,8 @@ def join(self) -> None: ```python posthog.join() ``` + + posthog-capability: shutdown """ with self._lifecycle_lock: self._join_requested = True @@ -3399,6 +3393,8 @@ def shutdown(self) -> None: ```python posthog.shutdown() ``` + + posthog-capability: shutdown """ with self._lifecycle_lock: if self._shutdown_complete: @@ -3835,8 +3831,7 @@ def load_feature_flags(self): posthog.load_feature_flags() ``` - Category: - Feature flags + posthog-capability: flag-definition-loader """ if self.disabled: with self._flag_definition_publication_lock: @@ -4020,8 +4015,7 @@ def feature_enabled( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: is-feature-enabled """ warnings.warn( "`feature_enabled` is deprecated and will be removed in a future major version. " @@ -4312,6 +4306,8 @@ def get_feature_flag_result( Returns: Optional[FeatureFlagResult]: The feature flag result or None if disabled/not found. + + posthog-capability: get-feature-flag-result """ return self._get_feature_flag_result( key, @@ -4361,8 +4357,7 @@ def get_feature_flag( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flag """ warnings.warn( "`get_feature_flag` is deprecated and will be removed in a future major version. " @@ -4470,8 +4465,7 @@ def get_feature_flag_payload( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flag-payload """ warnings.warn( "`get_feature_flag_payload` is deprecated and will be removed in a future major " @@ -4778,8 +4772,7 @@ def get_all_flags( posthog.get_all_flags('distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flags """ response = self.get_all_flags_and_payloads( distinct_id, @@ -4825,8 +4818,7 @@ def get_all_flags_and_payloads( posthog.get_all_flags_and_payloads('distinct_id_of_your_user') ``` - Category: - Feature flags + posthog-capability: get-feature-flags-and-payloads """ if self.disabled: return {"featureFlags": None, "featureFlagPayloads": None} @@ -4939,8 +4931,7 @@ def evaluate_flags( posthog.capture("page_viewed", distinct_id="user_123", flags=flags) ``` - Category: - Feature flags + posthog-capability: evaluate-flags """ host = self._get_feature_flag_evaluations_host() @@ -5266,8 +5257,7 @@ def feature_flag_definitions(self): The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions. - Category: - Feature flags + posthog-capability: flag-definition-loader """ return self.feature_flags diff --git a/posthog/emails.py b/posthog/emails.py index c5bb6cec8..4b856ddcf 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -125,6 +125,17 @@ class EmailError(Exception): Branch on ``code``, PostHog's stable error code, or on ``name``, a coarse category of the failure. The message never contains the secret key or the request body. + + Attributes: + name: A coarse category of the failure. + code: PostHog's stable error code. + message: What went wrong. It never contains the secret key or the request body. + status_code: The HTTP status of the response, or ``None`` when none arrived. + why: PostHog's explanation of the failure, when it gave one. + fix: What to change to fix it, when PostHog said. + docs_url: A docs page about the error, when PostHog gave one. + field: The request field the error is about, when there is one. + retry_after: Seconds to wait before retrying, when PostHog said. """ def __init__( @@ -161,7 +172,14 @@ class EmailWarning: @dataclass(frozen=True) class SendEmailResult: - """The queued email. ``id`` is its message ID.""" + """The queued email. ``id`` is its message ID. + + Attributes: + id: The email's message ID. + replayed: ``True`` when the response says PostHog replayed an earlier send for + the same ``idempotency_key`` instead of sending again. + warnings: Warnings PostHog returned about the request. + """ id: str replayed: bool = False @@ -185,6 +203,13 @@ class EmailStatus: ``reason`` says why a failed email was not sent, such as ``provider_error`` or ``recipient_suppressed``, or gives the bounce type of a bounced email: ``hard``, ``soft`` or ``unknown``. It is ``None`` otherwise. + + Attributes: + id: The email's message ID. + status: Where the email is now, one of the values above. + created_at: When PostHog queued the email. + status_at: When the email reached its current status. + reason: Why a failed email was not sent, or a bounced email's bounce type. """ id: str diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 6a16ee319..9db7634bc 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -99,6 +99,9 @@ class InconclusiveMatchError(Exception): """ Raised when local evaluation can't determine a flag's value. ``reason`` says why, as reported by ``FeatureFlagEvaluations.unresolved_flags``. + + Attributes: + reason: Why local evaluation couldn't resolve the flag. """ reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION @@ -116,6 +119,9 @@ class RequiresServerEvaluation(Exception): This error should propagate immediately to trigger API fallback, unlike InconclusiveMatchError which allows trying other conditions. + + Attributes: + reason: Why the flag needs server-side evaluation. """ reason: UnresolvedFlagReason = UnresolvedFlagReason.UNSUPPORTED_DEFINITION diff --git a/posthog/types.py b/posthog/types.py index 85938a939..6aa7a2a0c 100644 --- a/posthog/types.py +++ b/posthog/types.py @@ -84,6 +84,11 @@ class FeatureFlagEvaluationRuntime(str, Enum): ``ALL`` means the flag suits both client-side and server-side evaluation, so it matches either runtime. Inheriting from ``str`` keeps the members directly comparable to their ``"all"`` / ``"client"`` / ``"server"`` values. + + Values: + ALL: The flag suits both client-side and server-side evaluation. + CLIENT: The flag is meant for client-side evaluation. + SERVER: The flag is meant for server-side evaluation. """ ALL = "all" @@ -278,6 +283,7 @@ def __post_init__(self) -> None: ) def get_value(self) -> FlagValue: + """Return the variant for a multivariate flag, otherwise whether the flag is enabled.""" return self.variant or self.enabled @classmethod @@ -294,6 +300,7 @@ def from_json(cls, resp: Any) -> "FeatureFlag": def from_value_and_payload( cls, key: str, value: FlagValue, payload: Any ) -> "FeatureFlag": + """Build a flag from its value and payload. A string value is a variant, so the flag is enabled.""" enabled, variant = (True, value) if isinstance(value, str) else (value, None) return cls( key=key, @@ -542,7 +549,12 @@ class FlagsResponse(TypedDict, total=False): class FlagsAndPayloads(TypedDict, total=True): - """Feature flag values and payloads keyed by feature flag key.""" + """Feature flag values and payloads keyed by feature flag key. + + Attributes: + featureFlags: Each flag's value, keyed by flag key. + featureFlagPayloads: Each flag's payload, keyed by flag key. + """ featureFlags: Optional[dict[str, FlagValue]] featureFlagPayloads: Optional[dict[str, Any]] diff --git a/pyproject.toml b/pyproject.toml index d13cde587..ee69e9a0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ metrics = [ dev = [ "django-stubs", "griffe", + "griffe2md", "lxml", "mypy", "mypy-baseline", diff --git a/uv.lock b/uv.lock index 83c4611c2..270d74523 100644 --- a/uv.lock +++ b/uv.lock @@ -1392,6 +1392,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141, upload-time = "2026-03-27T11:34:47.721Z" }, ] +[[package]] +name = "griffe2md" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "griffelib" }, + { name = "jinja2" }, + { name = "mdformat" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/a8/18adadd26454832fb0beaea07e5f05b92f8830a112ec860d3c403914d651/griffe2md-1.5.0.tar.gz", hash = "sha256:58e184dffcd506ca97f54516dc07dd41f98ddc76eb17ba30518cf3dda18f6a51", size = 47309, upload-time = "2026-03-12T16:22:01.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/50/75d7902f3a82e77710123418e905302309e116c4d12050f04cf6f4ed9d4e/griffe2md-1.5.0-py3-none-any.whl", hash = "sha256:1becaff032cb02ffedd46057a83c218400bb586579c37010ec8504377108591d", size = 32367, upload-time = "2026-03-12T16:22:00.237Z" }, +] + +[[package]] +name = "griffe2md" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "griffelib" }, + { name = "jinja2" }, + { name = "mdformat" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/28/588e886370cb55d351895c86e5b9574293769a902b7f722d341f08d357d2/griffe2md-1.5.1.tar.gz", hash = "sha256:6ccf7ed1db2955c70b88eabf404f4f6a2c632d50790157fc3d3b7d269017dffd", size = 50114, upload-time = "2026-10-06T09:10:33.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/d3/c45c4e97e10b8ec466d472a35f10a651080ddec636dc805599a305f5d4ac/griffe2md-1.5.1-py3-none-any.whl", hash = "sha256:74ef2ed31926b241186f7c4c5f3f30725e4579468717d4c4f7f1565b41f40f8a", size = 37338, upload-time = "2026-10-06T09:10:32.153Z" }, +] + [[package]] name = "griffecli" version = "2.0.2" @@ -2270,6 +2310,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, ] +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3222,6 +3275,8 @@ async = [ dev = [ { name = "django-stubs" }, { name = "griffe" }, + { name = "griffe2md", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "griffe2md", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "lxml" }, { name = "mypy" }, { name = "mypy-baseline" }, @@ -3323,6 +3378,7 @@ requires-dist = [ { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, { name = "griffe", marker = "extra == 'dev'" }, + { name = "griffe2md", marker = "extra == 'dev'" }, { name = "httpx", marker = "extra == 'async'", specifier = ">=0.27.0,<1.0" }, { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.0" }, { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.9" },