Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 268 additions & 0 deletions .github/scripts/check_public_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Generate and check the Python SDK public API snapshot."""

from __future__ import annotations

import argparse
import difflib
import sys
from pathlib import Path
from typing import Iterable

try:
import griffe
except ImportError: # pragma: no cover - exercised only when dependencies are missing.
print("griffe is required; install dev dependencies first.", file=sys.stderr)
raise

ROOT = Path(__file__).resolve().parents[2]
SNAPSHOT_PATH = ROOT / "references" / "public_api_snapshot.txt"
PUBLIC_SPECIAL_NAMES = {"__version__"}
EXCLUDED_MODULE_PREFIXES = ("posthog.test",)

HEADER = """# This file is generated by .github/scripts/check_public_api.py.
# Run `make public_api_snapshot` after an intentional public API change.
# Public API scope: public posthog modules (excluding tests) and their exported
# members. Modules with __all__ use it; other modules include non-underscore
# names. External imports are excluded.
"""


def _path_parts(path: str) -> list[str]:
return path.split(".")


def _is_excluded_path(path: str) -> bool:
return any(
path == prefix or path.startswith(f"{prefix}.")
for prefix in EXCLUDED_MODULE_PREFIXES
)


def _is_public_name(name: str) -> bool:
return not name.startswith("_") or name in PUBLIC_SPECIAL_NAMES


def _is_public_posthog_path(path: str) -> bool:
if not (path == "posthog" or path.startswith("posthog.")):
return False
if _is_excluded_path(path):
return False
return all(_is_public_name(part) for part in _path_parts(path)[1:])


def _object_path(obj: object) -> str:
return str(getattr(obj, "path", ""))


def _alias_target_path(obj: object) -> str:
return str(getattr(obj, "target_path", ""))


def _is_public_alias(obj: object) -> bool:
target_path = _alias_target_path(obj)
return _is_public_posthog_path(target_path)


def _module_exports(module: object) -> set[str] | None:
exports = getattr(module, "exports", None)
if exports is None:
return None
return {str(name) for name in exports}


def _is_module(obj: object) -> bool:
return bool(getattr(obj, "is_module", False))


def _is_class(obj: object) -> bool:
return bool(getattr(obj, "is_class", False))


def _is_function(obj: object) -> bool:
return bool(getattr(obj, "is_function", False))


def _is_attribute(obj: object) -> bool:
return bool(getattr(obj, "is_attribute", False))


def _is_alias(obj: object) -> bool:
return bool(getattr(obj, "is_alias", False))


def _is_public_member(parent: object, name: str, member: object) -> bool:
if name == "__all__":
return False

exports = _module_exports(parent)
if exports is not None:
if name not in exports:
return False
elif not _is_public_name(name):
return False

if _is_alias(member):
return _is_public_alias(member)

if _is_module(member):
return _is_public_posthog_path(_object_path(member))

return True


def _signature(obj: object) -> str | None:
signature = getattr(obj, "signature", None)
if signature is None:
return None

try:
return str(signature())
except Exception: # noqa: BLE001 - keep snapshot generation best-effort.
return None


def _signature_for_path(obj: object) -> str | None:
signature = _signature(obj)
if signature is None:
return None

name = str(getattr(obj, "name", ""))
if name and signature.startswith(name):
return f"{_object_path(obj)}{signature[len(name) :]}"
return f"{_object_path(obj)} {signature}"


def _attribute_details(obj: object) -> str:
parts = [_object_path(obj)]

annotation = getattr(obj, "annotation", None)
if annotation is not None:
parts.append(f": {annotation}")

value = getattr(obj, "value", None)
if value is not None:
parts.append(f" = {value}")

return "".join(parts)


def _record(obj: object) -> str:
if _is_alias(obj):
return f"alias {_object_path(obj)} -> {_alias_target_path(obj)}"

if _is_module(obj):
return f"module {_object_path(obj)}"

if _is_class(obj):
signature = _signature_for_path(obj)
if signature is not None:
return f"class {signature}"
return f"class {_object_path(obj)}"

if _is_function(obj):
signature = _signature_for_path(obj)
prefix = "method" if _is_class(getattr(obj, "parent", None)) else "function"
if signature is not None:
return f"{prefix} {signature}"
return f"{prefix} {_object_path(obj)}"

if _is_attribute(obj):
return f"attribute {_attribute_details(obj)}"

return f"{getattr(obj, 'kind', 'object')} {_object_path(obj)}"


def _iter_class_members(cls: object) -> Iterable[object]:
members = getattr(cls, "members", {})
for name, member in sorted(members.items()):
if not _is_public_member(cls, str(name), member):
continue

yield member
if not _is_alias(member) and _is_class(member):
yield from _iter_class_members(member)


def _iter_module_members(module: object) -> Iterable[object]:
yield module

members = getattr(module, "members", {})
for name, member in sorted(members.items()):
if _is_alias(member):
if _is_public_member(module, str(name), member):
yield member
continue

if _is_module(member):
if _is_public_posthog_path(_object_path(member)):
yield from _iter_module_members(member)
continue

if not _is_public_member(module, str(name), member):
continue

yield member
if _is_class(member):
yield from _iter_class_members(member)


def generate_snapshot() -> str:
package = griffe.load(
"posthog",
allow_inspection=False,
search_paths=[ROOT],
submodules=True,
)
records = sorted(_record(obj) for obj in _iter_module_members(package))
return HEADER + "\n".join(records) + "\n"


def check_snapshot(snapshot: str) -> int:
try:
existing = SNAPSHOT_PATH.read_text()
except FileNotFoundError:
print(
f"Missing public API snapshot: {SNAPSHOT_PATH.relative_to(ROOT)}",
file=sys.stderr,
)
return 1

if existing == snapshot:
print("Public API snapshot is up to date.")
return 0

diff = difflib.unified_diff(
existing.splitlines(keepends=True),
snapshot.splitlines(keepends=True),
fromfile=str(SNAPSHOT_PATH.relative_to(ROOT)),
tofile="generated public API",
)
print(
"Public API snapshot is out of date. Run `make public_api_snapshot`.",
file=sys.stderr,
)
print("".join(diff), file=sys.stderr)
return 1


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--write",
action="store_true",
help="write the generated public API snapshot (default: check)",
)
args = parser.parse_args()
Comment thread
marandaneto marked this conversation as resolved.

snapshot = generate_snapshot()
if args.write:
SNAPSHOT_PATH.write_text(snapshot)
print(f"Wrote {SNAPSHOT_PATH.relative_to(ROOT)}")
return 0

return check_snapshot(snapshot)


if __name__ == "__main__":
raise SystemExit(main())
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ jobs:
run: |
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter

public-api:
name: Public API snapshot
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2

- name: Set up Python dev environment
uses: ./.github/actions/setup-python-dev

- name: Check public API snapshot
run: |
make public_api_check

package-build:
name: Package build
runs-on: ubuntu-latest
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ test:
coverage run -m pytest
coverage report

public_api_snapshot:
python .github/scripts/check_public_api.py --write

public_api_check:
python .github/scripts/check_public_api.py

build_release:
rm -rf dist/*
python setup.py sdist bdist_wheel
Expand Down Expand Up @@ -67,4 +73,4 @@ prep_local:
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"

.PHONY: test lint build_release build_release_analytics e2e_test prep_local
.PHONY: test lint public_api_snapshot public_api_check build_release build_release_analytics e2e_test prep_local
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ otel = [
]
dev = [
"django-stubs",
"griffe",
"lxml",
"mypy",
"mypy-baseline",
Expand Down
Loading
Loading