diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py new file mode 100644 index 000000000..8ade17f58 --- /dev/null +++ b/bin/docs_scripts/generate_markdown.py @@ -0,0 +1,266 @@ +"""Generate the SDK reference Markdown from source with griffe2md, which never imports the SDK. + +docs/capabilities.json maps each entry's label to a PostHog/sdk-specs capability, shown in +the sdk-specs README's order. Its `utils` lists the Client members that go to SDK utils, and +its `next` maps labels that only the next major has. + + python bin/docs_scripts/generate_markdown.py --sdk-specs DIR [--major 8] +""" + +import argparse +import copy +import json +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 args.OptionalSetArgs emails.CodeEmailArgs""".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"] +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 clean(obj) -> None: + """Strips Sphinx roles, and the Category section the JSON docs read, from the docstrings.""" + for o in [obj, *(m for m in obj.members.values() if not m.is_alias)]: + if o.docstring: + text = ROLE.sub(r"`\1`", o.docstring.value) + head, _, tail = text.partition("\nCategory:\n") + text = head.rstrip() + "".join(f"\n{t}" for t in tail.split("\n")[1:]) + o.docstring.value = re.sub(r"(? str: + """griffe2md's signature template gives an unannotated parameter the previous one's + annotation and drops `async`, so griffe writes the signature instead. Private + parameters, which start with `_`, are left out.""" + prefix = "async " if "async" in function.labels else "" + public = copy.copy(function) + public.parameters = griffe.Parameters( + *(p for p in function.parameters if not p.name.startswith("_")) + ) + # A constructor is called by its class's name. + name = function.parent.name if function.name == "__init__" else str(name).strip() + return prefix + public.signature(name=name) + + +def whole_summaries(build): + """griffe2md's summary tables keep the first line of each docstring, which can stop + mid-sentence, so they keep the whole first paragraph instead.""" + + def section(objects, **kwargs): + out = build(objects, **kwargs) + docstrings = {o.name: o.docstring for o in objects} + for item in out.value: + if docstring := docstrings[item.name]: + item.description = " ".join(docstring.value.split("\n\n", 1)[0].split()) + return out + + return section + + +ENV = griffe2md.prepare_env() +ENV.filters["format_signature"] = signature +for kind in ("functions", "classes", "attributes"): + ENV.filters[f"as_{kind}_section"] = whole_summaries( + ENV.filters[f"as_{kind}_section"] + ) + + +def entry(obj, name: str) -> str: + template = ENV.get_template(f"{obj.kind.value}.md.jinja") + md = template.render(**griffe2md.prepare_context(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], current: bool) -> list[str]: + """`current` is false for a next major, which can remove what the map names.""" + 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)) + module = [ + m + for n, m in pkg.members.items() + if not m.is_alias and m.is_function and not n.startswith("_") + ] + sync, asyncs = ( + methods(pkg["client.Client"]), + methods(pkg["async_client.AsyncClient"]), + ) + found = {label("Client", m) for m in sync} | { + label("AsyncClient", m) for m in asyncs + } + found |= {f"posthog.{m.name}" for m in module} + for obj in [*sync, *asyncs, *module, *utils, *types]: + clean(obj) + data = json.loads((ROOT / "docs/capabilities.json").read_text()) + utils_names, later = set(data.pop("utils")), data.pop("next", {}) + mapped = {**later, **data} + for name, slug in mapped.items(): + if current and name not in found and name not in later: + raise SystemExit(f"docs/capabilities.json: no symbol for {name}") + if slug not in caps: + raise SystemExit( + f"docs/capabilities.json: {name}: {slug} isn't in sdk-specs" + ) + for name in sorted(found - mapped.keys() - utils_names): + if name.startswith("Client"): + raise SystemExit(f"docs/capabilities.json: map {name} or add it to utils") + client = [(mapped.get(label("Client", m)), m) for m in sync] + asyncs = sorted( + ((mapped.get(label("AsyncClient", m)), m) for m in asyncs), + key=lambda e: order.get(e[0] or "", len(order)), + ) + 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", + # A Returns section is one return value, even when its description wraps. + docstring_options={"returns_multiple_items": False}, + 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), declared >= major), + ] + 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/capabilities.json b/docs/capabilities.json new file mode 100644 index 000000000..7bf0d6031 --- /dev/null +++ b/docs/capabilities.json @@ -0,0 +1,82 @@ +{ + "AsyncClient(...)": "setup", + "AsyncClient.alias": "alias", + "AsyncClient.capture": "capture", + "AsyncClient.capture_exception": "capture-exception", + "AsyncClient.capture_immediate": "capture", + "AsyncClient.emails": "emails", + "AsyncClient.evaluate_flags": "evaluate-flags", + "AsyncClient.flush": "flush", + "AsyncClient.group_identify": "group-identify", + "AsyncClient.join": "shutdown", + "AsyncClient.set": "identify", + "AsyncClient.set_once": "identify", + "AsyncClient.shutdown": "shutdown", + "Client(...)": "setup", + "Client.alias": "alias", + "Client.capture": "capture", + "Client.capture_ai": "capture-ai", + "Client.capture_exception": "capture-exception", + "Client.emails": "emails", + "Client.evaluate_flags": "evaluate-flags", + "Client.feature_enabled": "is-feature-enabled", + "Client.feature_flag_definitions": "flag-definition-loader", + "Client.flush": "flush", + "Client.get_active_span": "traces", + "Client.get_all_flags": "get-feature-flags", + "Client.get_all_flags_and_payloads": "get-feature-flags-and-payloads", + "Client.get_feature_flag": "get-feature-flag", + "Client.get_feature_flag_payload": "get-feature-flag-payload", + "Client.get_feature_flag_result": "get-feature-flag-result", + "Client.get_feature_flags_and_payloads": "get-feature-flags-and-payloads", + "Client.get_feature_variants": "get-feature-flags", + "Client.group_identify": "group-identify", + "Client.join": "shutdown", + "Client.load_feature_flags": "flag-definition-loader", + "Client.set": "identify", + "Client.set_once": "identify", + "Client.shutdown": "shutdown", + "Client.start_span": "traces", + "posthog.alias": "alias", + "posthog.capture": "capture", + "posthog.capture_ai": "capture-ai", + "posthog.capture_exception": "capture-exception", + "posthog.evaluate_flags": "evaluate-flags", + "posthog.feature_enabled": "is-feature-enabled", + "posthog.feature_flag_definitions": "flag-definition-loader", + "posthog.flush": "flush", + "posthog.get_active_span": "traces", + "posthog.get_all_flags": "get-feature-flags", + "posthog.get_all_flags_and_payloads": "get-feature-flags-and-payloads", + "posthog.get_feature_flag": "get-feature-flag", + "posthog.get_feature_flag_payload": "get-feature-flag-payload", + "posthog.get_feature_flag_result": "get-feature-flag-result", + "posthog.group_identify": "group-identify", + "posthog.join": "shutdown", + "posthog.load_feature_flags": "flag-definition-loader", + "posthog.set": "identify", + "posthog.set_once": "identify", + "posthog.setup": "setup", + "posthog.shutdown": "shutdown", + "posthog.start_span": "traces", + "utils": [ + "Client.consumers", + "Client.feature_flags", + "Client.get_context_options", + "Client.get_feature_flag_evaluation_runtime", + "Client.get_feature_flag_keys_by_evaluation_runtime", + "Client.get_feature_payloads", + "Client.get_flags_decision", + "Client.get_remote_config_payload", + "Client.get_tags", + "Client.identify_context", + "Client.metrics", + "Client.new_context", + "Client.queue", + "Client.scoped", + "Client.set_context_device_id", + "Client.set_context_option", + "Client.set_context_session", + "Client.tag" + ] +} 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..705c4e5b7 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -493,7 +493,7 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: Examples: ```python # Context and capture usage - from posthog import new_context, identify_context, tag_context, capture + from posthog import new_context, identify_context, tag, capture # Enter a new context (e.g. a request/response cycle, an instance of a background job, etc) with new_context(): # Associate this context with some user, by distinct_id @@ -514,7 +514,7 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: capture('purchase', groups={'company': 'id:5'}) # Adding a tag to the current context will cause it to appear on all subsequent events - tag_context('some-tag', 'some-value') + tag('some-tag', 'some-value') capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict ``` 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 87ba50705..eca88d7d0 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -770,9 +770,8 @@ def __init__( poll_interval: Seconds between local feature flag definition refreshes. secret_key: A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config - payloads, and decrypted flag payloads. Example:: - - posthog.Client(project_api_key, secret_key="phx_...") + payloads, and decrypted flag payloads. For example, + `posthog.Client(project_api_key, secret_key="phx_...")`. personal_api_key: Deprecated alias for ``secret_key``. Still honored for backwards compatibility; prefer ``secret_key``, which also @@ -2273,7 +2272,7 @@ def capture_exception( # Some code that might fail pass except Exception as e: - posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties) + posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties) ``` Category: diff --git a/posthog/emails.py b/posthog/emails.py index c53528c7c..42e70b971 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -155,6 +155,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__( @@ -191,7 +202,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 @@ -294,6 +312,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_flag_evaluations.py b/posthog/feature_flag_evaluations.py index 28e5bf31e..1a9a170d8 100644 --- a/posthog/feature_flag_evaluations.py +++ b/posthog/feature_flag_evaluations.py @@ -115,12 +115,12 @@ class FeatureFlagEvaluations: ``flags`` option so the captured event carries the exact flag values the code branched on. - Example:: - - flags = posthog.evaluate_flags(distinct_id, person_properties={"plan": "enterprise"}) - if flags.is_enabled("new-dashboard"): - render_new_dashboard() - posthog.capture("page_viewed", distinct_id=distinct_id, flags=flags) + ```python + flags = posthog.evaluate_flags(distinct_id, person_properties={"plan": "enterprise"}) + if flags.is_enabled("new-dashboard"): + render_new_dashboard() + posthog.capture("page_viewed", distinct_id=distinct_id, flags=flags) + ``` To narrow the set of flags that get attached to a captured event, use the in-memory helpers :meth:`only` and :meth:`only_accessed`. To narrow the set of flags requested 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/test/test_reference_markdown.py b/posthog/test/test_reference_markdown.py new file mode 100644 index 000000000..bba347504 --- /dev/null +++ b/posthog/test/test_reference_markdown.py @@ -0,0 +1,67 @@ +"""Checks the Markdown reference generator's signatures, which griffe2md's own template got wrong.""" + +import importlib.util +from pathlib import Path + +import pytest + +griffe = pytest.importorskip("griffe") +pytest.importorskip("griffe2md") + +ROOT = Path(__file__).resolve().parents[2] +_spec = importlib.util.spec_from_file_location( + "generate_markdown", ROOT / "bin/docs_scripts/generate_markdown.py" +) +generator = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(generator) + + +@pytest.fixture(scope="module") +def pkg(): + return griffe.load( + "posthog", + search_paths=[str(ROOT)], + docstring_parser="google", + docstring_options={"returns_multiple_items": False}, + resolve_aliases=False, + ) + + +def declaration(pkg, path: str) -> str: + """The first line of the entry's code block.""" + md = generator.entry(pkg[path], path) + return md.split("```python\n", 1)[1].split("\n", 1)[0] + + +def test_unannotated_parameters_get_no_annotation(pkg): + line = declaration(pkg, "client.Client.__init__") + assert "debug=False" in line + assert "debug: str" not in line + assert "_use_ai_lane" not in line, "private parameters stay out of the signature" + + +def test_coroutines_are_marked_async(pkg): + assert declaration(pkg, "async_client.AsyncClient.flush").startswith("async ") + assert not declaration(pkg, "async_client.AsyncClient.capture").startswith("async ") + + +def test_keyword_only_marker_is_kept(pkg): + assert ", *, groups" in declaration(pkg, "client.Client.evaluate_flags") + + +def test_constructor_is_called_by_its_class_name(pkg): + assert declaration(pkg, "client.Client.__init__").startswith("Client(") + + +def test_a_wrapped_returns_description_is_one_row(pkg): + md = generator.entry( + pkg["client.Client.get_feature_flag_payload"], "Client.get_feature_flag_payload" + ) + assert md.count("`Optional[object]` |") == 1 + + +def test_summaries_keep_the_whole_first_paragraph(pkg): + cls = pkg["feature_flag_evaluations.FeatureFlagEvaluations"] + generator.clean(cls) + md = generator.entry(cls, "FeatureFlagEvaluations") + assert "on the first access per (distinct_id, flag, value) tuple" in md 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 cc6c9414c..e6241040e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ metrics = [ dev = [ "django-stubs", "griffe", + "griffe2md", "lxml", "mypy", "mypy-baseline", @@ -123,6 +124,9 @@ test = [ "opentelemetry-instrumentation-django>=0.60b0", "pytest-bdd>=8.1.0", "zstandard>=0.23.0", + # posthog/test/test_reference_markdown.py checks the reference generator's output. + "griffe", + "griffe2md", # gevent 25.4.1+ replaces queue.Queue, exercising the compatibility path. "gevent>=25.4.1; implementation_name == 'cpython'", ] diff --git a/uv.lock b/uv.lock index 0a6ebf568..7f0750642 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" }, @@ -3263,6 +3318,9 @@ test = [ { name = "freezegun" }, { name = "gevent", marker = "implementation_name == 'cpython'" }, { name = "google-genai" }, + { 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 = "jsonschema" }, { name = "langchain" }, { name = "langchain-anthropic" }, @@ -3323,6 +3381,9 @@ 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 = "griffe", marker = "extra == 'test'" }, + { name = "griffe2md", marker = "extra == 'dev'" }, + { name = "griffe2md", marker = "extra == 'test'" }, { 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" },