From d811ca2bf1e361a441418c6c8235513d7e213b26 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 4 Nov 2026 14:38:45 -0500 Subject: [PATCH 01/12] docs: add the Markdown reference adapter Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/check_reference.py | 195 ++++++++++ bin/docs_scripts/generate_markdown.py | 522 ++++++++++++++++++++++++++ bin/docs_scripts/render.mjs | 200 ++++++++++ bin/docs_scripts/render.test.mjs | 167 ++++++++ bin/docs_scripts/sdk_specs.py | 43 +++ docs/getting-started.md | 37 ++ posthog/__init__.py | 65 ++-- posthog/async_client.py | 27 +- posthog/capture_compression.py | 6 + posthog/client.py | 70 ++-- posthog/emails.py | 27 +- posthog/feature_flags.py | 6 + posthog/types.py | 14 +- 13 files changed, 1292 insertions(+), 87 deletions(-) create mode 100644 bin/docs_scripts/check_reference.py create mode 100644 bin/docs_scripts/generate_markdown.py create mode 100644 bin/docs_scripts/render.mjs create mode 100644 bin/docs_scripts/render.test.mjs create mode 100644 bin/docs_scripts/sdk_specs.py create mode 100644 docs/getting-started.md diff --git a/bin/docs_scripts/check_reference.py b/bin/docs_scripts/check_reference.py new file mode 100644 index 000000000..e96540e02 --- /dev/null +++ b/bin/docs_scripts/check_reference.py @@ -0,0 +1,195 @@ +"""End condition for the Python reference page. + +Builds its own inventory of the public Python surface with `ast`, independent of the extractor, +then checks the generated page against it and against the release rules: + + python bin/docs_scripts/check_reference.py + +Exits 1 and lists every failure. +""" + +import ast +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import sdk_specs # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] + +STRUCTURE = {"Getting started", "SDK utils", "Configs", "Types"} +BANNED = { + "Used by section": r"^(#+ |\*\*)?Used by\b[:*]", + "preview wording": r"\b(preview|candidate)\b", + "commit SHA": r"\b[0-9a-f]{12,40}\b", + "empty class stub": r"^class \w+\(\):\s*$", + "scraped icon text": r"svg/svg", + "Sphinx role": r":(meth|func|class|attr|obj|data|exc|mod):`", + "Sphinx literal block": r"::$", + "variadic default": r"\*\*\w+[^,)\n]* = \{\}", + "private parameter": r"^- `_\w+`", + "old example label": r"\*\*(Good|Bad) example", + "unlabeled example": r"\*\*Example\.\*\*", + "list instead of table": r"^- `\w+` \(", +} + + +def public_defs(path: Path, class_name: str | None) -> list[str]: + tree = ast.parse(path.read_text()) + body = tree.body + if class_name: + body = next( + n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name + ).body + names = [ + n.name for n in body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + return sorted( + {n for n in names if not n.startswith("_")} | ({"__init__"} & set(names)) + ) + + +def slugify(text: str) -> str: + return re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-") + + +def main(md_path: str, specs: str) -> int: + root = ROOT + md = Path(md_path).read_text() + caps = sdk_specs.capabilities(specs) + failures = [] + headings = set(re.findall(r"^#{3,4} `([^`]+)`$", md, re.M)) + + inventory = { + "Client": public_defs(root / "posthog/client.py", "Client"), + "AsyncClient": public_defs(root / "posthog/async_client.py", "AsyncClient"), + "posthog": public_defs(root / "posthog/__init__.py", None), + } + for owner, names in inventory.items(): + for name in names: + label = ( + f"{owner}({owner == 'posthog' and '' or '...'})" + if name == "__init__" + else f"{owner}.{name}" + ) + if name == "__init__": + label = f"{owner}(...)" + if label not in headings: + failures.append(f"missing public symbol {label}") + + status = re.search(r"^status: (current|experimental)$", md, re.M) + if not status: + failures.append("front matter has no current or experimental status") + h2 = re.findall(r"^## (.+)$", md, re.M) + allowed = ( + STRUCTURE + | {c["title"] for c in caps["capabilities"].values()} + | {"AsyncClient", "Module functions"} + ) + failures += [f"unexpected top-level heading {h}" for h in h2 if h not in allowed] + + blocks = len( + re.findall( + r"^(Parameters|Fields|Settings) \(\d+\)$", md, re.M + ) + ) + if blocks != md.count("
"): + failures.append( + f"{blocks} member blocks but {md.count('
')} details blocks" + ) + tables = len( + re.findall( + r"^(Parameters|Fields|Settings) \(\d+\)\n\n\| (Parameter|Field|Setting) \|\n\| --- \|$", + md, + re.M, + ) + ) + if tables != blocks: + failures.append( + f"{blocks} member blocks but only {tables} open with a one-column table" + ) + if re.search(r"
", md) or re.search( + r"^#{3,6} `\w+` (parameter|field|setting)$", md, re.M + ): + failures.append("a bare
or a member heading is left on the page") + # Replay the site's heading slugger: every capability heading must keep its plain sdk-specs anchor. + seen: dict[str, int] = {} + ids: list[tuple[str, str, str]] = [] + in_fence = False + for line in md.split("\n"): + if line.lstrip().startswith("```"): + in_fence = not in_fence + heading = None if in_fence else re.match(r"^(#{1,6}) (.+)$", line) + if heading: + base = slugify(heading.group(2)) + n = seen.get(base, 0) + seen[base] = n + 1 + ids.append( + (heading.group(1), heading.group(2), base if n == 0 else f"{base}-{n}") + ) + for level, text, anchor in ids: + cap = caps["capabilities"].get(slugify(text)) + if level == "##" and cap and cap["title"] == text and anchor != slugify(text): + failures.append(f"capability {text!r} lost its anchor, got #{anchor}") + failures += [ + f"escaped pipe inside code span: {s!r}" + for s in re.findall(r"`([^`\n]+)`", md) + if "\\|" in s + ] + for block in re.findall( + r"Fields \(\d+\).*?
", md, re.S + ): + failures += [ + f"field default is a name, not a value: {d!r}" + for d in re.findall(r"\*\*Default:\*\* `([^`]+)`", block) + if re.fullmatch(r"[a-z_][\w.]*( or .+)?", d) + and d not in ("None", "True", "False") + ] + for entry in re.split(r"^(?=#{3,4} `)", md, flags=re.M): + if ( + entry.startswith(("### `AsyncClient", "#### `AsyncClient")) + and "**Available in:**" not in entry.split("\n#", 1)[0] + ): + failures.append( + f"{entry.splitlines()[0]} doesn't say it needs the async extra" + ) + types = md.split("## Types", 1)[-1] + for name in ( + "CaptureCompression", + "FeatureFlagEvaluationRuntime", + "UnresolvedFlagReason", + ): + section = types.split(f"### `{name}`", 1)[-1].split("\n### ", 1)[0] + if f"### `{name}`" in types and "**Value:**" not in section: + failures.append(f"enum {name} doesn't show its member values") + failures += [ + f"type method {m} has no description" + for m in re.findall( + r"^#### `([^`]+)`\n\n(?:```|
|\*\*Returns|\[Source)", types, re.M + ) + ] + for block in re.findall( + r"Fields \(\d+\)(.*?)
", types, re.S + ): + failures += [ + f"type field {r.split('`')[1]} has no description" + for r in re.findall(r"^\| (`.+) \|$", block, re.M) + if not re.search(r"
(?!\*\*)[^<]", r) + ] + prose = re.sub(r"```.*?```", "", md, flags=re.S) + for name, pattern in BANNED.items(): + for match in re.finditer(pattern, prose, re.M | re.I): + failures.append(f"{name}: {match.group(0)!r}") + + for failure in failures: + print(f"FAIL {failure}") + total = sum(len(v) for v in inventory.values()) + print( + f"{len(failures)} failures, {total} public symbols checked, {len(h2)} top-level headings" + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(*sys.argv[1:])) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py new file mode 100644 index 000000000..9407e5324 --- /dev/null +++ b/bin/docs_scripts/generate_markdown.py @@ -0,0 +1,522 @@ +"""Generate this SDK's reference Markdown from source. + +Griffe reads the package statically, so the SDK is never imported. Docstrings are parsed as +Google style. A `posthog-capability: ` line maps a symbol to its sdk-specs capability, +and unmapped symbols land in SDK utils under their `Category:`. The shared renderer turns the +symbol list into Markdown, so every SDK's page has the same shape. + + python bin/docs_scripts/generate_markdown.py --sdk-specs [--major 8 --source-ref v1] +""" + +import argparse +import json +import logging +import re +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path +from typing import Any + +import griffe + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import sdk_specs # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = "https://github.com/PostHog/posthog-python/blob/v{version}/{path}#L{line}" +REFERENCES = [ + ("Client", "client.Client", "Client"), + ("AsyncClient", "async_client.AsyncClient", "AsyncClient"), +] +TYPES = """args.OptionalCaptureArgs args.OptionalSetArgs feature_flag_evaluations.FeatureFlagEvaluations +types.FeatureFlagResult types.FeatureFlag types.FlagsAndPayloads types.FeatureFlagEvaluationRuntime +types.UnresolvedFlagReason flag_definition_cache.FlagDefinitionCacheProvider flag_definition_cache.FlagDefinitionCacheData +feature_flags.InconclusiveMatchError feature_flags.RequiresServerEvaluation capture_send.CaptureError +capture_compression.CaptureCompression tracing.span.Span +metrics_capture.PostHogMetrics emails.Emails emails.AsyncEmails emails.EmailError emails.EmailStatus +emails.SendEmailResult ai.prompts.Prompts""".split() +UTILS = { + "Framework integrations": """integrations.django.PosthogContextMiddleware integrations.flask.PosthogFlaskIntegration + integrations.asgi.PosthogASGIMiddleware integrations.celery.PosthogCeleryIntegration + integrations.drf.create_exception_handler integrations.otel.PostHogContextSpanProcessor + integrations.otel.enrich_current_span""".split(), + "HTTP connection": "request.set_socket_options request.enable_keep_alive request.disable_connection_reuse".split(), +} +WRAPPERS = "openai anthropic gemini langchain openai_agents claude_agent_sdk".split() +CONFIGS = [("integrations.django.PosthogContextMiddleware", "Django settings")] +# Where a symbol works when that isn't everywhere Python runs, by module. Extras are checked against pyproject.toml. +AVAILABLE_IN = { + "async_client": "the `async` extra, `posthog[async]`", + "integrations.django": "Django apps", + "integrations.drf": "Django REST framework apps", + "integrations.flask": "Flask apps", + "integrations.celery": "Celery workers", + "integrations.asgi": "ASGI apps, such as FastAPI and Starlette", + "integrations.otel": "the `otel` extra, `posthog[otel]`", + "ai.langchain": "the `langchain` extra, `posthog[langchain]`", + "ai.openai": "apps with the `openai` package", + "ai.anthropic": "apps with the `anthropic` package", + "ai.gemini": "apps with the Google Gen AI package", + "ai.openai_agents": "apps with the `openai-agents` package", + "ai.claude_agent_sdk": "apps with the `claude-agent-sdk` package", +} +LITERAL_VALUE = re.compile( + r"""^(None|True|False|-?\d[\d_.e]*|(["']).*\2|\[\]|\{\}|\(\))$""" +) +TAG = re.compile(r"^[ \t]*posthog-capability:[ \t]*([a-z0-9-]+)[ \t]*$\n?", re.M) +FENCE = re.compile(r"```(\w*)\n(.*?)```", re.S) +LABEL = re.compile(r"#\s*(Correct example|Counter example \(incorrect\))\.?:?\s*(.*)") +ROLE = re.compile( + r":(?:py:)?(?:meth|func|class|attr|obj|data|exc|mod|const):`~?([^`]+)`" +) +LITERAL = re.compile(r"^([^\n]*?)::\n\n((?:(?:[ \t]+[^\n]*)?\n)+)", re.M) + + +def text(value) -> str: + return str(value) if value is not None else "" + + +def md(value: str) -> str: + """Converts the Sphinx markup in these docstrings to Markdown: roles, double backticks, literal blocks.""" + value = re.sub(r"``([^`]+)``", r"`\1`", ROLE.sub(r"`\1`", value or "")) + + def block(m: re.Match) -> str: + lead = m.group(1) + ":\n\n" if m.group(1).strip() else "" + return lead + "```python\n" + textwrap.dedent(m.group(2)).strip() + "\n```\n\n" + + return LITERAL.sub(block, value + "\n").strip() + + +def get(pkg, path: str): + try: + return pkg[path] + except KeyError: + return None + + +def available_in(path: str) -> str | None: + module = path.removeprefix("posthog.") + return next( + ( + where + for prefix, where in AVAILABLE_IN.items() + if module == prefix or module.startswith(prefix + ".") + ), + None, + ) + + +def check_extras() -> None: + extras = re.search( + r"^\[project\.optional-dependencies\]\n(.*?)(?=^\[)", + (ROOT / "pyproject.toml").read_text(), + re.M | re.S, + ) + named = set(re.findall(r"^(\w+) = ", extras.group(1), re.M)) if extras else set() + missing = { + x for w in AVAILABLE_IN.values() for x in re.findall(r"posthog\[(\w+)\]", w) + } - named + if missing: + raise SystemExit( + f"AVAILABLE_IN names extras pyproject.toml doesn't define: {sorted(missing)}" + ) + + +def source(obj, version: str) -> str: + return SOURCE.format( + version=version, path=obj.relative_package_filepath, line=obj.lineno + ) + + +def parse(obj) -> tuple[str | None, list]: + raw = obj.docstring.value if obj.docstring else "" + tag = TAG.search(raw) + body = TAG.sub("", raw).strip() + sections = griffe.Docstring(body, parent=obj).parse("google") if body else [] + return (tag.group(1) if tag else None), sections + + +def examples(value) -> list[dict]: + out = [] + for _, code in FENCE.findall("\n".join(t for _, t in value)): + label = why = None + head, _, rest = code.lstrip("\n").partition("\n") + match = LABEL.match(head.strip()) + if match: + label, why, code = ( + "correct" if match.group(1).startswith("Correct") else "incorrect", + match.group(2) or None, + rest, + ) + out.append({"code": code, "label": label, "why": why}) + return out + + +def typed_dict_fields(pkg, annotation: str) -> dict: + match = re.fullmatch(r"Unpack\[(\w+)\]", annotation) + cls = get(pkg, f"args.{match.group(1)}") if match else None + return {n: text(a.annotation) for n, a in cls.attributes.items()} if cls else {} + + +def params(pkg, fn, documented: dict) -> list[dict]: + out = [] + for p in fn.parameters: + if p.name in ("self", "cls") or p.name.startswith("_"): + continue + kind = p.kind.value + if kind == "variadic keyword": + fields = typed_dict_fields(pkg, text(p.annotation)) + for name in [n for n in documented if n in fields]: + out.append( + { + "name": name, + "type": fields[name], + "description": md(documented.pop(name)), + } + ) + if fields: + continue + star = {"variadic positional": "*", "variadic keyword": "**"}.get(kind, "") + required = p.default is None and not star + out.append( + { + "name": star + p.name, + "type": text(p.annotation), + "default": None if star else p.default and text(p.default), + "required": required, + "description": md(documented.pop(p.name, "")), + } + ) + out += [{"name": n, "description": md(d)} for n, d in documented.items()] + return out + + +def signature(fn, name: str) -> str: + parts = [] + for p in fn.parameters: + if p.name in ("self", "cls") or p.name.startswith("_"): + continue + star = {"variadic positional": "*", "variadic keyword": "**"}.get( + p.kind.value, "" + ) + part = star + p.name + (f": {p.annotation}" if p.annotation is not None else "") + parts.append( + part + (f" = {p.default}" if p.default is not None and not star else "") + ) + ret = f" -> {fn.returns}" if fn.returns is not None and name[0].islower() else "" + prefix = "async " if "async" in fn.labels else "" + line = f"{prefix}{name}({', '.join(parts)}){ret}" + return ( + line + if len(line) <= 88 + else f"{prefix}{name}(\n" + "".join(f" {p},\n" for p in parts) + f"){ret}" + ) + + +def entry(pkg, obj, qualname: str, version: str, group: str | None = None) -> dict: + slug, sections = parse(obj) + e: dict[str, Any] = { + "qualname": qualname, + "capability": slug, + "group": group, + "description": "", + "examples": [], + "source": source(obj, version), + "availableIn": available_in(obj.module.path), + } + texts, notes, documented = [], [], {} + for s in sections: + kind = s.kind.value + if kind == "text": + texts.append(s.value) + elif kind in ("parameters", "other parameters"): + documented.update({p.name: p.description for p in s.value}) + elif kind == "returns": + e["returns"] = { + "type": text(s.value[0].annotation) + or text(getattr(obj, "returns", "")), + "description": md(" ".join(r.description for r in s.value)), + } + elif kind == "raises": + e["raises"] = [ + {"type": text(r.annotation), "description": md(r.description)} + for r in s.value + ] + elif kind == "examples": + e["examples"] += examples(s.value) + elif kind == "deprecated": + e["deprecated"] = s.value.description + elif kind == "admonition" and s.title in ("Example", "Examples"): + body = s.value.description + e["examples"] += ( + examples([(None, body)]) + if "```" in body + else [{"code": body, "label": None, "why": None}] + ) + elif kind == "admonition" and s.title == "Category": + e["group"] = group or s.value.description.strip() + elif kind == "admonition": + notes.append(f"**{s.title}.** {md(s.value.description)}") + e["description"], e["notes"] = md("\n\n".join(texts)), "\n\n".join(notes) + if obj.is_function: + name = qualname.removesuffix("(...)") + e["signature"] = signature(obj, name if obj.name == "__init__" else obj.name) + e["params"] = params(pkg, obj, documented) + if not e.get("returns") and obj.returns is not None and obj.name != "__init__": + e["returns"] = {"type": text(obj.returns)} + elif obj.is_class and "__init__" in obj.members: + init = obj.members["__init__"] + e["signature"] = signature(init, obj.name) + e["params"] = params( + pkg, + init, + documented + | { + p.name: p.description + for s in parse(init)[1] + if s.kind.value == "parameters" + for p in s.value + }, + ) + elif obj.is_attribute: + e["signature"] = ( + f"{qualname}: {obj.annotation}" if obj.annotation is not None else None + ) + return e + + +def members(cls) -> list: + return [ + m + for n, m in cls.members.items() + if not m.is_alias + and (not n.startswith("_") or n == "__init__") + and (m.is_function or "property" in m.labels) + ] + + +def documented_fields(cls) -> dict: + """Field descriptions from the class's `Attributes:`, `Args:` and `Values:` sections, and from `__init__`'s `Args:`.""" + sections = parse(cls)[1] + ( + parse(cls.members["__init__"])[1] if "__init__" in cls.members else [] + ) + found = { + item.name: item.description + for s in sections + if s.kind.value in ("attributes", "parameters") + for item in s.value + } + for s in sections: + if s.kind.value == "admonition" and s.title == "Values": + found |= { + n: " ".join(d.split()) + for n, d in re.findall( + r"^(\w+): (.*?)(?=^\w+:|\Z)", s.value.description, re.M | re.S + ) + } + return found + + +def is_data(cls) -> bool: + """Dataclasses, TypedDicts, enums and errors carry fields readers use. Service objects carry only state.""" + bases = " ".join(text(b) for b in cls.bases) + return any("dataclass" in text(d.value) for d in cls.decorators) or any( + k in bases for k in ("TypedDict", "Enum", "Exception", "Error") + ) + + +def type_info(pkg, cls, version: str) -> dict: + _, sections = parse(cls) + documented = documented_fields(cls) + # Only a literal is a default. A field set from an __init__ argument shows that argument's name instead. + enum = any("Enum" in text(b) for b in cls.bases) + + def value(a): + return text(a.value) if LITERAL_VALUE.match(text(a.value)) else None + + fields = [ + { + "name": n, + "type": None if enum else text(a.annotation), + "default": None if enum else value(a), + "value": value(a) if enum else None, + "description": md( + a.docstring.value if a.docstring else documented.get(n, "") + ), + } + for n, a in cls.attributes.items() + if is_data(cls) and not n.startswith("_") and "property" not in a.labels + ] + methods = [ + entry(pkg, m, f"{cls.name}.{m.name}", version) + for m in members(cls) + if m.name != "__init__" + ] + description = md("\n\n".join(s.value for s in sections if s.kind.value == "text")) + return { + "name": cls.name, + "description": description, + "fields": fields, + "methods": methods, + "source": source(cls, version), + } + + +def configs(pkg) -> list[dict]: + out = [] + for path, where in CONFIGS: + cls = get(pkg, path) + doc = cls.docstring.value if cls and cls.docstring else "" + for name, desc in re.findall( + r"^\s*- `(POSTHOG_[A-Z_]+)`: (.*?)(?=^\s*- `|^\s*$)", doc, re.M | re.S + ): + out.append( + { + "name": name, + "where": where, + "description": md(" ".join(desc.split())), + } + ) + return out + + +def page(pkg, version: str, major: int, status: str) -> dict: + refs: list[dict[str, Any]] = [] + for title, path, prefix in REFERENCES: + cls = pkg[path] + refs.append( + { + "title": title, + "entries": [ + entry( + pkg, + m, + f"{prefix}(...)" + if m.name == "__init__" + else f"{prefix}.{m.name}", + version, + ) + for m in members(cls) + ], + } + ) + module = [ + entry(pkg, m, f"posthog.{n}", version) + for n, m in pkg.members.items() + if not m.is_alias and m.is_function and not n.startswith("_") + ] + refs.append({"title": "Module functions", "entries": module}) + for group, paths in UTILS.items(): + for path in paths: + if (obj := get(pkg, path)) is not None: + refs[0]["entries"].append( + entry(pkg, obj, f"posthog.{path}", version, group) + ) + if obj.is_class: + refs[0]["entries"] += [ + entry(pkg, m, f"{obj.name}.{m.name}", version, group) + for m in members(obj) + if m.name != "__init__" + ] + for name in WRAPPERS: + if (mod := get(pkg, f"ai.{name}")) is not None: + names = ", ".join( + f"`{n}`" + for n in mod.members + if not n.startswith("_") and n[0].isupper() + ) + summary = md(mod.docstring.value.split("\n\n")[0]) if mod.docstring else "" + refs[0]["entries"].append( + { + "qualname": f"posthog.ai.{name}", + "group": "AI wrapper clients", + "availableIn": available_in(f"ai.{name}"), + "description": " ".join( + filter(None, [summary, f"Exports {names}."]) + ), + } + ) + # Newest guide first, so the migration page opens on the latest upgrade. + migrations = sorted((ROOT / "docs").glob("migration-*.md"), reverse=True) + return { + "sdk": "python", + "title": "PostHog Python SDK", + "version": version, + "major": major, + "status": status, + "language": "python", + "gettingStarted": (ROOT / "docs/getting-started.md").read_text(), + "references": refs, + "configs": configs(pkg), + "types": [type_info(pkg, c, version) for p in TYPES if (c := get(pkg, p))], + "migrations": [m.read_text() for m in migrations], + } + + +def main() -> None: + args = argparse.ArgumentParser() + args.add_argument( + "--sdk-specs", + required=True, + help="Checkout of PostHog/sdk-specs to take capabilities from", + ) + args.add_argument( + "--renderer", default=str(Path(__file__).resolve().parent / "render.mjs") + ) + args.add_argument( + "--major", + type=int, + help="Major this branch targets, defaults to the declared version's major", + ) + args.add_argument( + "--source-ref", + help="Git ref for source links, defaults to the release tag v", + ) + opts = args.parse_args() + logging.getLogger("griffe").setLevel(logging.ERROR) + check_extras() + pkg = griffe.load( + "posthog", + search_paths=[str(ROOT)], + docstring_parser="google", + resolve_aliases=False, + ) + found = re.search(r'VERSION = "([^"]+)"', (ROOT / "posthog/version.py").read_text()) + if found is None: + raise SystemExit("posthog/version.py has no VERSION") + declared = found.group(1) + major = opts.major or int(declared.split(".")[0]) + # A branch that targets a newer major than it has released documents an experimental reference. + status = "current" if int(declared.split(".")[0]) >= major else "experimental" + version = declared if status == "current" else "unreleased" + global SOURCE + SOURCE = SOURCE.replace("v{version}", opts.source_ref or "v{version}") + data = page(pkg, version, major, status) + outputs = { + f"posthog-python-{data['major']}.md": [], + "posthog-python-migration.md": ["--migration"], + } + with ( + tempfile.NamedTemporaryFile("w", suffix=".json") as f, + tempfile.NamedTemporaryFile("w", suffix=".json") as caps, + ): + json.dump(data, f) + json.dump(sdk_specs.capabilities(opts.sdk_specs), caps) + f.flush() + caps.flush() + for name, flag in outputs.items(): + md = subprocess.run( + ["node", opts.renderer, f.name, caps.name, *flag], + check=True, + capture_output=True, + text=True, + ).stdout + if md: + (ROOT / "references" / name).write_text(md) + print(f"wrote references/{name}") + + +if __name__ == "__main__": + main() diff --git a/bin/docs_scripts/render.mjs b/bin/docs_scripts/render.mjs new file mode 100644 index 000000000..aee720468 --- /dev/null +++ b/bin/docs_scripts/render.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +// Renders one SDK's symbol list into its reference page Markdown. +// +// Every SDK's extractor hands this the same JSON shape, so every page comes out the same way. +// Capabilities come from sdk-specs: an entry's `capability` slug picks its area and heading. +// Entries with no capability go to SDK utils. Usage: +// +// node render.mjs > reference.md +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +/** The heading id the docs site and GitHub give a heading with this text. */ +export function slugify(text) { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}\- ]/gu, '') + .replace(/ /g, '-') +} + +const fence = (code, lang) => '```' + lang + '\n' + code.trim() + '\n```' +const code = (text) => (text ? '`' + String(text).replace(/\s*\n\s*/g, ' ').trim() + '`' : '') +/** + * Table cell text. Code fences become inline code, paragraphs join with
, and pipes are + * escaped. A pipe inside a code span splits it into spans around `\|`, since the site's MDX shows + * `\|` inside one span literally. + */ +const cellText = (text) => + String(text ?? '') + .trim() + .replace(/```\w*\n([\s\S]*?)```/g, (_, c) => '`' + c.trim().replace(/\s*\n\s*/g, '; ') + '`') + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ')) + .join('
') + .split(/(`[^`]*`)/) + .map((part) => + !part.startsWith('`') + ? part.replace(/\|/g, '\\|') + : part.slice(1, -1).split(/\s*\|\s*/).map((x) => '`' + x + '`').join(' \\| ') + ) + .join('') + +/** + * One details block holding a one-column table: a row per parameter, field or setting with its + * name, a labeled type and default line, then its description. + */ +function members(title, rows) { + if (!rows?.length) return null + const head = { Parameters: 'Parameter', Fields: 'Field', Settings: 'Setting' }[title] + const lines = rows.map((r) => { + const labels = [ + r.where && `**Where:** ${r.where}`, + r.value && `**Value:** ${cellText(code(r.value))}`, + r.type && `**Type:** ${cellText(code(r.type))}`, + r.required ? '**Required**' : r.default && `**Default:** ${cellText(code(r.default))}`, + ].filter(Boolean) + const cell = [code(r.name), labels.join(' · '), r.description && cellText(r.description)].filter(Boolean).join('
') + return `| ${cell} |` + }) + return ['
', `${title} (${rows.length})`, '', `| ${head} |`, '| --- |', ...lines, '', '
'].join('\n') +} + +const sentence = (text) => (text ? text[0].toUpperCase() + text.slice(1) + (/[.!?]$/.test(text) ? '' : '.') : '') + +function example(ex, lang) { + const label = + // Source examples are correct usage unless marked as a counter example. + ex.label === 'incorrect' ? `**Counter example (incorrect).** ${sentence(ex.why)}`.trim() : '**Correct example.**' + return `${label}\n\n${fence(ex.code, lang)}` +} + +function entry(e, level, lang) { + const out = [`${'#'.repeat(level)} \`${e.qualname}\``] + if (e.deprecated) out.push(`**Deprecated.** ${e.deprecated}`) + if (e.availableIn) out.push(`**Available in:** ${e.availableIn}`) + if (e.description) out.push(e.description) + if (e.signature) out.push(fence(e.signature, lang)) + const params = members('Parameters', e.params) + if (params) out.push(params) + if (e.returns && (e.returns.type || e.returns.description)) { + out.push(`**Returns** ${code(e.returns.type)}${e.returns.description ? `. ${e.returns.description}` : ''}`.trim()) + } + for (const r of e.raises ?? []) out.push(`**Raises** ${code(r.type)}. ${r.description ?? ''}`.trim()) + if (e.notes) out.push(e.notes) + for (const ex of e.examples ?? []) out.push(example(ex, lang)) + if (e.source) out.push(`[Source](${e.source})`) + return out.join('\n\n') +} + +/** Moves every heading down one level, dropping the H1 when asked. Code fences stay as written. */ +function nest(markdown, { dropTitle = true } = {}) { + let inFence = false + return markdown + .trim() + .split('\n') + .flatMap((line) => { + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence + if (inFence || !/^#{1,5} /.test(line)) return [line] + return dropTitle && line.startsWith('# ') ? [] : ['#' + line] + }) + .join('\n') + .replace(/^\n+/, '') +} + +function capabilityOf(e, index) { + const cap = index.capabilities[e.capability] + if (!cap) throw new Error(`Unknown capability "${e.capability}" on ${e.qualname}`) + if (slugify(cap.title) !== e.capability) throw new Error(`Title "${cap.title}" doesn't produce slug "${e.capability}"`) + return cap +} + +const order = (index) => (e) => { + const cap = index.capabilities[e.capability] + return [index.areas.indexOf(cap.area), Object.keys(index.capabilities).indexOf(e.capability)] +} + +function byOrder(entries, index) { + const key = order(index) + return [...entries].sort((a, b) => { + const [x, y] = [key(a), key(b)] + return x[0] - y[0] || x[1] - y[1] + }) +} + +function groups(entries, keyOf) { + const map = new Map() + for (const e of entries) map.set(keyOf(e), [...(map.get(keyOf(e)) ?? []), e]) + return map +} + +function defaultReference(ref, index, lang) { + const mapped = ref.entries.filter((e) => e.capability) + mapped.forEach((e) => capabilityOf(e, index)) + const out = [] + // Each sdk-specs capability is a top-level section, in area order. Its heading's anchor is the shared slug. + for (const [slug, entries] of groups(byOrder(mapped, index), (e) => e.capability)) { + out.push(`## ${index.capabilities[slug].title}`, ...entries.map((e) => entry(e, 3, lang))) + } + const utils = ref.entries.filter((e) => !e.capability) + if (utils.length) { + out.push('## SDK utils') + for (const [group, entries] of groups(utils, (e) => e.group ?? 'Other')) { + out.push(`### ${group}`, ...entries.map((e) => entry(e, 4, lang))) + } + } + return out +} + +function secondaryReference(ref, index, lang) { + const mapped = ref.entries.filter((e) => e.capability) + mapped.forEach((e) => capabilityOf(e, index)) + const entries = [...byOrder(mapped, index), ...ref.entries.filter((e) => !e.capability)] + return [`## ${ref.title}`, ...(ref.intro ? [ref.intro] : []), ...entries.map((e) => entry(e, 3, lang))] +} + +function type(t, lang) { + if (!t.description && !t.fields?.length && !t.methods?.length) return [] + const out = [`### \`${t.name}\``] + if (t.description) out.push(t.description) + const fields = members('Fields', t.fields) + if (fields) out.push(fields) + out.push(...(t.methods ?? []).map((m) => entry(m, 4, lang))) + if (t.source) out.push(`[Source](${t.source})`) + return [out.join('\n\n')] +} + +export function render(page, index) { + const lang = page.language + const [first, ...rest] = page.references + // current: the released major customers install. experimental: a newer major that hasn't shipped. + if (!['current', 'experimental'].includes(page.status)) throw new Error(`Unknown status "${page.status}"`) + // Getting started renders before the reference, so its headings would take capability anchors first. + for (const [, title] of page.gettingStarted.matchAll(/^#{2,6} (.+)$/gm)) { + if (index.capabilities[slugify(title)]) throw new Error(`Getting started heading "${title}" takes the "${slugify(title)}" anchor`) + } + const sections = [ + ['---', `title: ${page.title}`, `sdk: ${page.sdk}`, `version: ${page.version}`, `major: ${page.major}`, `status: ${page.status}`, '---'].join('\n'), + '## Getting started', + nest(page.gettingStarted), + ...defaultReference(first, index, lang), + ...rest.flatMap((ref) => secondaryReference(ref, index, lang)), + ] + const configs = members('Settings', page.configs) + if (configs) sections.push('## Configs', configs) + const types = (page.types ?? []).flatMap((t) => type(t, lang)) + if (types.length) sections.push('## Types', ...types) + return sections.join('\n\n') + '\n' +} + +/** The SDK's migration guides as their own page, newest first. Each guide's title becomes an H2. */ +export function renderMigration(page) { + if (!page.migrations?.length) return null + const head = ['---', `title: ${page.title} migration guides`, `sdk: ${page.sdk}`, 'kind: migration', '---'].join('\n') + return [head, ...page.migrations.map((guide) => nest(guide, { dropTitle: false }))].join('\n\n') + '\n' +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [pagePath, indexPath, flag] = process.argv.slice(2) + const read = (p) => JSON.parse(readFileSync(p, 'utf8')) + process.stdout.write((flag === '--migration' ? renderMigration(read(pagePath)) : render(read(pagePath), read(indexPath))) ?? '') +} diff --git a/bin/docs_scripts/render.test.mjs b/bin/docs_scripts/render.test.mjs new file mode 100644 index 000000000..ad09ae3de --- /dev/null +++ b/bin/docs_scripts/render.test.mjs @@ -0,0 +1,167 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { render, renderMigration, slugify } from './render.mjs' + +const capabilities = { + areas: ['Events', 'Feature flags', 'Lifecycle'], + capabilities: { + capture: { title: 'Capture', area: 'Events' }, + 'get-feature-flag': { title: 'Get feature flag', area: 'Feature flags' }, + flush: { title: 'Flush', area: 'Lifecycle' }, + }, +} + +const capture = { + qualname: 'Client.capture', + capability: 'capture', + signature: 'capture(event: str, **kwargs) -> Optional[str]', + description: 'Captures an event.', + params: [ + { name: 'event', type: 'str', required: true, description: 'The event name.' }, + { name: 'timestamp', type: 'datetime | str', default: 'None', description: 'When it happened | UTC.' }, + { name: 'secret_key', default: 'None', description: 'A secret key.\nExample:\n\n```python\nClient(key, secret_key="phx_")\n```' }, + ], + returns: { type: 'Optional[str]', description: 'The event UUID, or None when it was dropped.' }, + examples: [ + { code: 'client.capture("signed_up", distinct_id="u1")' }, + { label: 'incorrect', why: 'fails because `distinct_id` is keyword-only', code: 'client.capture("u1", "signed_up")' }, + { label: 'correct', code: 'client.capture("signed_up", distinct_id="u1")' }, + ], +} + +const page = { + sdk: 'python', + title: 'PostHog Python SDK', + version: '8.0.0', + major: 8, + status: 'experimental', + language: 'python', + gettingStarted: '# Getting started\n\nInstall it.\n\n## Install\n\n```bash\npip install posthog\n```', + references: [ + { + title: 'Client', + entries: [ + { qualname: 'Client.flush', capability: 'flush', description: 'Sends queued events.' }, + capture, + { qualname: 'Client.tag', capability: null, group: 'Contexts', description: 'Adds a tag.' }, + ], + }, + { + title: 'AsyncClient', + entries: [{ qualname: 'AsyncClient.capture', capability: 'capture', description: 'Captures an event.' }], + }, + ], + configs: [{ name: 'POSTHOG_MW_CAPTURE_EXCEPTIONS', where: 'Django settings', type: 'bool', default: 'True', description: 'Captures exceptions.' }], + types: [ + { + name: 'FeatureFlagEvaluations', + kind: 'class', + description: 'A snapshot of flag values.', + fields: [], + methods: [{ qualname: 'FeatureFlagEvaluations.is_enabled', signature: 'is_enabled(key: str) -> bool', description: 'Checks a flag.' }], + }, + { name: 'Empty', kind: 'class', description: '', fields: [], methods: [] }, + { name: 'Compression', description: 'Request compression.', fields: [{ name: 'GZIP', value: '"gzip"', description: 'gzip, from the standard library.' }], methods: [] }, + ], + migrations: ['# Migrating from 7.x to 8.0\n\n## Capture\n\nIt changed.', '# Migrating from 6.x to 7.0\n\nOlder.'], +} + +const md = render(page, capabilities) +const h2 = md.split('\n').filter((l) => l.startsWith('## ')).map((l) => l.slice(3)) + +test('slugify matches heading ids', () => { + assert.equal(slugify('Get feature flag'), 'get-feature-flag') + assert.equal(slugify('Get anonymous ID'), 'get-anonymous-id') + assert.equal(slugify('`Client.capture`'), 'clientcapture') +}) + +test('top-level headings are capabilities in area order, then structure', () => { + assert.deepEqual(h2, ['Getting started', 'Capture', 'Flush', 'SDK utils', 'AsyncClient', 'Configs', 'Types']) +}) + +test('capability headings carry the shared slug', () => { + assert.match(md, /^## Capture$/m) + assert.equal(slugify('Capture'), 'capture') + assert.match(md, /^## Flush$/m) + assert.doesNotMatch(md, /^## Events$/m) +}) + +test('unknown capabilities and slug mismatches throw', () => { + const bad = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'x', capability: 'nope' }] }] } + assert.throws(() => render(bad, capabilities), /Unknown capability "nope"/) + const mismatch = { ...capabilities, capabilities: { ...capabilities.capabilities, capture: { title: 'Capture events', area: 'Events' } } } + assert.throws(() => render(page, mismatch), /slug/) +}) + +test('a getting started heading that would take a capability slug throws', () => { + const clash = { ...page, gettingStarted: '# Getting started\n\n## Capture\n\nSend one.' } + assert.throws(() => render(clash, capabilities), /Getting started heading "Capture" takes the "capture" anchor/) +}) + +test('an entry renders signature, parameters, returns and labeled examples', () => { + assert.match(md, /^### `Client\.capture`$/m) + assert.match(md, /```python\ncapture\(event: str, \*\*kwargs\) -> Optional\[str\]\n```/) + assert.match(md, /
\nParameters \(3\)<\/summary>\n\n\| Parameter \|\n\| --- \|\n\| `event`
\*\*Type:\*\* `str` · \*\*Required\*\*
The event name\. \|\n/) + assert.match(md, /^\| `timestamp`
\*\*Type:\*\* `datetime` \\\| `str` · \*\*Default:\*\* `None`
When it happened \\\| UTC\. \|$/m) + assert.match(md, /^\| `secret_key`
\*\*Default:\*\* `None`
A secret key\. Example:
`Client\(key, secret_key="phx_"\)` \|\n\n<\/details>/m) + assert.doesNotMatch(md, /^#####/m) + assert.match(md, /\*\*Returns\*\* `Optional\[str\]`\. The event UUID/) + assert.match(md, /\*\*Correct example\.\*\*\n\n```python\nclient\.capture\("signed_up", distinct_id="u1"\)\n```\n\n\*\*Counter/) + assert.doesNotMatch(md, /\*\*Example\.\*\*/) + assert.match(md, /\*\*Counter example \(incorrect\)\.\*\* Fails because `distinct_id` is keyword-only\.\n\n```python/) + assert.match(md, /\*\*Correct example\.\*\*\n\n```python\nclient\.capture\("signed_up", distinct_id="u1"\)/) +}) + +test('member names sit in table rows, so they never take a capability anchor', () => { + const clash = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'Client(...)', capability: 'capture', params: [{ name: 'flush', default: 'None' }] }, { qualname: 'Client.flush', capability: 'flush' }] }] } + const out = render(clash, capabilities) + assert.match(out, /^\| `flush`
\*\*Default:\*\* `None` \|$/m) + assert.doesNotMatch(out, /^#+ `flush`/m) +}) + +test('every parameter block sits in a details block, even a short one', () => { + const one = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'Client.flush', capability: 'flush', params: [{ name: 'timeout', default: '5' }] }] }] } + assert.match(render(one, capabilities), /
\nParameters \(1\)<\/summary>\n\n\| Parameter \|\n\| --- \|\n\| `timeout`
\*\*Default:\*\* `5` \|\n\n<\/details>/) + assert.equal((md.match(/
/g) ?? []).length, (md.match(/<\/details>/g) ?? []).length) + assert.doesNotMatch(md, /^- `/m) +}) + +test('unmapped entries land in SDK utils under their group', () => { + const utils = md.slice(md.indexOf('## SDK utils')) + assert.match(utils, /^### Contexts\n\n#### `Client\.tag`/m) +}) + +test('secondary references list entries without area headings', () => { + const asyncRef = md.slice(md.indexOf('## AsyncClient'), md.indexOf('## Configs')) + assert.match(asyncRef, /^### `AsyncClient\.capture`$/m) + assert.doesNotMatch(asyncRef, /^### Capture$/m) +}) + +test('types explain members and skip empty stubs', () => { + assert.match(md, /^### `FeatureFlagEvaluations`\n\nA snapshot of flag values\./m) + assert.match(md, /^#### `FeatureFlagEvaluations\.is_enabled`$/m) + assert.doesNotMatch(md, /`Empty`/) + assert.match(md, /^\| `GZIP`
\*\*Value:\*\* `"gzip"`
gzip, from the standard library\. \|$/m) +}) + +test('guides drop their H1 and nest under their section', () => { + assert.match(md, /## Getting started\n\nInstall it\.\n\n### Install/) + assert.doesNotMatch(md, /^# /m) +}) + +test('configs render as headed settings and the page has no reverse usage lists', () => { + assert.match(md, /^## Configs\n\n
\nSettings \(1\)<\/summary>\n\n\| Setting \|\n\| --- \|\n\| `POSTHOG_MW_CAPTURE_EXCEPTIONS`
\*\*Where:\*\* Django settings · \*\*Type:\*\* `bool` · \*\*Default:\*\* `True`
Captures exceptions\. \|$/m) + assert.doesNotMatch(md, /Used by/i) +}) + +test('front matter names the SDK and version', () => { + assert.match(md, /^---\ntitle: PostHog Python SDK\nsdk: python\nversion: 8\.0\.0\nmajor: 8\nstatus: experimental\n---\n/) + assert.throws(() => render({ ...page, status: 'preview' }, capabilities), /status/) +}) + +test('migration guides render as their own page, each guide an H2', () => { + const out = renderMigration(page) + assert.match(out, /^---\ntitle: PostHog Python SDK migration guides\nsdk: python\nkind: migration\n---\n\n## Migrating from 7\.x to 8\.0\n\n### Capture\n\nIt changed\.\n\n## Migrating from 6\.x to 7\.0\n\nOlder\.\n$/) + assert.doesNotMatch(md, /Migrating from/) + assert.equal(renderMigration({ ...page, migrations: [] }), null) +}) diff --git a/bin/docs_scripts/sdk_specs.py b/bin/docs_scripts/sdk_specs.py new file mode 100644 index 000000000..6a4710636 --- /dev/null +++ b/bin/docs_scripts/sdk_specs.py @@ -0,0 +1,43 @@ +"""Read sdk-specs capabilities for the reference: each slug's title, in the README table's order. + +The slug is the folder name under `openspec/specs/`, and it becomes the capability's anchor on +the reference page. Pass a checkout of PostHog/sdk-specs at the commit to document against. +""" + +import re +import subprocess +from pathlib import Path + +ACRONYMS = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} + + +def sentence_case(name: str) -> str: + words = [ + ACRONYMS.get(w.lower(), w if w.isupper() and len(w) > 1 else w.lower()) + for w in name.split() + ] + words[0] = words[0][0].upper() + words[0][1:] + return " ".join(words) + + +def capabilities(checkout: str) -> dict: + """The renderer's capability index. It fails when a spec folder has no README row.""" + root = Path(checkout) + readme = (root / "README.md").read_text() + rows = re.findall( + r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", readme, re.M + ) + if not rows: + raise SystemExit(f"{root / 'README.md'} has no capability table") + caps = {slug: {"title": sentence_case(name)} for name, slug in rows} + folders = {p.name for p in (root / "openspec/specs").iterdir() if p.is_dir()} + if folders - set(caps): + raise SystemExit( + f"sdk-specs capabilities missing from its README table: {sorted(folders - set(caps))}" + ) + head = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + ) + return {"sdkSpecs": head.stdout.strip(), "areas": [], "capabilities": caps} 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]] From b5a6328044e2d5a9ff638492a583119b581fddc0 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 4 Nov 2026 14:53:53 -0500 Subject: [PATCH 02/12] docs: render the reference in one Python script Drops the Node renderer, its tests, the page check and the sdk-specs module. The generated pages are unchanged byte for byte. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/check_reference.py | 195 -------- bin/docs_scripts/generate_markdown.py | 688 +++++++++++++------------- bin/docs_scripts/render.mjs | 200 -------- bin/docs_scripts/render.test.mjs | 167 ------- bin/docs_scripts/sdk_specs.py | 43 -- 5 files changed, 345 insertions(+), 948 deletions(-) delete mode 100644 bin/docs_scripts/check_reference.py delete mode 100644 bin/docs_scripts/render.mjs delete mode 100644 bin/docs_scripts/render.test.mjs delete mode 100644 bin/docs_scripts/sdk_specs.py diff --git a/bin/docs_scripts/check_reference.py b/bin/docs_scripts/check_reference.py deleted file mode 100644 index e96540e02..000000000 --- a/bin/docs_scripts/check_reference.py +++ /dev/null @@ -1,195 +0,0 @@ -"""End condition for the Python reference page. - -Builds its own inventory of the public Python surface with `ast`, independent of the extractor, -then checks the generated page against it and against the release rules: - - python bin/docs_scripts/check_reference.py - -Exits 1 and lists every failure. -""" - -import ast -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import sdk_specs # noqa: E402 - -ROOT = Path(__file__).resolve().parents[2] - -STRUCTURE = {"Getting started", "SDK utils", "Configs", "Types"} -BANNED = { - "Used by section": r"^(#+ |\*\*)?Used by\b[:*]", - "preview wording": r"\b(preview|candidate)\b", - "commit SHA": r"\b[0-9a-f]{12,40}\b", - "empty class stub": r"^class \w+\(\):\s*$", - "scraped icon text": r"svg/svg", - "Sphinx role": r":(meth|func|class|attr|obj|data|exc|mod):`", - "Sphinx literal block": r"::$", - "variadic default": r"\*\*\w+[^,)\n]* = \{\}", - "private parameter": r"^- `_\w+`", - "old example label": r"\*\*(Good|Bad) example", - "unlabeled example": r"\*\*Example\.\*\*", - "list instead of table": r"^- `\w+` \(", -} - - -def public_defs(path: Path, class_name: str | None) -> list[str]: - tree = ast.parse(path.read_text()) - body = tree.body - if class_name: - body = next( - n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name - ).body - names = [ - n.name for n in body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - ] - return sorted( - {n for n in names if not n.startswith("_")} | ({"__init__"} & set(names)) - ) - - -def slugify(text: str) -> str: - return re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-") - - -def main(md_path: str, specs: str) -> int: - root = ROOT - md = Path(md_path).read_text() - caps = sdk_specs.capabilities(specs) - failures = [] - headings = set(re.findall(r"^#{3,4} `([^`]+)`$", md, re.M)) - - inventory = { - "Client": public_defs(root / "posthog/client.py", "Client"), - "AsyncClient": public_defs(root / "posthog/async_client.py", "AsyncClient"), - "posthog": public_defs(root / "posthog/__init__.py", None), - } - for owner, names in inventory.items(): - for name in names: - label = ( - f"{owner}({owner == 'posthog' and '' or '...'})" - if name == "__init__" - else f"{owner}.{name}" - ) - if name == "__init__": - label = f"{owner}(...)" - if label not in headings: - failures.append(f"missing public symbol {label}") - - status = re.search(r"^status: (current|experimental)$", md, re.M) - if not status: - failures.append("front matter has no current or experimental status") - h2 = re.findall(r"^## (.+)$", md, re.M) - allowed = ( - STRUCTURE - | {c["title"] for c in caps["capabilities"].values()} - | {"AsyncClient", "Module functions"} - ) - failures += [f"unexpected top-level heading {h}" for h in h2 if h not in allowed] - - blocks = len( - re.findall( - r"^(Parameters|Fields|Settings) \(\d+\)$", md, re.M - ) - ) - if blocks != md.count("
"): - failures.append( - f"{blocks} member blocks but {md.count('
')} details blocks" - ) - tables = len( - re.findall( - r"^(Parameters|Fields|Settings) \(\d+\)\n\n\| (Parameter|Field|Setting) \|\n\| --- \|$", - md, - re.M, - ) - ) - if tables != blocks: - failures.append( - f"{blocks} member blocks but only {tables} open with a one-column table" - ) - if re.search(r"
", md) or re.search( - r"^#{3,6} `\w+` (parameter|field|setting)$", md, re.M - ): - failures.append("a bare
or a member heading is left on the page") - # Replay the site's heading slugger: every capability heading must keep its plain sdk-specs anchor. - seen: dict[str, int] = {} - ids: list[tuple[str, str, str]] = [] - in_fence = False - for line in md.split("\n"): - if line.lstrip().startswith("```"): - in_fence = not in_fence - heading = None if in_fence else re.match(r"^(#{1,6}) (.+)$", line) - if heading: - base = slugify(heading.group(2)) - n = seen.get(base, 0) - seen[base] = n + 1 - ids.append( - (heading.group(1), heading.group(2), base if n == 0 else f"{base}-{n}") - ) - for level, text, anchor in ids: - cap = caps["capabilities"].get(slugify(text)) - if level == "##" and cap and cap["title"] == text and anchor != slugify(text): - failures.append(f"capability {text!r} lost its anchor, got #{anchor}") - failures += [ - f"escaped pipe inside code span: {s!r}" - for s in re.findall(r"`([^`\n]+)`", md) - if "\\|" in s - ] - for block in re.findall( - r"Fields \(\d+\).*?
", md, re.S - ): - failures += [ - f"field default is a name, not a value: {d!r}" - for d in re.findall(r"\*\*Default:\*\* `([^`]+)`", block) - if re.fullmatch(r"[a-z_][\w.]*( or .+)?", d) - and d not in ("None", "True", "False") - ] - for entry in re.split(r"^(?=#{3,4} `)", md, flags=re.M): - if ( - entry.startswith(("### `AsyncClient", "#### `AsyncClient")) - and "**Available in:**" not in entry.split("\n#", 1)[0] - ): - failures.append( - f"{entry.splitlines()[0]} doesn't say it needs the async extra" - ) - types = md.split("## Types", 1)[-1] - for name in ( - "CaptureCompression", - "FeatureFlagEvaluationRuntime", - "UnresolvedFlagReason", - ): - section = types.split(f"### `{name}`", 1)[-1].split("\n### ", 1)[0] - if f"### `{name}`" in types and "**Value:**" not in section: - failures.append(f"enum {name} doesn't show its member values") - failures += [ - f"type method {m} has no description" - for m in re.findall( - r"^#### `([^`]+)`\n\n(?:```|
|\*\*Returns|\[Source)", types, re.M - ) - ] - for block in re.findall( - r"Fields \(\d+\)(.*?)
", types, re.S - ): - failures += [ - f"type field {r.split('`')[1]} has no description" - for r in re.findall(r"^\| (`.+) \|$", block, re.M) - if not re.search(r"
(?!\*\*)[^<]", r) - ] - prose = re.sub(r"```.*?```", "", md, flags=re.S) - for name, pattern in BANNED.items(): - for match in re.finditer(pattern, prose, re.M | re.I): - failures.append(f"{name}: {match.group(0)!r}") - - for failure in failures: - print(f"FAIL {failure}") - total = sum(len(v) for v in inventory.values()) - print( - f"{len(failures)} failures, {total} public symbols checked, {len(h2)} top-level headings" - ) - return 1 if failures else 0 - - -if __name__ == "__main__": - sys.exit(main(*sys.argv[1:])) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 9407e5324..1a4bb41e1 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -1,35 +1,27 @@ -"""Generate this SDK's reference Markdown from source. +"""Generate the SDK reference Markdown from source with Griffe, which never imports the SDK. -Griffe reads the package statically, so the SDK is never imported. Docstrings are parsed as -Google style. A `posthog-capability: ` line maps a symbol to its sdk-specs capability, -and unmapped symbols land in SDK utils under their `Category:`. The shared renderer turns the -symbol list into Markdown, so every SDK's page has the same shape. +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, grouped by +their `Category:`. - python bin/docs_scripts/generate_markdown.py --sdk-specs [--major 8 --source-ref v1] + python bin/docs_scripts/generate_markdown.py --sdk-specs DIR [--major 8 --source-ref v1] """ import argparse -import json import logging import re -import subprocess -import sys -import tempfile import textwrap from pathlib import Path -from typing import Any +from typing import NamedTuple import griffe -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import sdk_specs # noqa: E402 - ROOT = Path(__file__).resolve().parents[2] -SOURCE = "https://github.com/PostHog/posthog-python/blob/v{version}/{path}#L{line}" -REFERENCES = [ - ("Client", "client.Client", "Client"), - ("AsyncClient", "async_client.AsyncClient", "AsyncClient"), -] +SOURCE = "[Source](https://github.com/PostHog/posthog-python/blob/{ref}/{path}#L{line})" +PAGE = "---\ntitle: PostHog Python SDK\nsdk: python\nversion: {}\nmajor: {}\nstatus: {}\n---" +GUIDES = ( + "---\ntitle: PostHog Python SDK migration guides\nsdk: python\nkind: migration\n---" +) TYPES = """args.OptionalCaptureArgs args.OptionalSetArgs feature_flag_evaluations.FeatureFlagEvaluations types.FeatureFlagResult types.FeatureFlag types.FlagsAndPayloads types.FeatureFlagEvaluationRuntime types.UnresolvedFlagReason flag_definition_cache.FlagDefinitionCacheProvider flag_definition_cache.FlagDefinitionCacheData @@ -46,7 +38,7 @@ } WRAPPERS = "openai anthropic gemini langchain openai_agents claude_agent_sdk".split() CONFIGS = [("integrations.django.PosthogContextMiddleware", "Django settings")] -# Where a symbol works when that isn't everywhere Python runs, by module. Extras are checked against pyproject.toml. +# Where a symbol works, by module, when that isn't everywhere Python runs. AVAILABLE_IN = { "async_client": "the `async` extra, `posthog[async]`", "integrations.django": "Django apps", @@ -62,229 +54,250 @@ "ai.openai_agents": "apps with the `openai-agents` package", "ai.claude_agent_sdk": "apps with the `claude-agent-sdk` package", } -LITERAL_VALUE = re.compile( - r"""^(None|True|False|-?\d[\d_.e]*|(["']).*\2|\[\]|\{\}|\(\))$""" -) +ACRONYMS = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} +STAR = {"variadic positional": "*", "variadic keyword": "**"} TAG = re.compile(r"^[ \t]*posthog-capability:[ \t]*([a-z0-9-]+)[ \t]*$\n?", re.M) +CAPABILITY_ROW = re.compile( + r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", re.M +) FENCE = re.compile(r"```(\w*)\n(.*?)```", re.S) LABEL = re.compile(r"#\s*(Correct example|Counter example \(incorrect\))\.?:?\s*(.*)") ROLE = re.compile( r":(?:py:)?(?:meth|func|class|attr|obj|data|exc|mod|const):`~?([^`]+)`" ) LITERAL = re.compile(r"^([^\n]*?)::\n\n((?:(?:[ \t]+[^\n]*)?\n)+)", re.M) +LITERAL_VALUE = re.compile( + r"""^(None|True|False|-?\d[\d_.e]*|(["']).*\2|\[\]|\{\}|\(\))$""" +) +SETTING = re.compile(r"^\s*- `(POSTHOG_[A-Z_]+)`: (.*?)(?=^\s*- `|^\s*$)", re.M | re.S) +VALUE = re.compile(r"^(\w+): (.*?)(?=^\w+:|\Z)", re.M | re.S) +NEWLINE = re.compile(r"\s*\n\s*") + + +class Entry(NamedTuple): + slug: str | None + group: str | None + name: str + body: list[str] + + def render(self, level: int) -> str: + return "\n\n".join([f"{'#' * level} `{self.name}`", *self.body]) def text(value) -> str: - return str(value) if value is not None else "" + return "" if value is None else str(value) + + +def get(obj, path: str): + try: + return obj[path] + except KeyError: + return None + + +def fence(code: str) -> str: + return f"```python\n{code.strip()}\n```" + + +def code(value) -> str: + return f"`{NEWLINE.sub(' ', str(value)).strip()}`" if value else "" def md(value: str) -> str: - """Converts the Sphinx markup in these docstrings to Markdown: roles, double backticks, literal blocks.""" + """Sphinx roles, double backticks and literal blocks become Markdown.""" value = re.sub(r"``([^`]+)``", r"`\1`", ROLE.sub(r"`\1`", value or "")) def block(m: re.Match) -> str: lead = m.group(1) + ":\n\n" if m.group(1).strip() else "" - return lead + "```python\n" + textwrap.dedent(m.group(2)).strip() + "\n```\n\n" + return lead + fence(textwrap.dedent(m.group(2))) + "\n\n" return LITERAL.sub(block, value + "\n").strip() -def get(pkg, path: str): - try: - return pkg[path] - except KeyError: - return None +def unpipe(span: str) -> str: + """Escapes pipes for a table. A pipe splits its code span, since MDX shows `\\|` literally.""" + if not span.startswith("`"): + return span.replace("|", "\\|") + return " \\| ".join(f"`{x}`" for x in re.split(r"\s*\|\s*", span[1:-1])) -def available_in(path: str) -> str | None: - module = path.removeprefix("posthog.") - return next( - ( - where - for prefix, where in AVAILABLE_IN.items() - if module == prefix or module.startswith(prefix + ".") - ), - None, +def cell(value) -> str: + """Table cell text: fences become inline code and paragraphs join with
.""" + value = FENCE.sub( + lambda m: f"`{NEWLINE.sub('; ', m.group(2).strip())}`", text(value).strip() ) + value = "
".join(NEWLINE.sub(" ", p) for p in re.split(r"\n\s*\n", value)) + return "".join(unpipe(s) for s in re.split(r"(`[^`]*`)", value)) -def check_extras() -> None: - extras = re.search( - r"^\[project\.optional-dependencies\]\n(.*?)(?=^\[)", - (ROOT / "pyproject.toml").read_text(), - re.M | re.S, +def row(name, desc="", kind="", default="", required=False, value="", where="") -> str: + """One parameter, field or setting: its name, a labels line, then its description.""" + default = ( + "**Required**" + if required + else default and f"**Default:** {cell(code(default))}" ) - named = set(re.findall(r"^(\w+) = ", extras.group(1), re.M)) if extras else set() - missing = { - x for w in AVAILABLE_IN.values() for x in re.findall(r"posthog\[(\w+)\]", w) - } - named - if missing: - raise SystemExit( - f"AVAILABLE_IN names extras pyproject.toml doesn't define: {sorted(missing)}" - ) + labels = [ + where and f"**Where:** {where}", + value and f"**Value:** {cell(code(value))}", + kind and f"**Type:** {cell(code(kind))}", + default, + ] + parts = [code(name), " · ".join(x for x in labels if x), cell(desc)] + return "| " + "
".join(x for x in parts if x) + " |" -def source(obj, version: str) -> str: - return SOURCE.format( - version=version, path=obj.relative_package_filepath, line=obj.lineno - ) +def table(title: str, rows: list[str]) -> list[str]: + """A details block holding a one-column table.""" + head = f"{title} ({len(rows)})\n\n| {title[:-1]} |\n| --- |" + return ["\n".join(["
", head, *rows, "", "
"])] if rows else [] def parse(obj) -> tuple[str | None, list]: + """The symbol's capability tag and its parsed docstring sections.""" raw = obj.docstring.value if obj.docstring else "" - tag = TAG.search(raw) - body = TAG.sub("", raw).strip() + tag, body = TAG.search(raw), TAG.sub("", raw).strip() sections = griffe.Docstring(body, parent=obj).parse("google") if body else [] return (tag.group(1) if tag else None), sections -def examples(value) -> list[dict]: +def examples(blocks: str) -> list[str]: + """Each fenced block, labeled by its first comment line. Unlabeled blocks are correct.""" out = [] - for _, code in FENCE.findall("\n".join(t for _, t in value)): - label = why = None - head, _, rest = code.lstrip("\n").partition("\n") - match = LABEL.match(head.strip()) - if match: - label, why, code = ( - "correct" if match.group(1).startswith("Correct") else "incorrect", - match.group(2) or None, - rest, - ) - out.append({"code": code, "label": label, "why": why}) + for _, src in FENCE.findall(blocks): + head, _, rest = src.lstrip("\n").partition("\n") + label = "**Correct example.**" + if m := LABEL.match(head.strip()): + src, why = rest, m.group(2) + if m.group(1).startswith("Counter"): + why = ( + why[:1].upper() + + why[1:] + + ("." if why and why[-1] not in ".!?" else "") + ) + label = f"**Counter example (incorrect).** {why}".strip() + out.append(f"{label}\n\n{fence(src)}") return out -def typed_dict_fields(pkg, annotation: str) -> dict: - match = re.fullmatch(r"Unpack\[(\w+)\]", annotation) - cls = get(pkg, f"args.{match.group(1)}") if match else None - return {n: text(a.annotation) for n, a in cls.attributes.items()} if cls else {} - - -def params(pkg, fn, documented: dict) -> list[dict]: - out = [] - for p in fn.parameters: - if p.name in ("self", "cls") or p.name.startswith("_"): - continue - kind = p.kind.value - if kind == "variadic keyword": - fields = typed_dict_fields(pkg, text(p.annotation)) - for name in [n for n in documented if n in fields]: - out.append( - { - "name": name, - "type": fields[name], - "description": md(documented.pop(name)), - } - ) - if fields: - continue - star = {"variadic positional": "*", "variadic keyword": "**"}.get(kind, "") - required = p.default is None and not star - out.append( - { - "name": star + p.name, - "type": text(p.annotation), - "default": None if star else p.default and text(p.default), - "required": required, - "description": md(documented.pop(p.name, "")), - } - ) - out += [{"name": n, "description": md(d)} for n, d in documented.items()] - return out +def public(p) -> bool: + return p.name not in ("self", "cls") and not p.name.startswith("_") def signature(fn, name: str) -> str: parts = [] - for p in fn.parameters: - if p.name in ("self", "cls") or p.name.startswith("_"): - continue - star = {"variadic positional": "*", "variadic keyword": "**"}.get( - p.kind.value, "" - ) + for p in filter(public, fn.parameters): + star = STAR.get(p.kind.value, "") part = star + p.name + (f": {p.annotation}" if p.annotation is not None else "") parts.append( part + (f" = {p.default}" if p.default is not None and not star else "") ) - ret = f" -> {fn.returns}" if fn.returns is not None and name[0].islower() else "" - prefix = "async " if "async" in fn.labels else "" - line = f"{prefix}{name}({', '.join(parts)}){ret}" - return ( - line - if len(line) <= 88 - else f"{prefix}{name}(\n" + "".join(f" {p},\n" for p in parts) + f"){ret}" + returns = ( + f" -> {fn.returns}" if fn.returns is not None and name[0].islower() else "" ) + head = ("async " if "async" in fn.labels else "") + name + if len(line := f"{head}({', '.join(parts)}){returns}") <= 88: + return line + return f"{head}(\n" + "".join(f" {p},\n" for p in parts) + f"){returns}" + + +def params(fn, documented: dict) -> list[str]: + """A row per parameter. Each documented key of an `Unpack[...]` TypedDict gets a row.""" + rows = [] + for p in filter(public, fn.parameters): + star = STAR.get(p.kind.value, "") + unpacked = re.fullmatch(r"Unpack\[(\w+)\]", text(p.annotation)) + typed = get(fn.package, f"args.{unpacked.group(1)}") if unpacked else None + if star == "**" and typed and (fields := typed.attributes): + for n in [n for n in documented if n in fields]: + rows.append(row(n, md(documented.pop(n)), text(fields[n].annotation))) + continue + desc = md(documented.pop(p.name, "")) + default, required = ( + "" if star else text(p.default), + p.default is None and not star, + ) + rows.append(row(star + p.name, desc, text(p.annotation), default, required)) + return rows + [row(n, md(d)) for n, d in documented.items()] -def entry(pkg, obj, qualname: str, version: str, group: str | None = None) -> dict: +def entry(obj, name: str, ref: str, group: str | None = None) -> Entry: slug, sections = parse(obj) - e: dict[str, Any] = { - "qualname": qualname, - "capability": slug, - "group": group, - "description": "", - "examples": [], - "source": source(obj, version), - "availableIn": available_in(obj.module.path), - } - texts, notes, documented = [], [], {} + texts, notes, shown, raises = [], [], [], [] + documented: dict[str, str] = {} + returns, deprecated = ("", ""), "" for s in sections: - kind = s.kind.value + kind, value = s.kind.value, s.value if kind == "text": - texts.append(s.value) + texts.append(value) elif kind in ("parameters", "other parameters"): - documented.update({p.name: p.description for p in s.value}) + documented |= {p.name: p.description for p in value} elif kind == "returns": - e["returns"] = { - "type": text(s.value[0].annotation) - or text(getattr(obj, "returns", "")), - "description": md(" ".join(r.description for r in s.value)), - } + rtype = text(value[0].annotation) or text(getattr(obj, "returns", "")) + returns = (rtype, md(" ".join(r.description for r in value))) elif kind == "raises": - e["raises"] = [ - {"type": text(r.annotation), "description": md(r.description)} - for r in s.value + raises = [ + f"**Raises** {code(text(r.annotation))}. {md(r.description)}".strip() + for r in value ] elif kind == "examples": - e["examples"] += examples(s.value) + shown += examples("\n".join(t for _, t in value)) elif kind == "deprecated": - e["deprecated"] = s.value.description + deprecated = f"**Deprecated.** {value.description}" elif kind == "admonition" and s.title in ("Example", "Examples"): - body = s.value.description - e["examples"] += ( - examples([(None, body)]) + body = value.description + shown += ( + examples(body) if "```" in body - else [{"code": body, "label": None, "why": None}] + else [f"**Correct example.**\n\n{fence(body)}"] ) elif kind == "admonition" and s.title == "Category": - e["group"] = group or s.value.description.strip() + group = group or value.description.strip() elif kind == "admonition": - notes.append(f"**{s.title}.** {md(s.value.description)}") - e["description"], e["notes"] = md("\n\n".join(texts)), "\n\n".join(notes) + notes.append(f"**{s.title}.** {md(value.description)}") + sig, rows = "", [] if obj.is_function: - name = qualname.removesuffix("(...)") - e["signature"] = signature(obj, name if obj.name == "__init__" else obj.name) - e["params"] = params(pkg, obj, documented) - if not e.get("returns") and obj.returns is not None and obj.name != "__init__": - e["returns"] = {"type": text(obj.returns)} + sig = signature( + obj, name.removesuffix("(...)") if obj.name == "__init__" else obj.name + ) + rows = params(obj, documented) + if not any(returns) and obj.returns is not None and obj.name != "__init__": + returns = (text(obj.returns), "") elif obj.is_class and "__init__" in obj.members: init = obj.members["__init__"] - e["signature"] = signature(init, obj.name) - e["params"] = params( - pkg, - init, - documented - | { - p.name: p.description - for s in parse(init)[1] - if s.kind.value == "parameters" - for p in s.value - }, - ) - elif obj.is_attribute: - e["signature"] = ( - f"{qualname}: {obj.annotation}" if obj.annotation is not None else None - ) - return e + args = { + p.name: p.description + for s in parse(init)[1] + if s.kind.value == "parameters" + for p in s.value + } + sig, rows = signature(init, obj.name), params(init, documented | args) + elif obj.is_attribute and obj.annotation is not None: + sig = f"{name}: {obj.annotation}" + where = available_in(obj.module.path) + returned = any(returns) and f"**Returns** {code(returns[0])}" + ( + returns[1] and f". {returns[1]}" + ) + body = [ + deprecated, + where and f"**Available in:** {where}", + md("\n\n".join(texts)), + sig and fence(sig), + *table("Parameters", rows), + returned and returned.strip(), + *raises, + "\n\n".join(notes), + *shown, + SOURCE.format(ref=ref, path=obj.relative_package_filepath, line=obj.lineno), + ] + return Entry(slug, group, name, [b for b in body if b]) + + +def available_in(module: str) -> str | None: + module = module.removeprefix("posthog.") + "." + return next( + (w for m, w in AVAILABLE_IN.items() if module.startswith(m + ".")), None + ) def members(cls) -> list: @@ -297,126 +310,84 @@ def members(cls) -> list: ] -def documented_fields(cls) -> dict: - """Field descriptions from the class's `Attributes:`, `Args:` and `Values:` sections, and from `__init__`'s `Args:`.""" +def type_block(cls, ref: str) -> str: + """A type's section. Dataclasses, TypedDicts, enums and errors show their fields.""" sections = parse(cls)[1] + ( parse(cls.members["__init__"])[1] if "__init__" in cls.members else [] ) - found = { - item.name: item.description + documented = { + i.name: i.description for s in sections if s.kind.value in ("attributes", "parameters") - for item in s.value + for i in s.value } for s in sections: if s.kind.value == "admonition" and s.title == "Values": - found |= { - n: " ".join(d.split()) - for n, d in re.findall( - r"^(\w+): (.*?)(?=^\w+:|\Z)", s.value.description, re.M | re.S - ) + documented |= { + n: " ".join(d.split()) for n, d in VALUE.findall(s.value.description) } - return found - - -def is_data(cls) -> bool: - """Dataclasses, TypedDicts, enums and errors carry fields readers use. Service objects carry only state.""" bases = " ".join(text(b) for b in cls.bases) - return any("dataclass" in text(d.value) for d in cls.decorators) or any( + data = any("dataclass" in text(d.value) for d in cls.decorators) or any( k in bases for k in ("TypedDict", "Enum", "Exception", "Error") ) - - -def type_info(pkg, cls, version: str) -> dict: - _, sections = parse(cls) - documented = documented_fields(cls) - # Only a literal is a default. A field set from an __init__ argument shows that argument's name instead. - enum = any("Enum" in text(b) for b in cls.bases) - - def value(a): - return text(a.value) if LITERAL_VALUE.match(text(a.value)) else None - - fields = [ - { - "name": n, - "type": None if enum else text(a.annotation), - "default": None if enum else value(a), - "value": value(a) if enum else None, - "description": md( - a.docstring.value if a.docstring else documented.get(n, "") - ), - } - for n, a in cls.attributes.items() - if is_data(cls) and not n.startswith("_") and "property" not in a.labels - ] + rows = [] + for n, a in cls.attributes.items(): + if data and not n.startswith("_") and "property" not in a.labels: + # Only a literal is a default. A field set from an __init__ argument has none. + literal = text(a.value) if LITERAL_VALUE.match(text(a.value)) else "" + desc = md(a.docstring.value if a.docstring else documented.get(n, "")) + enum = "Enum" in bases + rows.append( + row(n, desc, value=literal) + if enum + else row(n, desc, text(a.annotation), literal) + ) methods = [ - entry(pkg, m, f"{cls.name}.{m.name}", version) + entry(m, f"{cls.name}.{m.name}", ref).render(4) for m in members(cls) if m.name != "__init__" ] - description = md("\n\n".join(s.value for s in sections if s.kind.value == "text")) - return { - "name": cls.name, - "description": description, - "fields": fields, - "methods": methods, - "source": source(cls, version), - } + desc = md("\n\n".join(s.value for s in parse(cls)[1] if s.kind.value == "text")) + if not (desc or rows or methods): + return "" + src = SOURCE.format(ref=ref, path=cls.relative_package_filepath, line=cls.lineno) + return "\n\n".join( + [ + f"### `{cls.name}`", + *filter(None, [desc]), + *table("Fields", rows), + *methods, + src, + ] + ) -def configs(pkg) -> list[dict]: - out = [] - for path, where in CONFIGS: - cls = get(pkg, path) - doc = cls.docstring.value if cls and cls.docstring else "" - for name, desc in re.findall( - r"^\s*- `(POSTHOG_[A-Z_]+)`: (.*?)(?=^\s*- `|^\s*$)", doc, re.M | re.S - ): - out.append( - { - "name": name, - "where": where, - "description": md(" ".join(desc.split())), - } - ) - return out +def reference(pkg, caps: dict[str, str], ref: str) -> list[str]: + """Every capability, SDK utils, AsyncClient, module functions, configs and types.""" + def methods(cls: str, prefix: str) -> list[Entry]: + names = {"__init__": "(...)"} + return [ + entry(m, prefix + names.get(m.name, f".{m.name}"), ref) + for m in members(pkg[cls]) + ] -def page(pkg, version: str, major: int, status: str) -> dict: - refs: list[dict[str, Any]] = [] - for title, path, prefix in REFERENCES: - cls = pkg[path] - refs.append( - { - "title": title, - "entries": [ - entry( - pkg, - m, - f"{prefix}(...)" - if m.name == "__init__" - else f"{prefix}.{m.name}", - version, - ) - for m in members(cls) - ], - } - ) + client, asyncs = ( + methods("client.Client", "Client"), + methods("async_client.AsyncClient", "AsyncClient"), + ) module = [ - entry(pkg, m, f"posthog.{n}", version) + entry(m, f"posthog.{n}", ref) for n, m in pkg.members.items() if not m.is_alias and m.is_function and not n.startswith("_") ] - refs.append({"title": "Module functions", "entries": module}) for group, paths in UTILS.items(): - for path in paths: - if (obj := get(pkg, path)) is not None: - refs[0]["entries"].append( - entry(pkg, obj, f"posthog.{path}", version, group) - ) + for obj, path in [(get(pkg, p), p) for p in paths]: + if obj is not None: + client.append(entry(obj, f"posthog.{path}", ref, group)) if obj.is_class: - refs[0]["entries"] += [ - entry(pkg, m, f"{obj.name}.{m.name}", version, group) + client += [ + entry(m, f"{obj.name}.{m.name}", ref, group) for m in members(obj) if m.name != "__init__" ] @@ -428,94 +399,125 @@ def page(pkg, version: str, major: int, status: str) -> dict: if not n.startswith("_") and n[0].isupper() ) summary = md(mod.docstring.value.split("\n\n")[0]) if mod.docstring else "" - refs[0]["entries"].append( - { - "qualname": f"posthog.ai.{name}", - "group": "AI wrapper clients", - "availableIn": available_in(f"ai.{name}"), - "description": " ".join( - filter(None, [summary, f"Exports {names}."]) - ), - } + where = available_in(f"ai.{name}") + body = [ + where and f"**Available in:** {where}", + " ".join(filter(None, [summary, f"Exports {names}."])), + ] + client.append( + Entry( + None, + "AI wrapper clients", + f"posthog.ai.{name}", + [b for b in body if b], + ) ) - # Newest guide first, so the migration page opens on the latest upgrade. - migrations = sorted((ROOT / "docs").glob("migration-*.md"), reverse=True) - return { - "sdk": "python", - "title": "PostHog Python SDK", - "version": version, - "major": major, - "status": status, - "language": "python", - "gettingStarted": (ROOT / "docs/getting-started.md").read_text(), - "references": refs, - "configs": configs(pkg), - "types": [type_info(pkg, c, version) for p in TYPES if (c := get(pkg, p))], - "migrations": [m.read_text() for m in migrations], - } + if unknown := {e.slug for e in client + asyncs + module if e.slug} - caps.keys(): + raise SystemExit( + f"posthog-capability tags sdk-specs doesn't define: {sorted(unknown)}" + ) + + # Each capability is titled as in sdk-specs, so its heading's anchor is the capability's slug. + out = [] + for slug, title in caps.items(): + if tagged := [e for e in client if e.slug == slug]: + out += [f"## {title}", *(e.render(3) for e in tagged)] + if utils := [e for e in client if not e.slug]: + out.append("## SDK utils") + for group in dict.fromkeys(e.group or "Other" for e in utils): + out += [ + f"### {group}", + *(e.render(4) for e in utils if (e.group or "Other") == group), + ] + for title, entries in (("AsyncClient", asyncs), ("Module functions", module)): + ordered = sorted( + entries, key=lambda e: list(caps).index(e.slug) if e.slug else len(caps) + ) + out += [f"## {title}", *(e.render(3) for e in ordered)] + settings = [] + for path, where in CONFIGS: + cls = get(pkg, path) + doc = cls.docstring.value if cls and cls.docstring else "" + settings += [ + row(n, md(" ".join(d.split())), where=where) + for n, d in SETTING.findall(doc) + ] + if settings: + out += ["## Configs", *table("Settings", settings)] + if types := [ + t for t in (type_block(pkg[p], ref) for p in TYPES if get(pkg, p)) if t + ]: + out += ["## Types", *types] + return out + + +def capabilities(checkout: str) -> dict[str, str]: + """Each sdk-specs capability's slug and title in sentence case, in the README's order.""" + caps = {} + for name, slug in CAPABILITY_ROW.findall( + (Path(checkout) / "README.md").read_text() + ): + words = [ + ACRONYMS.get(w.lower(), w if w.isupper() and len(w) > 1 else w.lower()) + for w in name.split() + ] + title = " ".join(words) + caps[slug] = title[0].upper() + title[1:] + return caps + + +def nest(markdown: str, drop_title: bool = True) -> str: + """Moves each heading down one level, dropping the H1 when asked. 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 (drop_title and line.startswith("# ")): + out.append("#" + line) + return "\n".join(out).lstrip("\n") + + +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() - args.add_argument( - "--sdk-specs", - required=True, - help="Checkout of PostHog/sdk-specs to take capabilities from", - ) - args.add_argument( - "--renderer", default=str(Path(__file__).resolve().parent / "render.mjs") - ) + 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="Major this branch targets, defaults to the declared version's major", + "--major", type=int, help="target major, defaults to the declared one" ) args.add_argument( - "--source-ref", - help="Git ref for source links, defaults to the release tag v", + "--source-ref", help="git ref for source links, defaults to the tag" ) opts = args.parse_args() logging.getLogger("griffe").setLevel(logging.ERROR) - check_extras() - pkg = griffe.load( - "posthog", - search_paths=[str(ROOT)], - docstring_parser="google", - resolve_aliases=False, - ) - found = re.search(r'VERSION = "([^"]+)"', (ROOT / "posthog/version.py").read_text()) - if found is None: - raise SystemExit("posthog/version.py has no VERSION") - declared = found.group(1) + pkg = griffe.load("posthog", search_paths=[str(ROOT)], resolve_aliases=False) + declared = (ROOT / "posthog/version.py").read_text().split('"')[1] major = opts.major or int(declared.split(".")[0]) - # A branch that targets a newer major than it has released documents an experimental reference. + # A branch that targets a newer major than it has released documents an experimental one. status = "current" if int(declared.split(".")[0]) >= major else "experimental" version = declared if status == "current" else "unreleased" - global SOURCE - SOURCE = SOURCE.replace("v{version}", opts.source_ref or "v{version}") - data = page(pkg, version, major, status) - outputs = { - f"posthog-python-{data['major']}.md": [], - "posthog-python-migration.md": ["--migration"], - } - with ( - tempfile.NamedTemporaryFile("w", suffix=".json") as f, - tempfile.NamedTemporaryFile("w", suffix=".json") as caps, - ): - json.dump(data, f) - json.dump(sdk_specs.capabilities(opts.sdk_specs), caps) - f.flush() - caps.flush() - for name, flag in outputs.items(): - md = subprocess.run( - ["node", opts.renderer, f.name, caps.name, *flag], - check=True, - capture_output=True, - text=True, - ).stdout - if md: - (ROOT / "references" / name).write_text(md) - print(f"wrote references/{name}") + ref = opts.source_ref or f"v{version}" + started = nest((ROOT / "docs/getting-started.md").read_text()) + reference_md = reference(pkg, capabilities(opts.sdk_specs), ref) + write( + f"posthog-python-{major}.md", + [ + PAGE.format(version, major, status), + "## Getting started", + started, + *reference_md, + ], + ) + # Newest guide first, so the migration page opens on the latest upgrade. + if guides := sorted((ROOT / "docs").glob("migration-*.md"), reverse=True): + write( + "posthog-python-migration.md", + [GUIDES, *(nest(g.read_text(), drop_title=False) for g in guides)], + ) if __name__ == "__main__": diff --git a/bin/docs_scripts/render.mjs b/bin/docs_scripts/render.mjs deleted file mode 100644 index aee720468..000000000 --- a/bin/docs_scripts/render.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node -// Renders one SDK's symbol list into its reference page Markdown. -// -// Every SDK's extractor hands this the same JSON shape, so every page comes out the same way. -// Capabilities come from sdk-specs: an entry's `capability` slug picks its area and heading. -// Entries with no capability go to SDK utils. Usage: -// -// node render.mjs > reference.md -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - -/** The heading id the docs site and GitHub give a heading with this text. */ -export function slugify(text) { - return text - .toLowerCase() - .replace(/[^\p{L}\p{N}\- ]/gu, '') - .replace(/ /g, '-') -} - -const fence = (code, lang) => '```' + lang + '\n' + code.trim() + '\n```' -const code = (text) => (text ? '`' + String(text).replace(/\s*\n\s*/g, ' ').trim() + '`' : '') -/** - * Table cell text. Code fences become inline code, paragraphs join with
, and pipes are - * escaped. A pipe inside a code span splits it into spans around `\|`, since the site's MDX shows - * `\|` inside one span literally. - */ -const cellText = (text) => - String(text ?? '') - .trim() - .replace(/```\w*\n([\s\S]*?)```/g, (_, c) => '`' + c.trim().replace(/\s*\n\s*/g, '; ') + '`') - .split(/\n\s*\n/) - .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ')) - .join('
') - .split(/(`[^`]*`)/) - .map((part) => - !part.startsWith('`') - ? part.replace(/\|/g, '\\|') - : part.slice(1, -1).split(/\s*\|\s*/).map((x) => '`' + x + '`').join(' \\| ') - ) - .join('') - -/** - * One details block holding a one-column table: a row per parameter, field or setting with its - * name, a labeled type and default line, then its description. - */ -function members(title, rows) { - if (!rows?.length) return null - const head = { Parameters: 'Parameter', Fields: 'Field', Settings: 'Setting' }[title] - const lines = rows.map((r) => { - const labels = [ - r.where && `**Where:** ${r.where}`, - r.value && `**Value:** ${cellText(code(r.value))}`, - r.type && `**Type:** ${cellText(code(r.type))}`, - r.required ? '**Required**' : r.default && `**Default:** ${cellText(code(r.default))}`, - ].filter(Boolean) - const cell = [code(r.name), labels.join(' · '), r.description && cellText(r.description)].filter(Boolean).join('
') - return `| ${cell} |` - }) - return ['
', `${title} (${rows.length})`, '', `| ${head} |`, '| --- |', ...lines, '', '
'].join('\n') -} - -const sentence = (text) => (text ? text[0].toUpperCase() + text.slice(1) + (/[.!?]$/.test(text) ? '' : '.') : '') - -function example(ex, lang) { - const label = - // Source examples are correct usage unless marked as a counter example. - ex.label === 'incorrect' ? `**Counter example (incorrect).** ${sentence(ex.why)}`.trim() : '**Correct example.**' - return `${label}\n\n${fence(ex.code, lang)}` -} - -function entry(e, level, lang) { - const out = [`${'#'.repeat(level)} \`${e.qualname}\``] - if (e.deprecated) out.push(`**Deprecated.** ${e.deprecated}`) - if (e.availableIn) out.push(`**Available in:** ${e.availableIn}`) - if (e.description) out.push(e.description) - if (e.signature) out.push(fence(e.signature, lang)) - const params = members('Parameters', e.params) - if (params) out.push(params) - if (e.returns && (e.returns.type || e.returns.description)) { - out.push(`**Returns** ${code(e.returns.type)}${e.returns.description ? `. ${e.returns.description}` : ''}`.trim()) - } - for (const r of e.raises ?? []) out.push(`**Raises** ${code(r.type)}. ${r.description ?? ''}`.trim()) - if (e.notes) out.push(e.notes) - for (const ex of e.examples ?? []) out.push(example(ex, lang)) - if (e.source) out.push(`[Source](${e.source})`) - return out.join('\n\n') -} - -/** Moves every heading down one level, dropping the H1 when asked. Code fences stay as written. */ -function nest(markdown, { dropTitle = true } = {}) { - let inFence = false - return markdown - .trim() - .split('\n') - .flatMap((line) => { - if (/^\s*(```|~~~)/.test(line)) inFence = !inFence - if (inFence || !/^#{1,5} /.test(line)) return [line] - return dropTitle && line.startsWith('# ') ? [] : ['#' + line] - }) - .join('\n') - .replace(/^\n+/, '') -} - -function capabilityOf(e, index) { - const cap = index.capabilities[e.capability] - if (!cap) throw new Error(`Unknown capability "${e.capability}" on ${e.qualname}`) - if (slugify(cap.title) !== e.capability) throw new Error(`Title "${cap.title}" doesn't produce slug "${e.capability}"`) - return cap -} - -const order = (index) => (e) => { - const cap = index.capabilities[e.capability] - return [index.areas.indexOf(cap.area), Object.keys(index.capabilities).indexOf(e.capability)] -} - -function byOrder(entries, index) { - const key = order(index) - return [...entries].sort((a, b) => { - const [x, y] = [key(a), key(b)] - return x[0] - y[0] || x[1] - y[1] - }) -} - -function groups(entries, keyOf) { - const map = new Map() - for (const e of entries) map.set(keyOf(e), [...(map.get(keyOf(e)) ?? []), e]) - return map -} - -function defaultReference(ref, index, lang) { - const mapped = ref.entries.filter((e) => e.capability) - mapped.forEach((e) => capabilityOf(e, index)) - const out = [] - // Each sdk-specs capability is a top-level section, in area order. Its heading's anchor is the shared slug. - for (const [slug, entries] of groups(byOrder(mapped, index), (e) => e.capability)) { - out.push(`## ${index.capabilities[slug].title}`, ...entries.map((e) => entry(e, 3, lang))) - } - const utils = ref.entries.filter((e) => !e.capability) - if (utils.length) { - out.push('## SDK utils') - for (const [group, entries] of groups(utils, (e) => e.group ?? 'Other')) { - out.push(`### ${group}`, ...entries.map((e) => entry(e, 4, lang))) - } - } - return out -} - -function secondaryReference(ref, index, lang) { - const mapped = ref.entries.filter((e) => e.capability) - mapped.forEach((e) => capabilityOf(e, index)) - const entries = [...byOrder(mapped, index), ...ref.entries.filter((e) => !e.capability)] - return [`## ${ref.title}`, ...(ref.intro ? [ref.intro] : []), ...entries.map((e) => entry(e, 3, lang))] -} - -function type(t, lang) { - if (!t.description && !t.fields?.length && !t.methods?.length) return [] - const out = [`### \`${t.name}\``] - if (t.description) out.push(t.description) - const fields = members('Fields', t.fields) - if (fields) out.push(fields) - out.push(...(t.methods ?? []).map((m) => entry(m, 4, lang))) - if (t.source) out.push(`[Source](${t.source})`) - return [out.join('\n\n')] -} - -export function render(page, index) { - const lang = page.language - const [first, ...rest] = page.references - // current: the released major customers install. experimental: a newer major that hasn't shipped. - if (!['current', 'experimental'].includes(page.status)) throw new Error(`Unknown status "${page.status}"`) - // Getting started renders before the reference, so its headings would take capability anchors first. - for (const [, title] of page.gettingStarted.matchAll(/^#{2,6} (.+)$/gm)) { - if (index.capabilities[slugify(title)]) throw new Error(`Getting started heading "${title}" takes the "${slugify(title)}" anchor`) - } - const sections = [ - ['---', `title: ${page.title}`, `sdk: ${page.sdk}`, `version: ${page.version}`, `major: ${page.major}`, `status: ${page.status}`, '---'].join('\n'), - '## Getting started', - nest(page.gettingStarted), - ...defaultReference(first, index, lang), - ...rest.flatMap((ref) => secondaryReference(ref, index, lang)), - ] - const configs = members('Settings', page.configs) - if (configs) sections.push('## Configs', configs) - const types = (page.types ?? []).flatMap((t) => type(t, lang)) - if (types.length) sections.push('## Types', ...types) - return sections.join('\n\n') + '\n' -} - -/** The SDK's migration guides as their own page, newest first. Each guide's title becomes an H2. */ -export function renderMigration(page) { - if (!page.migrations?.length) return null - const head = ['---', `title: ${page.title} migration guides`, `sdk: ${page.sdk}`, 'kind: migration', '---'].join('\n') - return [head, ...page.migrations.map((guide) => nest(guide, { dropTitle: false }))].join('\n\n') + '\n' -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - const [pagePath, indexPath, flag] = process.argv.slice(2) - const read = (p) => JSON.parse(readFileSync(p, 'utf8')) - process.stdout.write((flag === '--migration' ? renderMigration(read(pagePath)) : render(read(pagePath), read(indexPath))) ?? '') -} diff --git a/bin/docs_scripts/render.test.mjs b/bin/docs_scripts/render.test.mjs deleted file mode 100644 index ad09ae3de..000000000 --- a/bin/docs_scripts/render.test.mjs +++ /dev/null @@ -1,167 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { render, renderMigration, slugify } from './render.mjs' - -const capabilities = { - areas: ['Events', 'Feature flags', 'Lifecycle'], - capabilities: { - capture: { title: 'Capture', area: 'Events' }, - 'get-feature-flag': { title: 'Get feature flag', area: 'Feature flags' }, - flush: { title: 'Flush', area: 'Lifecycle' }, - }, -} - -const capture = { - qualname: 'Client.capture', - capability: 'capture', - signature: 'capture(event: str, **kwargs) -> Optional[str]', - description: 'Captures an event.', - params: [ - { name: 'event', type: 'str', required: true, description: 'The event name.' }, - { name: 'timestamp', type: 'datetime | str', default: 'None', description: 'When it happened | UTC.' }, - { name: 'secret_key', default: 'None', description: 'A secret key.\nExample:\n\n```python\nClient(key, secret_key="phx_")\n```' }, - ], - returns: { type: 'Optional[str]', description: 'The event UUID, or None when it was dropped.' }, - examples: [ - { code: 'client.capture("signed_up", distinct_id="u1")' }, - { label: 'incorrect', why: 'fails because `distinct_id` is keyword-only', code: 'client.capture("u1", "signed_up")' }, - { label: 'correct', code: 'client.capture("signed_up", distinct_id="u1")' }, - ], -} - -const page = { - sdk: 'python', - title: 'PostHog Python SDK', - version: '8.0.0', - major: 8, - status: 'experimental', - language: 'python', - gettingStarted: '# Getting started\n\nInstall it.\n\n## Install\n\n```bash\npip install posthog\n```', - references: [ - { - title: 'Client', - entries: [ - { qualname: 'Client.flush', capability: 'flush', description: 'Sends queued events.' }, - capture, - { qualname: 'Client.tag', capability: null, group: 'Contexts', description: 'Adds a tag.' }, - ], - }, - { - title: 'AsyncClient', - entries: [{ qualname: 'AsyncClient.capture', capability: 'capture', description: 'Captures an event.' }], - }, - ], - configs: [{ name: 'POSTHOG_MW_CAPTURE_EXCEPTIONS', where: 'Django settings', type: 'bool', default: 'True', description: 'Captures exceptions.' }], - types: [ - { - name: 'FeatureFlagEvaluations', - kind: 'class', - description: 'A snapshot of flag values.', - fields: [], - methods: [{ qualname: 'FeatureFlagEvaluations.is_enabled', signature: 'is_enabled(key: str) -> bool', description: 'Checks a flag.' }], - }, - { name: 'Empty', kind: 'class', description: '', fields: [], methods: [] }, - { name: 'Compression', description: 'Request compression.', fields: [{ name: 'GZIP', value: '"gzip"', description: 'gzip, from the standard library.' }], methods: [] }, - ], - migrations: ['# Migrating from 7.x to 8.0\n\n## Capture\n\nIt changed.', '# Migrating from 6.x to 7.0\n\nOlder.'], -} - -const md = render(page, capabilities) -const h2 = md.split('\n').filter((l) => l.startsWith('## ')).map((l) => l.slice(3)) - -test('slugify matches heading ids', () => { - assert.equal(slugify('Get feature flag'), 'get-feature-flag') - assert.equal(slugify('Get anonymous ID'), 'get-anonymous-id') - assert.equal(slugify('`Client.capture`'), 'clientcapture') -}) - -test('top-level headings are capabilities in area order, then structure', () => { - assert.deepEqual(h2, ['Getting started', 'Capture', 'Flush', 'SDK utils', 'AsyncClient', 'Configs', 'Types']) -}) - -test('capability headings carry the shared slug', () => { - assert.match(md, /^## Capture$/m) - assert.equal(slugify('Capture'), 'capture') - assert.match(md, /^## Flush$/m) - assert.doesNotMatch(md, /^## Events$/m) -}) - -test('unknown capabilities and slug mismatches throw', () => { - const bad = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'x', capability: 'nope' }] }] } - assert.throws(() => render(bad, capabilities), /Unknown capability "nope"/) - const mismatch = { ...capabilities, capabilities: { ...capabilities.capabilities, capture: { title: 'Capture events', area: 'Events' } } } - assert.throws(() => render(page, mismatch), /slug/) -}) - -test('a getting started heading that would take a capability slug throws', () => { - const clash = { ...page, gettingStarted: '# Getting started\n\n## Capture\n\nSend one.' } - assert.throws(() => render(clash, capabilities), /Getting started heading "Capture" takes the "capture" anchor/) -}) - -test('an entry renders signature, parameters, returns and labeled examples', () => { - assert.match(md, /^### `Client\.capture`$/m) - assert.match(md, /```python\ncapture\(event: str, \*\*kwargs\) -> Optional\[str\]\n```/) - assert.match(md, /
\nParameters \(3\)<\/summary>\n\n\| Parameter \|\n\| --- \|\n\| `event`
\*\*Type:\*\* `str` · \*\*Required\*\*
The event name\. \|\n/) - assert.match(md, /^\| `timestamp`
\*\*Type:\*\* `datetime` \\\| `str` · \*\*Default:\*\* `None`
When it happened \\\| UTC\. \|$/m) - assert.match(md, /^\| `secret_key`
\*\*Default:\*\* `None`
A secret key\. Example:
`Client\(key, secret_key="phx_"\)` \|\n\n<\/details>/m) - assert.doesNotMatch(md, /^#####/m) - assert.match(md, /\*\*Returns\*\* `Optional\[str\]`\. The event UUID/) - assert.match(md, /\*\*Correct example\.\*\*\n\n```python\nclient\.capture\("signed_up", distinct_id="u1"\)\n```\n\n\*\*Counter/) - assert.doesNotMatch(md, /\*\*Example\.\*\*/) - assert.match(md, /\*\*Counter example \(incorrect\)\.\*\* Fails because `distinct_id` is keyword-only\.\n\n```python/) - assert.match(md, /\*\*Correct example\.\*\*\n\n```python\nclient\.capture\("signed_up", distinct_id="u1"\)/) -}) - -test('member names sit in table rows, so they never take a capability anchor', () => { - const clash = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'Client(...)', capability: 'capture', params: [{ name: 'flush', default: 'None' }] }, { qualname: 'Client.flush', capability: 'flush' }] }] } - const out = render(clash, capabilities) - assert.match(out, /^\| `flush`
\*\*Default:\*\* `None` \|$/m) - assert.doesNotMatch(out, /^#+ `flush`/m) -}) - -test('every parameter block sits in a details block, even a short one', () => { - const one = { ...page, references: [{ title: 'Client', entries: [{ qualname: 'Client.flush', capability: 'flush', params: [{ name: 'timeout', default: '5' }] }] }] } - assert.match(render(one, capabilities), /
\nParameters \(1\)<\/summary>\n\n\| Parameter \|\n\| --- \|\n\| `timeout`
\*\*Default:\*\* `5` \|\n\n<\/details>/) - assert.equal((md.match(/
/g) ?? []).length, (md.match(/<\/details>/g) ?? []).length) - assert.doesNotMatch(md, /^- `/m) -}) - -test('unmapped entries land in SDK utils under their group', () => { - const utils = md.slice(md.indexOf('## SDK utils')) - assert.match(utils, /^### Contexts\n\n#### `Client\.tag`/m) -}) - -test('secondary references list entries without area headings', () => { - const asyncRef = md.slice(md.indexOf('## AsyncClient'), md.indexOf('## Configs')) - assert.match(asyncRef, /^### `AsyncClient\.capture`$/m) - assert.doesNotMatch(asyncRef, /^### Capture$/m) -}) - -test('types explain members and skip empty stubs', () => { - assert.match(md, /^### `FeatureFlagEvaluations`\n\nA snapshot of flag values\./m) - assert.match(md, /^#### `FeatureFlagEvaluations\.is_enabled`$/m) - assert.doesNotMatch(md, /`Empty`/) - assert.match(md, /^\| `GZIP`
\*\*Value:\*\* `"gzip"`
gzip, from the standard library\. \|$/m) -}) - -test('guides drop their H1 and nest under their section', () => { - assert.match(md, /## Getting started\n\nInstall it\.\n\n### Install/) - assert.doesNotMatch(md, /^# /m) -}) - -test('configs render as headed settings and the page has no reverse usage lists', () => { - assert.match(md, /^## Configs\n\n
\nSettings \(1\)<\/summary>\n\n\| Setting \|\n\| --- \|\n\| `POSTHOG_MW_CAPTURE_EXCEPTIONS`
\*\*Where:\*\* Django settings · \*\*Type:\*\* `bool` · \*\*Default:\*\* `True`
Captures exceptions\. \|$/m) - assert.doesNotMatch(md, /Used by/i) -}) - -test('front matter names the SDK and version', () => { - assert.match(md, /^---\ntitle: PostHog Python SDK\nsdk: python\nversion: 8\.0\.0\nmajor: 8\nstatus: experimental\n---\n/) - assert.throws(() => render({ ...page, status: 'preview' }, capabilities), /status/) -}) - -test('migration guides render as their own page, each guide an H2', () => { - const out = renderMigration(page) - assert.match(out, /^---\ntitle: PostHog Python SDK migration guides\nsdk: python\nkind: migration\n---\n\n## Migrating from 7\.x to 8\.0\n\n### Capture\n\nIt changed\.\n\n## Migrating from 6\.x to 7\.0\n\nOlder\.\n$/) - assert.doesNotMatch(md, /Migrating from/) - assert.equal(renderMigration({ ...page, migrations: [] }), null) -}) diff --git a/bin/docs_scripts/sdk_specs.py b/bin/docs_scripts/sdk_specs.py deleted file mode 100644 index 6a4710636..000000000 --- a/bin/docs_scripts/sdk_specs.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Read sdk-specs capabilities for the reference: each slug's title, in the README table's order. - -The slug is the folder name under `openspec/specs/`, and it becomes the capability's anchor on -the reference page. Pass a checkout of PostHog/sdk-specs at the commit to document against. -""" - -import re -import subprocess -from pathlib import Path - -ACRONYMS = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} - - -def sentence_case(name: str) -> str: - words = [ - ACRONYMS.get(w.lower(), w if w.isupper() and len(w) > 1 else w.lower()) - for w in name.split() - ] - words[0] = words[0][0].upper() + words[0][1:] - return " ".join(words) - - -def capabilities(checkout: str) -> dict: - """The renderer's capability index. It fails when a spec folder has no README row.""" - root = Path(checkout) - readme = (root / "README.md").read_text() - rows = re.findall( - r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", readme, re.M - ) - if not rows: - raise SystemExit(f"{root / 'README.md'} has no capability table") - caps = {slug: {"title": sentence_case(name)} for name, slug in rows} - folders = {p.name for p in (root / "openspec/specs").iterdir() if p.is_dir()} - if folders - set(caps): - raise SystemExit( - f"sdk-specs capabilities missing from its README table: {sorted(folders - set(caps))}" - ) - head = subprocess.run( - ["git", "-C", str(root), "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - ) - return {"sdkSpecs": head.stdout.strip(), "areas": [], "capabilities": caps} From 7c20b46a569092677e5c779296f4dc606375dfb0 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 4 Nov 2026 17:00:41 -0500 Subject: [PATCH 03/12] docs: write the latest reference as its own file, with the site's front matter Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 44 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 1a4bb41e1..5503b55b4 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -18,10 +18,7 @@ ROOT = Path(__file__).resolve().parents[2] SOURCE = "[Source](https://github.com/PostHog/posthog-python/blob/{ref}/{path}#L{line})" -PAGE = "---\ntitle: PostHog Python SDK\nsdk: python\nversion: {}\nmajor: {}\nstatus: {}\n---" -GUIDES = ( - "---\ntitle: PostHog Python SDK migration guides\nsdk: python\nkind: migration\n---" -) +GUIDES = "---\ntitle: PostHog Python SDK migration guides\nsidebar: Docs\nshowTitle: true\n---" TYPES = """args.OptionalCaptureArgs args.OptionalSetArgs feature_flag_evaluations.FeatureFlagEvaluations types.FeatureFlagResult types.FeatureFlag types.FlagsAndPayloads types.FeatureFlagEvaluationRuntime types.UnresolvedFlagReason flag_definition_cache.FlagDefinitionCacheProvider flag_definition_cache.FlagDefinitionCacheData @@ -478,6 +475,27 @@ def nest(markdown: str, drop_title: bool = True) -> str: return "\n".join(out).lstrip("\n") +def front_matter(title: str, sections: list[str], noindex: bool = False) -> 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", + "contentMaxWidthClass: max-w-full", + ] + head += [ + "platformLogo: python", + *["noindex: true"] * noindex, + "tableOfContents:", + *toc, + ] + return "\n".join(["---", *head, "---"]) + + def write(name: str, sections: list[str]) -> None: (ROOT / "references" / name).write_text("\n\n".join(sections) + "\n") print(f"wrote references/{name}") @@ -503,15 +521,19 @@ def main() -> None: ref = opts.source_ref or f"v{version}" started = nest((ROOT / "docs/getting-started.md").read_text()) reference_md = reference(pkg, capabilities(opts.sdk_specs), ref) + body = ["## Getting started", started, *reference_md] + # Each major gets its own page. The current one is also the latest reference. + title = f"PostHog Python SDK {major}.x" + ( + "" if status == "current" else " (experimental)" + ) write( - f"posthog-python-{major}.md", - [ - PAGE.format(version, major, status), - "## Getting started", - started, - *reference_md, - ], + f"posthog-python-{major}.md", [front_matter(title, body, noindex=True), *body] ) + if status == "current": + write( + "posthog-python-reference.md", + [front_matter("PostHog Python SDK", body), *body], + ) # Newest guide first, so the migration page opens on the latest upgrade. if guides := sorted((ROOT / "docs").glob("migration-*.md"), reverse=True): write( From bdfa2181de4ac312a714d010fc932b5575962549 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 4 Nov 2026 17:16:38 -0500 Subject: [PATCH 04/12] docs: leave indexing to the site Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 5503b55b4..fbaf81eae 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -475,21 +475,16 @@ def nest(markdown: str, drop_title: bool = True) -> str: return "\n".join(out).lstrip("\n") -def front_matter(title: str, sections: list[str], noindex: bool = False) -> str: +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", - "contentMaxWidthClass: max-w-full", - ] + head = [f'title: "{title}"', "sidebar: Docs", "showTitle: true"] head += [ + "contentMaxWidthClass: max-w-full", "platformLogo: python", - *["noindex: true"] * noindex, "tableOfContents:", *toc, ] @@ -526,9 +521,7 @@ def main() -> None: title = f"PostHog Python SDK {major}.x" + ( "" if status == "current" else " (experimental)" ) - write( - f"posthog-python-{major}.md", [front_matter(title, body, noindex=True), *body] - ) + write(f"posthog-python-{major}.md", [front_matter(title, body), *body]) if status == "current": write( "posthog-python-reference.md", From 23e61f01227a0a7ee98fc1f45b3416fbe0608a42 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 4 Nov 2026 18:07:32 -0500 Subject: [PATCH 05/12] docs: render the reference with griffe2md Replaces the 539-line renderer with griffe2md, a dev dependency. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 559 +++++--------------------- pyproject.toml | 1 + uv.lock | 56 +++ 3 files changed, 168 insertions(+), 448 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index fbaf81eae..f17036812 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -1,478 +1,127 @@ -"""Generate the SDK reference Markdown from source with Griffe, which never imports the SDK. +"""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, grouped by -their `Category:`. +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 --source-ref v1] + python bin/docs_scripts/generate_markdown.py --sdk-specs DIR [--major 8] """ import argparse import logging import re -import textwrap from pathlib import Path -from typing import NamedTuple import griffe +import griffe2md ROOT = Path(__file__).resolve().parents[2] -SOURCE = "[Source](https://github.com/PostHog/posthog-python/blob/{ref}/{path}#L{line})" -GUIDES = "---\ntitle: PostHog Python SDK migration guides\nsidebar: Docs\nshowTitle: true\n---" -TYPES = """args.OptionalCaptureArgs args.OptionalSetArgs feature_flag_evaluations.FeatureFlagEvaluations -types.FeatureFlagResult types.FeatureFlag types.FlagsAndPayloads types.FeatureFlagEvaluationRuntime -types.UnresolvedFlagReason flag_definition_cache.FlagDefinitionCacheProvider flag_definition_cache.FlagDefinitionCacheData -feature_flags.InconclusiveMatchError feature_flags.RequiresServerEvaluation capture_send.CaptureError -capture_compression.CaptureCompression tracing.span.Span -metrics_capture.PostHogMetrics emails.Emails emails.AsyncEmails emails.EmailError emails.EmailStatus -emails.SendEmailResult ai.prompts.Prompts""".split() -UTILS = { - "Framework integrations": """integrations.django.PosthogContextMiddleware integrations.flask.PosthogFlaskIntegration - integrations.asgi.PosthogASGIMiddleware integrations.celery.PosthogCeleryIntegration - integrations.drf.create_exception_handler integrations.otel.PostHogContextSpanProcessor - integrations.otel.enrich_current_span""".split(), - "HTTP connection": "request.set_socket_options request.enable_keep_alive request.disable_connection_reuse".split(), -} -WRAPPERS = "openai anthropic gemini langchain openai_agents claude_agent_sdk".split() -CONFIGS = [("integrations.django.PosthogContextMiddleware", "Django settings")] -# Where a symbol works, by module, when that isn't everywhere Python runs. -AVAILABLE_IN = { - "async_client": "the `async` extra, `posthog[async]`", - "integrations.django": "Django apps", - "integrations.drf": "Django REST framework apps", - "integrations.flask": "Flask apps", - "integrations.celery": "Celery workers", - "integrations.asgi": "ASGI apps, such as FastAPI and Starlette", - "integrations.otel": "the `otel` extra, `posthog[otel]`", - "ai.langchain": "the `langchain` extra, `posthog[langchain]`", - "ai.openai": "apps with the `openai` package", - "ai.anthropic": "apps with the `anthropic` package", - "ai.gemini": "apps with the Google Gen AI package", - "ai.openai_agents": "apps with the `openai-agents` package", - "ai.claude_agent_sdk": "apps with the `claude-agent-sdk` package", -} -ACRONYMS = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} -STAR = {"variadic positional": "*", "variadic keyword": "**"} +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) -CAPABILITY_ROW = re.compile( - r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", re.M -) -FENCE = re.compile(r"```(\w*)\n(.*?)```", re.S) -LABEL = re.compile(r"#\s*(Correct example|Counter example \(incorrect\))\.?:?\s*(.*)") ROLE = re.compile( r":(?:py:)?(?:meth|func|class|attr|obj|data|exc|mod|const):`~?([^`]+)`" ) -LITERAL = re.compile(r"^([^\n]*?)::\n\n((?:(?:[ \t]+[^\n]*)?\n)+)", re.M) -LITERAL_VALUE = re.compile( - r"""^(None|True|False|-?\d[\d_.e]*|(["']).*\2|\[\]|\{\}|\(\))$""" -) -SETTING = re.compile(r"^\s*- `(POSTHOG_[A-Z_]+)`: (.*?)(?=^\s*- `|^\s*$)", re.M | re.S) -VALUE = re.compile(r"^(\w+): (.*?)(?=^\w+:|\Z)", re.M | re.S) -NEWLINE = re.compile(r"\s*\n\s*") - - -class Entry(NamedTuple): - slug: str | None - group: str | None - name: str - body: list[str] - - def render(self, level: int) -> str: - return "\n\n".join([f"{'#' * level} `{self.name}`", *self.body]) - - -def text(value) -> str: - return "" if value is None else str(value) - - -def get(obj, path: str): - try: - return obj[path] - except KeyError: - return None - - -def fence(code: str) -> str: - return f"```python\n{code.strip()}\n```" - - -def code(value) -> str: - return f"`{NEWLINE.sub(' ', str(value)).strip()}`" if value else "" - - -def md(value: str) -> str: - """Sphinx roles, double backticks and literal blocks become Markdown.""" - value = re.sub(r"``([^`]+)``", r"`\1`", ROLE.sub(r"`\1`", value or "")) - - def block(m: re.Match) -> str: - lead = m.group(1) + ":\n\n" if m.group(1).strip() else "" - return lead + fence(textwrap.dedent(m.group(2))) + "\n\n" - - return LITERAL.sub(block, value + "\n").strip() - - -def unpipe(span: str) -> str: - """Escapes pipes for a table. A pipe splits its code span, since MDX shows `\\|` literally.""" - if not span.startswith("`"): - return span.replace("|", "\\|") - return " \\| ".join(f"`{x}`" for x in re.split(r"\s*\|\s*", span[1:-1])) - - -def cell(value) -> str: - """Table cell text: fences become inline code and paragraphs join with
.""" - value = FENCE.sub( - lambda m: f"`{NEWLINE.sub('; ', m.group(2).strip())}`", text(value).strip() - ) - value = "
".join(NEWLINE.sub(" ", p) for p in re.split(r"\n\s*\n", value)) - return "".join(unpipe(s) for s in re.split(r"(`[^`]*`)", value)) - - -def row(name, desc="", kind="", default="", required=False, value="", where="") -> str: - """One parameter, field or setting: its name, a labels line, then its description.""" - default = ( - "**Required**" - if required - else default and f"**Default:** {cell(code(default))}" - ) - labels = [ - where and f"**Where:** {where}", - value and f"**Value:** {cell(code(value))}", - kind and f"**Type:** {cell(code(kind))}", - default, - ] - parts = [code(name), " · ".join(x for x in labels if x), cell(desc)] - return "| " + "
".join(x for x in parts if x) + " |" - - -def table(title: str, rows: list[str]) -> list[str]: - """A details block holding a one-column table.""" - head = f"{title} ({len(rows)})\n\n| {title[:-1]} |\n| --- |" - return ["\n".join(["
", head, *rows, "", "
"])] if rows else [] - - -def parse(obj) -> tuple[str | None, list]: - """The symbol's capability tag and its parsed docstring sections.""" - raw = obj.docstring.value if obj.docstring else "" - tag, body = TAG.search(raw), TAG.sub("", raw).strip() - sections = griffe.Docstring(body, parent=obj).parse("google") if body else [] - return (tag.group(1) if tag else None), sections - - -def examples(blocks: str) -> list[str]: - """Each fenced block, labeled by its first comment line. Unlabeled blocks are correct.""" - out = [] - for _, src in FENCE.findall(blocks): - head, _, rest = src.lstrip("\n").partition("\n") - label = "**Correct example.**" - if m := LABEL.match(head.strip()): - src, why = rest, m.group(2) - if m.group(1).startswith("Counter"): - why = ( - why[:1].upper() - + why[1:] - + ("." if why and why[-1] not in ".!?" else "") - ) - label = f"**Counter example (incorrect).** {why}".strip() - out.append(f"{label}\n\n{fence(src)}") - return out - - -def public(p) -> bool: - return p.name not in ("self", "cls") and not p.name.startswith("_") - - -def signature(fn, name: str) -> str: - parts = [] - for p in filter(public, fn.parameters): - star = STAR.get(p.kind.value, "") - part = star + p.name + (f": {p.annotation}" if p.annotation is not None else "") - parts.append( - part + (f" = {p.default}" if p.default is not None and not star else "") - ) - returns = ( - f" -> {fn.returns}" if fn.returns is not None and name[0].islower() else "" - ) - head = ("async " if "async" in fn.labels else "") + name - if len(line := f"{head}({', '.join(parts)}){returns}") <= 88: - return line - return f"{head}(\n" + "".join(f" {p},\n" for p in parts) + f"){returns}" - - -def params(fn, documented: dict) -> list[str]: - """A row per parameter. Each documented key of an `Unpack[...]` TypedDict gets a row.""" - rows = [] - for p in filter(public, fn.parameters): - star = STAR.get(p.kind.value, "") - unpacked = re.fullmatch(r"Unpack\[(\w+)\]", text(p.annotation)) - typed = get(fn.package, f"args.{unpacked.group(1)}") if unpacked else None - if star == "**" and typed and (fields := typed.attributes): - for n in [n for n in documented if n in fields]: - rows.append(row(n, md(documented.pop(n)), text(fields[n].annotation))) - continue - desc = md(documented.pop(p.name, "")) - default, required = ( - "" if star else text(p.default), - p.default is None and not star, - ) - rows.append(row(star + p.name, desc, text(p.annotation), default, required)) - return rows + [row(n, md(d)) for n, d in documented.items()] +CONFIG = { + **griffe2md.default_config, + "docstring_section_style": "table", + "show_root_full_path": False, + "show_signature_annotations": True, + "separate_signature": True, + "heading_level": 3, +} -def entry(obj, name: str, ref: str, group: str | None = None) -> Entry: - slug, sections = parse(obj) - texts, notes, shown, raises = [], [], [], [] - documented: dict[str, str] = {} - returns, deprecated = ("", ""), "" - for s in sections: - kind, value = s.kind.value, s.value - if kind == "text": - texts.append(value) - elif kind in ("parameters", "other parameters"): - documented |= {p.name: p.description for p in value} - elif kind == "returns": - rtype = text(value[0].annotation) or text(getattr(obj, "returns", "")) - returns = (rtype, md(" ".join(r.description for r in value))) - elif kind == "raises": - raises = [ - f"**Raises** {code(text(r.annotation))}. {md(r.description)}".strip() - for r in value - ] - elif kind == "examples": - shown += examples("\n".join(t for _, t in value)) - elif kind == "deprecated": - deprecated = f"**Deprecated.** {value.description}" - elif kind == "admonition" and s.title in ("Example", "Examples"): - body = value.description - shown += ( - examples(body) - if "```" in body - else [f"**Correct example.**\n\n{fence(body)}"] - ) - elif kind == "admonition" and s.title == "Category": - group = group or value.description.strip() - elif kind == "admonition": - notes.append(f"**{s.title}.** {md(value.description)}") - sig, rows = "", [] - if obj.is_function: - sig = signature( - obj, name.removesuffix("(...)") if obj.name == "__init__" else obj.name - ) - rows = params(obj, documented) - if not any(returns) and obj.returns is not None and obj.name != "__init__": - returns = (text(obj.returns), "") - elif obj.is_class and "__init__" in obj.members: - init = obj.members["__init__"] - args = { - p.name: p.description - for s in parse(init)[1] - if s.kind.value == "parameters" - for p in s.value - } - sig, rows = signature(init, obj.name), params(init, documented | args) - elif obj.is_attribute and obj.annotation is not None: - sig = f"{name}: {obj.annotation}" - where = available_in(obj.module.path) - returned = any(returns) and f"**Returns** {code(returns[0])}" + ( - returns[1] and f". {returns[1]}" - ) - body = [ - deprecated, - where and f"**Available in:** {where}", - md("\n\n".join(texts)), - sig and fence(sig), - *table("Parameters", rows), - returned and returned.strip(), - *raises, - "\n\n".join(notes), - *shown, - SOURCE.format(ref=ref, path=obj.relative_package_filepath, line=obj.lineno), - ] - return Entry(slug, group, name, [b for b in body if b]) +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 | None: - module = module.removeprefix("posthog.") + "." - return next( - (w for m, w in AVAILABLE_IN.items() if module.startswith(m + ".")), None - ) +def entry(obj, name: str) -> 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 members(cls) -> list: +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 (not n.startswith("_") or n == "__init__") and (m.is_function or "property" in m.labels) + and (not n.startswith("_") or n == "__init__") ] -def type_block(cls, ref: str) -> str: - """A type's section. Dataclasses, TypedDicts, enums and errors show their fields.""" - sections = parse(cls)[1] + ( - parse(cls.members["__init__"])[1] if "__init__" in cls.members else [] +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 ) - documented = { - i.name: i.description - for s in sections - if s.kind.value in ("attributes", "parameters") - for i in s.value + 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 } - for s in sections: - if s.kind.value == "admonition" and s.title == "Values": - documented |= { - n: " ".join(d.split()) for n, d in VALUE.findall(s.value.description) - } - bases = " ".join(text(b) for b in cls.bases) - data = any("dataclass" in text(d.value) for d in cls.decorators) or any( - k in bases for k in ("TypedDict", "Enum", "Exception", "Error") - ) - rows = [] - for n, a in cls.attributes.items(): - if data and not n.startswith("_") and "property" not in a.labels: - # Only a literal is a default. A field set from an __init__ argument has none. - literal = text(a.value) if LITERAL_VALUE.match(text(a.value)) else "" - desc = md(a.docstring.value if a.docstring else documented.get(n, "")) - enum = "Enum" in bases - rows.append( - row(n, desc, value=literal) - if enum - else row(n, desc, text(a.annotation), literal) - ) - methods = [ - entry(m, f"{cls.name}.{m.name}", ref).render(4) - for m in members(cls) - if m.name != "__init__" - ] - desc = md("\n\n".join(s.value for s in parse(cls)[1] if s.kind.value == "text")) - if not (desc or rows or methods): - return "" - src = SOURCE.format(ref=ref, path=cls.relative_package_filepath, line=cls.lineno) - return "\n\n".join( - [ - f"### `{cls.name}`", - *filter(None, [desc]), - *table("Fields", rows), - *methods, - src, - ] - ) + # 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 reference(pkg, caps: dict[str, str], ref: str) -> list[str]: - """Every capability, SDK utils, AsyncClient, module functions, configs and types.""" +def has(pkg, path: str) -> bool: + try: + pkg[path] + return True + except KeyError: + return False - def methods(cls: str, prefix: str) -> list[Entry]: - names = {"__init__": "(...)"} - return [ - entry(m, prefix + names.get(m.name, f".{m.name}"), ref) - for m in members(pkg[cls]) - ] - client, asyncs = ( - methods("client.Client", "Client"), - methods("async_client.AsyncClient", "AsyncClient"), +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 = [ - entry(m, f"posthog.{n}", ref) + m for n, m in pkg.members.items() if not m.is_alias and m.is_function and not n.startswith("_") ] - for group, paths in UTILS.items(): - for obj, path in [(get(pkg, p), p) for p in paths]: - if obj is not None: - client.append(entry(obj, f"posthog.{path}", ref, group)) - if obj.is_class: - client += [ - entry(m, f"{obj.name}.{m.name}", ref, group) - for m in members(obj) - if m.name != "__init__" - ] - for name in WRAPPERS: - if (mod := get(pkg, f"ai.{name}")) is not None: - names = ", ".join( - f"`{n}`" - for n in mod.members - if not n.startswith("_") and n[0].isupper() - ) - summary = md(mod.docstring.value.split("\n\n")[0]) if mod.docstring else "" - where = available_in(f"ai.{name}") - body = [ - where and f"**Available in:** {where}", - " ".join(filter(None, [summary, f"Exports {names}."])), - ] - client.append( - Entry( - None, - "AI wrapper clients", - f"posthog.ai.{name}", - [b for b in body if b], - ) - ) - if unknown := {e.slug for e in client + asyncs + module if e.slug} - caps.keys(): - raise SystemExit( - f"posthog-capability tags sdk-specs doesn't define: {sorted(unknown)}" - ) - - # Each capability is titled as in sdk-specs, so its heading's anchor is the capability's slug. + for obj in [*module, *utils, *types]: + tag(obj) out = [] for slug, title in caps.items(): - if tagged := [e for e in client if e.slug == slug]: - out += [f"## {title}", *(e.render(3) for e in tagged)] - if utils := [e for e in client if not e.slug]: - out.append("## SDK utils") - for group in dict.fromkeys(e.group or "Other" for e in utils): - out += [ - f"### {group}", - *(e.render(4) for e in utils if (e.group or "Other") == group), - ] - for title, entries in (("AsyncClient", asyncs), ("Module functions", module)): - ordered = sorted( - entries, key=lambda e: list(caps).index(e.slug) if e.slug else len(caps) - ) - out += [f"## {title}", *(e.render(3) for e in ordered)] - settings = [] - for path, where in CONFIGS: - cls = get(pkg, path) - doc = cls.docstring.value if cls and cls.docstring else "" - settings += [ - row(n, md(" ".join(d.split())), where=where) - for n, d in SETTING.findall(doc) - ] - if settings: - out += ["## Configs", *table("Settings", settings)] - if types := [ - t for t in (type_block(pkg[p], ref) for p in TYPES if get(pkg, p)) if t - ]: - out += ["## Types", *types] - return out - - -def capabilities(checkout: str) -> dict[str, str]: - """Each sdk-specs capability's slug and title in sentence case, in the README's order.""" - caps = {} - for name, slug in CAPABILITY_ROW.findall( - (Path(checkout) / "README.md").read_text() - ): - words = [ - ACRONYMS.get(w.lower(), w if w.isupper() and len(w) > 1 else w.lower()) - for w in name.split() - ] - title = " ".join(words) - caps[slug] = title[0].upper() + title[1:] - return caps - - -def nest(markdown: str, drop_title: bool = True) -> str: - """Moves each heading down one level, dropping the H1 when asked. 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 (drop_title and line.startswith("# ")): - out.append("#" + line) - return "\n".join(out).lstrip("\n") + 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: @@ -491,6 +140,18 @@ def front_matter(title: str, sections: list[str]) -> str: 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}") @@ -502,36 +163,38 @@ def main() -> None: args.add_argument( "--major", type=int, help="target major, defaults to the declared one" ) - args.add_argument( - "--source-ref", help="git ref for source links, defaults to the tag" - ) opts = args.parse_args() logging.getLogger("griffe").setLevel(logging.ERROR) - pkg = griffe.load("posthog", search_paths=[str(ROOT)], resolve_aliases=False) - declared = (ROOT / "posthog/version.py").read_text().split('"')[1] - major = opts.major or int(declared.split(".")[0]) - # A branch that targets a newer major than it has released documents an experimental one. - status = "current" if int(declared.split(".")[0]) >= major else "experimental" - version = declared if status == "current" else "unreleased" - ref = opts.source_ref or f"v{version}" + 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()) - reference_md = reference(pkg, capabilities(opts.sdk_specs), ref) - body = ["## Getting started", started, *reference_md] - # Each major gets its own page. The current one is also the latest reference. + body = [ + "## Getting started", + started, + *reference(pkg, capabilities(opts.sdk_specs)), + ] title = f"PostHog Python SDK {major}.x" + ( - "" if status == "current" else " (experimental)" + "" if declared >= major else " (experimental)" ) write(f"posthog-python-{major}.md", [front_matter(title, body), *body]) - if status == "current": + if declared >= major: write( "posthog-python-reference.md", [front_matter("PostHog Python SDK", body), *body], ) - # Newest guide first, so the migration page opens on the latest upgrade. 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", - [GUIDES, *(nest(g.read_text(), drop_title=False) for g in guides)], + [head, *(g.read_text().strip() for g in guides)], ) 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" }, From 6f5be015e12f8dcf68ac157e51caed936a230272 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 14:26:35 -0500 Subject: [PATCH 06/12] docs: write signatures with griffe and mark async methods griffe2md 1.5.1's signature template gives an unannotated parameter the previous parameter's annotation, so Client(...) showed `debug: str = False`. It also drops `async`. Swap its format_signature filter for griffe's own Function.signature(), prefixed with `async` for coroutines. Also document OptionalSetArgs and CodeEmailArgs in Types, since set, set_once and Emails.send reference them. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index f17036812..0b1a3fe99 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -18,7 +18,7 @@ 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() +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() @@ -47,8 +47,20 @@ def tag(obj) -> str | None: return found.group(1) if found else None +def signature(name, function, *_, **__) -> str: + """griffe2md's signature template gives an unannotated parameter the previous one's + annotation and drops `async`, so griffe writes the signature instead.""" + prefix = "async " if "async" in function.labels else "" + return prefix + function.signature(name=str(name).strip()) + + +ENV = griffe2md.prepare_env() +ENV.filters["format_signature"] = signature + + def entry(obj, name: str) -> str: - md = griffe2md.render_object_docs(obj, CONFIG) + 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. From 4e27330e474cb5beb1cf656b227caa08185e2f8b Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 14:35:55 -0500 Subject: [PATCH 07/12] docs: map capabilities in docs/capabilities.json instead of docstrings The posthog-capability lines showed in IDE hover help. The docstrings read as they do on main again, Category sections included, and the generator drops those sections from the reference. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 57 +++++++++++++------ docs/capabilities.json | 80 +++++++++++++++++++++++++++ posthog/__init__.py | 65 ++++++++++++++-------- posthog/async_client.py | 27 +-------- posthog/client.py | 70 +++++++++++++---------- 5 files changed, 205 insertions(+), 94 deletions(-) create mode 100644 docs/capabilities.json diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 0b1a3fe99..f7bfeb054 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -1,12 +1,14 @@ """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. +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 json import logging import re from pathlib import Path @@ -23,7 +25,6 @@ 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):`~?([^`]+)`" ) @@ -37,14 +38,14 @@ } -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 +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`", TAG.sub("", o.docstring.value)) + 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: @@ -109,21 +110,43 @@ def has(pkg, path: str) -> bool: return False -def reference(pkg, caps: dict[str, str]) -> list[str]: +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)) - 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) + 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]: @@ -191,7 +214,7 @@ def main() -> None: body = [ "## Getting started", started, - *reference(pkg, capabilities(opts.sdk_specs)), + *reference(pkg, capabilities(opts.sdk_specs), declared >= major), ] title = f"PostHog Python SDK {major}.x" + ( "" if declared >= major else " (experimental)" diff --git a/docs/capabilities.json b/docs/capabilities.json new file mode 100644 index 000000000..db0d5f409 --- /dev/null +++ b/docs/capabilities.json @@ -0,0 +1,80 @@ +{ + "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_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_session", + "Client.tag" + ] +} diff --git a/posthog/__init__.py b/posthog/__init__.py index 81ee63f27..d9f6d174f 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -530,7 +530,8 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: } ) ``` - posthog-capability: capture + Category: + Events """ return _proxy("capture", event, **kwargs) @@ -565,7 +566,8 @@ def capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[st ) ``` - posthog-capability: capture-ai + Category: + Events """ return _proxy("capture_ai", event, **kwargs) @@ -596,7 +598,8 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: from posthog import set set(distinct_id='distinct_id', properties={'name': 'Max Hedgehog'}) ``` - posthog-capability: identify + Category: + Identification """ return _proxy("set", **kwargs) @@ -629,7 +632,8 @@ def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: set_once(distinct_id='distinct_id', properties={'initial_url': '/blog'}) ``` - posthog-capability: identify + Category: + Identification """ return _proxy("set_once", **kwargs) @@ -667,7 +671,8 @@ def group_identify( 'employees': 11 }) ``` - posthog-capability: group-identify + Category: + Identification """ return _proxy( @@ -709,7 +714,8 @@ def alias( from posthog import alias alias(previous_id='distinct_id', distinct_id='alias_id') ``` - posthog-capability: alias + Category: + Identification """ return _proxy( @@ -748,7 +754,8 @@ def capture_exception( except Exception as e: capture_exception(e) ``` - posthog-capability: capture-exception + Category: + Events """ return _proxy("capture_exception", exception=exception, **kwargs) @@ -790,7 +797,8 @@ def feature_enabled( if is_my_flag_enabled: matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - posthog-capability: is-feature-enabled + Category: + Feature flags """ return _proxy( "feature_enabled", @@ -842,7 +850,8 @@ def get_feature_flag( if enabled_variant == 'variant-key': matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - posthog-capability: get-feature-flag + Category: + Feature flags """ return _proxy( "get_feature_flag", @@ -890,7 +899,8 @@ def get_all_flags( from posthog import get_all_flags get_all_flags('distinct_id_of_your_user') ``` - posthog-capability: get-feature-flags + Category: + Feature flags """ return _proxy( "get_all_flags", @@ -945,8 +955,6 @@ 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", @@ -997,7 +1005,8 @@ def get_feature_flag_payload( This function returns the payload only, not the FeatureFlagResult wrapper used internally to compute it. - posthog-capability: get-feature-flag-payload + Category: + Feature flags """ return _proxy( "get_feature_flag_payload", @@ -1061,7 +1070,8 @@ def get_all_flags_and_payloads( Returns: A dict with ``featureFlags`` and ``featureFlagPayloads`` entries. - posthog-capability: get-feature-flags-and-payloads + Category: + Feature flags """ return _proxy( "get_all_flags_and_payloads", @@ -1124,7 +1134,8 @@ def evaluate_flags( capture("page_viewed", distinct_id="user_123", flags=flags) ``` - posthog-capability: evaluate-flags + Category: + Feature flags """ return _proxy( "evaluate_flags", @@ -1152,7 +1163,8 @@ def feature_flag_definitions(): definitions = feature_flag_definitions() ``` - posthog-capability: flag-definition-loader + Category: + Feature flags """ return _proxy("feature_flag_definitions") @@ -1215,7 +1227,8 @@ def load_feature_flags(): load_feature_flags() ``` - posthog-capability: flag-definition-loader + Category: + Feature flags """ return _proxy("load_feature_flags") @@ -1257,7 +1270,8 @@ def flush(timeout_seconds: Optional[float] = 10) -> None: posthog.flush(timeout_seconds=10) ``` - posthog-capability: flush + Category: + Client management """ _proxy("flush", timeout_seconds=timeout_seconds) @@ -1276,7 +1290,8 @@ def join() -> None: join() ``` - posthog-capability: shutdown + Category: + Client management """ _proxy("join") @@ -1323,7 +1338,8 @@ def start_span( outgoing_headers = {"traceparent": span.traceparent()} ``` - posthog-capability: traces + Category: + Tracing """ return _proxy( "start_span", @@ -1351,7 +1367,8 @@ def get_active_span() -> Optional[Span]: headers["traceparent"] = span.traceparent() ``` - posthog-capability: traces + Category: + Tracing """ return _proxy("get_active_span") @@ -1386,7 +1403,8 @@ def shutdown() -> None: shutdown() ``` - posthog-capability: shutdown + Category: + Client management """ _proxy("shutdown") @@ -1404,7 +1422,8 @@ def setup() -> Client: ``project_api_key`` are missing or blank, the client is disabled and module-level calls become no-ops. - posthog-capability: setup + Category: + Initialization """ global default_client if not default_client: diff --git a/posthog/async_client.py b/posthog/async_client.py index 639c1240c..48de0abd6 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -139,7 +139,6 @@ def __init__( email_request_max_retries: int = 2, migrated_from: Optional[str] = None, ) -> None: - """posthog-capability: setup""" if flush_at <= 0: raise ValueError("flush_at must be greater than zero") if flush_interval <= 0: @@ -288,8 +287,6 @@ def emails(self) -> AsyncEmails: distinct_id="user_123", ) ``` - - posthog-capability: emails """ if self._emails is None: self._emails = _client_emails( @@ -575,10 +572,7 @@ def _build_capture_event( def capture( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: - """Queue an event without blocking for network delivery. - - posthog-capability: capture - """ + """Queue an event without blocking for network delivery.""" try: msg, disable_geoip, property_allowlist = self._build_capture_event( event, kwargs @@ -618,10 +612,7 @@ 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. - - posthog-capability: capture - """ + """Capture one event and wait until its delivery attempt completes.""" current = asyncio.current_task() if current is None: # pragma: no cover - async functions always have a task return None @@ -704,7 +695,6 @@ 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: @@ -717,7 +707,6 @@ 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: @@ -739,7 +728,6 @@ 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( @@ -782,7 +770,6 @@ 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: @@ -830,10 +817,7 @@ def capture_exception( exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs], ) -> Optional[str]: - """Capture an exception. This method never raises, including in debug mode. - - posthog-capability: capture-exception - """ + """Capture an exception. This method never raises, including in debug mode.""" try: if exception is not None and exception_is_already_captured(exception): self.log.debug("Exception already captured, skipping") @@ -978,8 +962,6 @@ 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: @@ -1207,7 +1189,6 @@ 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 @@ -1238,7 +1219,6 @@ 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 @@ -1308,7 +1288,6 @@ async def shutdown(self) -> None: raise errors[0] async def join(self) -> None: - """posthog-capability: shutdown""" await self.shutdown() diff --git a/posthog/client.py b/posthog/client.py index fa206b9d0..87ba50705 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -887,7 +887,8 @@ def __init__( posthog = Posthog('', host='') ``` - posthog-capability: setup + Category: + Initialization """ # api_key: This should be the Team API Key (token), public self.api_key = (project_api_key or "").strip() @@ -1279,8 +1280,6 @@ def emails(self) -> Emails: distinct_id="user_123", ) ``` - - posthog-capability: emails """ if self._emails is None: self._emails = _client_emails( @@ -1526,7 +1525,8 @@ def get_feature_variants( only these flags will be evaluated, improving performance. device_id: The device ID for this request. - posthog-capability: get-feature-flags + Category: + Feature flags """ resp_data = self.get_flags_decision( distinct_id, @@ -1612,7 +1612,8 @@ def get_feature_flags_and_payloads( result = posthog.get_feature_flags_and_payloads('') ``` - posthog-capability: get-feature-flags-and-payloads + Category: + Feature flags """ resp = self.get_flags_decision( distinct_id, @@ -1797,7 +1798,8 @@ def capture( posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'}) ``` - posthog-capability: capture + Category: + Capture """ return self._capture(event, self._analytics_lane, **kwargs) @@ -1817,7 +1819,8 @@ 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. - posthog-capability: capture-ai + Category: + Capture """ if not event.startswith("$ai_"): self.log.debug( @@ -2020,7 +2023,8 @@ def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'}) ``` - posthog-capability: identify + Category: + Identification Note: This method will not raise exceptions. Errors are logged. """ @@ -2069,7 +2073,8 @@ def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'}) ``` - posthog-capability: identify + Category: + Identification Note: This method will not raise exceptions. Errors are logged. """ @@ -2133,7 +2138,8 @@ def group_identify( }) ``` - posthog-capability: group-identify + Category: + Identification Note: This method will not raise exceptions. Errors are logged. """ @@ -2202,7 +2208,8 @@ def alias( posthog.alias(previous_id='distinct_id', distinct_id='alias_id') ``` - posthog-capability: alias + Category: + Identification Note: This method will not raise exceptions. Errors are logged. """ @@ -2269,7 +2276,8 @@ def capture_exception( posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties) ``` - posthog-capability: capture-exception + Category: + Error Tracking """ distinct_id = kwargs.get("distinct_id", None) properties = kwargs.get("properties", None) @@ -2843,7 +2851,8 @@ def start_span( outgoing_headers = {"traceparent": span.traceparent()} ``` - posthog-capability: traces + Category: + Tracing """ pipeline = self._traces_pipeline if pipeline is None: @@ -2872,7 +2881,8 @@ def get_active_span(self) -> Optional[Span]: headers["traceparent"] = span.traceparent() ``` - posthog-capability: traces + Category: + Tracing """ return self._active_span_var.get() @@ -2914,8 +2924,6 @@ 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( @@ -3359,8 +3367,6 @@ def join(self) -> None: ```python posthog.join() ``` - - posthog-capability: shutdown """ with self._lifecycle_lock: self._join_requested = True @@ -3400,8 +3406,6 @@ def shutdown(self) -> None: ```python posthog.shutdown() ``` - - posthog-capability: shutdown """ with self._lifecycle_lock: if self._shutdown_complete: @@ -3838,7 +3842,8 @@ def load_feature_flags(self): posthog.load_feature_flags() ``` - posthog-capability: flag-definition-loader + Category: + Feature flags """ if self.disabled: with self._flag_definition_publication_lock: @@ -4022,7 +4027,8 @@ def feature_enabled( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - posthog-capability: is-feature-enabled + Category: + Feature flags """ warnings.warn( "`feature_enabled` is deprecated and will be removed in a future major version. " @@ -4313,8 +4319,6 @@ 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, @@ -4364,7 +4368,8 @@ def get_feature_flag( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - posthog-capability: get-feature-flag + Category: + Feature flags """ warnings.warn( "`get_feature_flag` is deprecated and will be removed in a future major version. " @@ -4472,7 +4477,8 @@ def get_feature_flag_payload( matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user') ``` - posthog-capability: get-feature-flag-payload + Category: + Feature flags """ warnings.warn( "`get_feature_flag_payload` is deprecated and will be removed in a future major " @@ -4779,7 +4785,8 @@ def get_all_flags( posthog.get_all_flags('distinct_id_of_your_user') ``` - posthog-capability: get-feature-flags + Category: + Feature flags """ response = self.get_all_flags_and_payloads( distinct_id, @@ -4825,7 +4832,8 @@ def get_all_flags_and_payloads( posthog.get_all_flags_and_payloads('distinct_id_of_your_user') ``` - posthog-capability: get-feature-flags-and-payloads + Category: + Feature flags """ if self.disabled: return {"featureFlags": None, "featureFlagPayloads": None} @@ -4938,7 +4946,8 @@ def evaluate_flags( posthog.capture("page_viewed", distinct_id="user_123", flags=flags) ``` - posthog-capability: evaluate-flags + Category: + Feature flags """ host = self._get_feature_flag_evaluations_host() @@ -5264,7 +5273,8 @@ def feature_flag_definitions(self): The currently loaded feature flag definitions, or ``None`` before local evaluation has loaded definitions. - posthog-capability: flag-definition-loader + Category: + Feature flags """ return self.feature_flags From 47f7b75cc982f2241c48c2c74e5d7884bf9ce33f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 14:38:00 -0500 Subject: [PATCH 08/12] docs: list v1's context option methods in SDK utils Co-Authored-By: Claude Opus 5.5 --- docs/capabilities.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/capabilities.json b/docs/capabilities.json index db0d5f409..7bf0d6031 100644 --- a/docs/capabilities.json +++ b/docs/capabilities.json @@ -62,6 +62,7 @@ "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", @@ -74,6 +75,7 @@ "Client.queue", "Client.scoped", "Client.set_context_device_id", + "Client.set_context_option", "Client.set_context_session", "Client.tag" ] From 51c7b4606e76e6f93fd6bcbe885fd439b7387388 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 15:47:42 -0500 Subject: [PATCH 09/12] test: check the reference generator's signatures Covers the two output bugs review reproduced: unannotated parameters getting the previous parameter's annotation, and coroutines missing async. Also checks the keyword-only marker. griffe and griffe2md join the test extra so CI runs them. Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_reference_markdown.py | 47 +++++++++++++++++++++++++ pyproject.toml | 3 ++ uv.lock | 5 +++ 3 files changed, 55 insertions(+) create mode 100644 posthog/test/test_reference_markdown.py diff --git a/posthog/test/test_reference_markdown.py b/posthog/test/test_reference_markdown.py new file mode 100644 index 000000000..45676cb47 --- /dev/null +++ b/posthog/test/test_reference_markdown.py @@ -0,0 +1,47 @@ +"""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", + 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 + + +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") diff --git a/pyproject.toml b/pyproject.toml index e340fc38b..e6241040e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,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 af1685e87..7f0750642 100644 --- a/uv.lock +++ b/uv.lock @@ -3318,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" }, @@ -3378,7 +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" }, From 72316e682f93ef1a61608da35ec0f4a42e2f469c Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 16:12:01 -0500 Subject: [PATCH 10/12] docs: leave private parameters out of reference signatures Client(...) listed _use_ai_lane and _enable_multimodal_capture. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 10 ++++++++-- posthog/test/test_reference_markdown.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index f7bfeb054..9923b5a12 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -8,6 +8,7 @@ """ import argparse +import copy import json import logging import re @@ -50,9 +51,14 @@ def clean(obj) -> None: def signature(name, function, *_, **__) -> str: """griffe2md's signature template gives an unannotated parameter the previous one's - annotation and drops `async`, so griffe writes the signature instead.""" + 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 "" - return prefix + function.signature(name=str(name).strip()) + public = copy.copy(function) + public.parameters = griffe.Parameters( + *(p for p in function.parameters if not p.name.startswith("_")) + ) + return prefix + public.signature(name=str(name).strip()) ENV = griffe2md.prepare_env() diff --git a/posthog/test/test_reference_markdown.py b/posthog/test/test_reference_markdown.py index 45676cb47..66261a65b 100644 --- a/posthog/test/test_reference_markdown.py +++ b/posthog/test/test_reference_markdown.py @@ -36,6 +36,7 @@ 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): From 325861c1158d333e60537316b1632d5e4b495099 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 19:27:51 -0500 Subject: [PATCH 11/12] docs: keep whole summaries and Returns, and call constructors by class name - Summary tables keep a docstring's first paragraph, not its first line. - A Returns section is one return value, even when its description wraps. - A constructor's signature reads `Client(...)`, not `__init__(...)`. Co-Authored-By: Claude Opus 5.5 --- bin/docs_scripts/generate_markdown.py | 25 ++++++++++++++++++++++++- posthog/test/test_reference_markdown.py | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/bin/docs_scripts/generate_markdown.py b/bin/docs_scripts/generate_markdown.py index 9923b5a12..8ade17f58 100644 --- a/bin/docs_scripts/generate_markdown.py +++ b/bin/docs_scripts/generate_markdown.py @@ -58,11 +58,32 @@ def signature(name, function, *_, **__) -> str: public.parameters = griffe.Parameters( *(p for p in function.parameters if not p.name.startswith("_")) ) - return prefix + public.signature(name=str(name).strip()) + # 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: @@ -210,6 +231,8 @@ def main() -> None: "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( diff --git a/posthog/test/test_reference_markdown.py b/posthog/test/test_reference_markdown.py index 66261a65b..bba347504 100644 --- a/posthog/test/test_reference_markdown.py +++ b/posthog/test/test_reference_markdown.py @@ -22,6 +22,7 @@ def pkg(): "posthog", search_paths=[str(ROOT)], docstring_parser="google", + docstring_options={"returns_multiple_items": False}, resolve_aliases=False, ) @@ -46,3 +47,21 @@ def test_coroutines_are_marked_async(pkg): 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 From fb0019a4672524fc384b9e45b716dad528016662 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 5 Nov 2026 19:27:51 -0500 Subject: [PATCH 12/12] docs: fix three docstring examples - The context example tags with `tag`, since `tag_context` doesn't exist. - The `capture_exception` example passes `distinct_id` by keyword, since it's keyword-only. - Two reST `Example::` blocks become Markdown, which the reference renders. Co-Authored-By: Claude Opus 5.5 --- posthog/__init__.py | 4 ++-- posthog/client.py | 7 +++---- posthog/feature_flag_evaluations.py | 12 ++++++------ 3 files changed, 11 insertions(+), 12 deletions(-) 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/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/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