Repository navigation
docs: render the Markdown reference with griffe2md (alternative to #1182) #1187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d811ca2
b5a6328
7c20b46
bdfa218
1223cb5
3f97627
57c9359
22efef7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| """Generate the SDK reference Markdown from source with griffe2md, which never imports the SDK. | ||
|
|
||
| A `posthog-capability: <slug>` docstring line puts a symbol under that PostHog/sdk-specs | ||
| capability, in the sdk-specs README's order. Untagged symbols go to SDK utils. | ||
|
|
||
| python bin/docs_scripts/generate_markdown.py --sdk-specs DIR [--major 8] | ||
| """ | ||
|
|
||
| import argparse | ||
| import logging | ||
| import re | ||
| from pathlib import Path | ||
|
|
||
| import griffe | ||
| import griffe2md | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[2] | ||
| TYPES = """args.OptionalCaptureArgs feature_flag_evaluations.FeatureFlagEvaluations types.FeatureFlagResult | ||
| types.FeatureFlag types.FlagsAndPayloads types.UnresolvedFlagReason feature_flags.InconclusiveMatchError | ||
| feature_flags.RequiresServerEvaluation capture_send.CaptureError tracing.span.Span emails.Emails | ||
| emails.EmailError emails.SendEmailResult""".split() | ||
| UTILS = """integrations.django.PosthogContextMiddleware integrations.flask.PosthogFlaskIntegration | ||
| integrations.asgi.PosthogASGIMiddleware integrations.celery.PosthogCeleryIntegration | ||
| request.set_socket_options request.enable_keep_alive request.disable_connection_reuse""".split() | ||
| FIRST = ["setup", "capture", "identify"] | ||
| TAG = re.compile(r"^[ \t]*posthog-capability:[ \t]*([a-z0-9-]+)[ \t]*$\n?", re.M) | ||
| ROLE = re.compile( | ||
| r":(?:py:)?(?:meth|func|class|attr|obj|data|exc|mod|const):`~?([^`]+)`" | ||
| ) | ||
| CONFIG = { | ||
| **griffe2md.default_config, | ||
| "docstring_section_style": "table", | ||
| "show_root_full_path": False, | ||
| "show_signature_annotations": True, | ||
| "separate_signature": True, | ||
| "heading_level": 3, | ||
| } | ||
|
|
||
|
|
||
| def tag(obj) -> str | None: | ||
| """The capability tag. It and any Sphinx roles are stripped from the docstrings rendered.""" | ||
| found = TAG.search(obj.docstring.value) if obj.docstring else None | ||
| for o in [obj, *(m for m in obj.members.values() if not m.is_alias)]: | ||
| if o.docstring: | ||
| text = ROLE.sub(r"`\1`", TAG.sub("", o.docstring.value)) | ||
| o.docstring.value = re.sub(r"(?<!`)``([^`]+)``(?!`)", r"`\1`", text) | ||
| return found.group(1) if found else None | ||
|
|
||
|
|
||
| def entry(obj, name: str) -> str: | ||
| md = griffe2md.render_object_docs(obj, CONFIG) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 Β· Identify coroutine methods in the rendered reference When |
||
| # 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 <code> are expressions. | ||
| md = re.sub(r"<code>(.*?)</code>", r"`\1`", md.replace("<br>", "<br />")) | ||
| # A parameter documented only in the docstring, like a **kwargs key, isn't required. | ||
| md = re.sub(r"^(`\w+` \| \| .*) \| \*required\*$", r"\1 | ", md, flags=re.M) | ||
| return re.sub(r"^### .*$", f"### `{name}`", md.strip(), count=1, flags=re.M) | ||
|
|
||
|
|
||
| def methods(cls) -> list: | ||
| """Public methods and properties, and `__init__`.""" | ||
| return [ | ||
| m | ||
| for n, m in cls.members.items() | ||
| if not m.is_alias | ||
| and (m.is_function or "property" in m.labels) | ||
| and (not n.startswith("_") or n == "__init__") | ||
| ] | ||
|
|
||
|
|
||
| def label(prefix: str, m) -> str: | ||
| return f"{prefix}(...)" if m.name == "__init__" else f"{prefix}.{m.name}" | ||
|
|
||
|
|
||
| def capabilities(checkout: str) -> dict[str, str]: | ||
| """Each sdk-specs capability's slug and title in sentence case, in the README's order.""" | ||
| readme = (Path(checkout) / "README.md").read_text() | ||
| rows = re.findall( | ||
| r"^\| ([^|]+?) \| \[`openspec/specs/([a-z0-9-]+)/spec\.md`\]", readme, re.M | ||
| ) | ||
| acronyms = {"id": "ID", "ai": "AI", "http": "HTTP", "mcp": "MCP"} | ||
| titles = { | ||
| s: " ".join(acronyms.get(w.lower(), w.lower()) for w in n.split()) | ||
| for n, s in rows | ||
| } | ||
| # Setup, capture and identify come first; the rest follow the README. | ||
| order = [*FIRST, *(s for s in titles if s not in FIRST)] | ||
| return {s: titles[s][0].upper() + titles[s][1:] for s in order if s in titles} | ||
|
|
||
|
|
||
| def has(pkg, path: str) -> bool: | ||
| try: | ||
| pkg[path] | ||
| return True | ||
| except KeyError: | ||
| return False | ||
|
|
||
|
|
||
| def reference(pkg, caps: dict[str, str]) -> list[str]: | ||
| order = {slug: i for i, slug in enumerate(caps)} | ||
| utils, types = ([pkg[p] for p in paths if has(pkg, p)] for paths in (UTILS, TYPES)) | ||
| client = [(tag(m), m) for m in methods(pkg["client.Client"])] | ||
| asyncs = sorted( | ||
| ((tag(m), m) for m in methods(pkg["async_client.AsyncClient"])), | ||
| key=lambda e: order.get(e[0] or "", len(order)), | ||
| ) | ||
| module = [ | ||
| m | ||
| for n, m in pkg.members.items() | ||
| if not m.is_alias and m.is_function and not n.startswith("_") | ||
| ] | ||
| for obj in [*module, *utils, *types]: | ||
| tag(obj) | ||
| out = [] | ||
| for slug, title in caps.items(): | ||
| if tagged := [m for t, m in client if t == slug]: | ||
| out += [f"## {title}", *(entry(m, label("Client", m)) for m in tagged)] | ||
| out += ["## SDK utils", *(entry(m, label("Client", m)) for t, m in client if not t)] | ||
| out += [entry(o, f"posthog.{o.path.removeprefix('posthog.')}") for o in utils] | ||
| out += ["## AsyncClient", "Needs the `async` extra, `posthog[async]`."] | ||
| out += [entry(m, label("AsyncClient", m)) for _, m in asyncs] | ||
| out += ["## Module functions", *(entry(m, f"posthog.{m.name}") for m in module)] | ||
| return out + ["## Types", *(entry(o, o.name) for o in types)] | ||
|
|
||
|
|
||
| def front_matter(title: str, sections: list[str]) -> str: | ||
| """The docs site's front matter. Its ToC lists the page's H2 sections only.""" | ||
| toc = [] | ||
| for heading in [s[3:] for s in sections if s.startswith("## ")]: | ||
| anchor = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-") | ||
| toc.append(f' - value: "{heading}"\n url: {anchor}\n depth: 0') | ||
| head = [f'title: "{title}"', "sidebar: Docs", "showTitle: true"] | ||
| head += [ | ||
| "contentMaxWidthClass: max-w-full", | ||
| "platformLogo: python", | ||
| "tableOfContents:", | ||
| *toc, | ||
| ] | ||
| return "\n".join(["---", *head, "---"]) | ||
|
|
||
|
|
||
| def nest(markdown: str) -> str: | ||
| """Moves each heading down one level and drops the H1. Code stays as written.""" | ||
| out, in_fence = [], False | ||
| for line in markdown.strip().split("\n"): | ||
| in_fence ^= bool(re.match(r"\s*```", line)) | ||
| if in_fence or not re.match(r"#{1,5} ", line): | ||
| out.append(line) | ||
| elif not line.startswith("# "): | ||
| out.append("#" + line) | ||
| return "\n".join(out) | ||
|
|
||
|
|
||
| def write(name: str, sections: list[str]) -> None: | ||
| (ROOT / "references" / name).write_text("\n\n".join(sections) + "\n") | ||
| print(f"wrote references/{name}") | ||
|
|
||
|
|
||
| def main() -> None: | ||
| args = argparse.ArgumentParser(description="Generate the SDK reference Markdown.") | ||
| args.add_argument("--sdk-specs", required=True, help="PostHog/sdk-specs checkout") | ||
| args.add_argument( | ||
| "--major", type=int, help="target major, defaults to the declared one" | ||
| ) | ||
| opts = args.parse_args() | ||
| logging.getLogger("griffe").setLevel(logging.ERROR) | ||
| pkg = griffe.load( | ||
| "posthog", | ||
| search_paths=[str(ROOT)], | ||
| docstring_parser="google", | ||
| resolve_aliases=False, | ||
| ) | ||
| declared = int( | ||
| (ROOT / "posthog/version.py").read_text().split('"')[1].split(".")[0] | ||
| ) | ||
| major = opts.major or declared | ||
| started = nest((ROOT / "docs/getting-started.md").read_text()) | ||
| body = [ | ||
| "## Getting started", | ||
| started, | ||
| *reference(pkg, capabilities(opts.sdk_specs)), | ||
| ] | ||
| title = f"PostHog Python SDK {major}.x" + ( | ||
| "" if declared >= major else " (experimental)" | ||
| ) | ||
| write(f"posthog-python-{major}.md", [front_matter(title, body), *body]) | ||
| if declared >= major: | ||
| write( | ||
| "posthog-python-reference.md", | ||
| [front_matter("PostHog Python SDK", body), *body], | ||
| ) | ||
| if guides := sorted((ROOT / "docs").glob("migration-*.md"), reverse=True): | ||
| head = "---\ntitle: PostHog Python SDK migration guides\nsidebar: Docs\nshowTitle: true\n---" | ||
| write( | ||
| "posthog-python-migration.md", | ||
| [head, *(g.read_text().strip() for g in guides)], | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("<ph_project_token>", host="<ph_client_api_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). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2 Β· Prevent annotations from propagating onto untyped parameters
With signature annotations enabled, griffe2md retains the preceding parameter's annotation for untyped arguments. The committed
Client(...)signatures consequently advertisedebug: str,before_send: float, and other false types, misleading callers and bots about valid inputs. Correct the signature template or disable annotation rendering before regenerating the pages.