diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77e42fc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.11', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install with the dev extra + run: python -m pip install -e ".[dev]" + - name: Tests + run: python -m pytest + # Fetches the public OpenAPI spec; no secrets involved. + - name: Codegen check + run: python codegen/check.py + # Build, static checks, install the wheel in a fresh venv, smoke test it. + # The live contract stage needs FORM4API_TEST_KEY and runs locally before a release. + - name: Release check (offline) + run: python scripts/release_check.py --offline diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e0fc979..550b58c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,6 +16,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' - - run: pip install build + # Gate: a failure in any of the next three steps stops the job before the + # publish step runs. + - name: Install with the dev extra + run: python -m pip install -e ".[dev]" + - name: Tests + run: python -m pytest + - name: Release check (offline) + run: python scripts/release_check.py --offline - run: python -m build - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f79181e..68ed822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ # Changelog +## Before publishing + +Run the release check with a real API key and get it green BEFORE `twine upload`: + +``` +py -m pip install -e ".[dev]" # once: build, twine, check-wheel-contents, validator +$env:FORM4API_TEST_KEY = "" # PowerShell; never paste it into a file +py scripts/release_check.py +``` + +It builds the sdist and wheel into a temp directory, runs `twine check --strict` +and `check-wheel-contents`, installs the wheel into a fresh venv, smoke-tests the +installed package from outside the repo, and compares live API responses with +the live OpenAPI spec (`contract/live-calls.json`). It never uploads anything. +`--offline` skips the live stage (CI uses it); `--keep` keeps the temp dirs. +Upload the files from the `dist/` you built with `py -m build`, only after a +passing run. Bump `pyproject.toml` and add the top entry below first: stage 1 +fails if they disagree. + ## Unreleased +- Added `form4api.__version__` (read from the installed package metadata, so it + always equals the pyproject version). + ## 0.9.0 — 2026-10-05 - **Congress date quality (backend insiderapi #330/#331).** `CongressTradeDto` diff --git a/contract/live-calls.json b/contract/live-calls.json new file mode 100644 index 0000000..7930d13 --- /dev/null +++ b/contract/live-calls.json @@ -0,0 +1,11 @@ +[ + { "id": "stats", "method": "GET /v1/stats", "params": {} }, + { "id": "transactions-aapl", "method": "GET /v1/transactions", "params": { "ticker": "AAPL", "limit": 5 } }, + { "id": "filings-aapl", "method": "GET /v1/filings", "params": { "ticker": "AAPL", "limit": 5 } }, + { "id": "company-aapl", "method": "GET /v1/companies/{ticker}", "params": { "ticker": "AAPL" } }, + { "id": "company-insiders-aapl", "method": "GET /v1/companies/{ticker}/insiders", "params": { "ticker": "AAPL" } }, + { "id": "insider-search", "method": "GET /v1/insiders", "params": { "name": "cook" } }, + { "id": "signals", "method": "GET /v1/signals", "params": { "limit": 5 } }, + { "id": "congress-sony", "method": "GET /v1/congress/trades", "params": { "ticker": "SONY" } }, + { "id": "search-apple", "method": "GET /v1/search", "params": { "q": "apple" } } +] diff --git a/form4api/__init__.py b/form4api/__init__.py index 5eeb127..1125751 100644 --- a/form4api/__init__.py +++ b/form4api/__init__.py @@ -1,4 +1,7 @@ from form4api._client import AsyncForm4ApiClient, Form4ApiClient +# Single source of truth is pyproject.toml: _client reads the installed package +# metadata, so this cannot drift from the published version. +from form4api._client import _SDK_VERSION as __version__ from form4api._errors import ( AuthError, Form4ApiError, @@ -24,6 +27,7 @@ from form4api._webhook_utils import verify_webhook __all__ = [ + "__version__", "Form4ApiClient", "AsyncForm4ApiClient", "Form4ApiError", diff --git a/pyproject.toml b/pyproject.toml index 73b4c29..a3e48f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,18 @@ Documentation = "https://www.form4api.com/docs" Repository = "https://github.com/theodor90/form4api-py.git" [project.optional-dependencies] -dev = ["pytest>=8", "pytest-httpx>=0.30", "respx>=0.21"] +# `contract` is what the live contract stage installs into its throwaway venv; +# `dev` is everything scripts/release_check.py and CI need on the host. +contract = ["openapi-schema-validator>=0.6.2"] +dev = [ + "pytest>=8", + "pytest-httpx>=0.30", + "respx>=0.21", + "build>=1.2", + "twine>=5.1", + "check-wheel-contents>=0.6", + "openapi-schema-validator>=0.6.2", +] [tool.setuptools.packages.find] where = ["."] diff --git a/scripts/contract_check.py b/scripts/contract_check.py new file mode 100644 index 0000000..40c6e05 --- /dev/null +++ b/scripts/contract_check.py @@ -0,0 +1,292 @@ +"""Live contract check: does the real API still match what the SDK expects? + +Two halves in one file so the validation logic is shared: + +* `validate_instance()` and friends are plain functions, imported by the pytest + suite (tests/test_contract_validation.py) with no API key and no network. +* `main()` is the live stage of scripts/release_check.py. It is run with the + python of a throwaway venv that has only the built WHEEL installed, so it + exercises the installed package, never the source tree. + +For every entry in contract/live-calls.json it calls the SDK method, captures the +RAW JSON the API sent (before the client rewrites camelCase to snake_case), and +validates that against the operation's 200 response schema in the live OpenAPI +document. It also checks the SDK call itself succeeded and returned the SDK's +model types. + +How the raw JSON is captured: the client keeps one httpx.Client on `_http`, so +a response event hook on it sees every response with no change to the SDK and +no second request (a parallel httpx call would double the quota use and could +disagree with what the SDK actually received). The hook is installed from +outside; the shipped package is untouched. It depends on the private `_http` +attribute, which the stage reports clearly if it ever goes away. + +The API key is read from FORM4API_TEST_KEY and is never printed. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import os +import re +import sys +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_SPEC_URL = "https://api.form4api.com/openapi/v1.json" # same default as codegen/generate.py +KEY_ENV = "FORM4API_TEST_KEY" +HERE = Path(__file__).resolve().parent +DEFAULT_CALLS = HERE.parent / "contract" / "live-calls.json" + +# operation ("VERB /path") -> how the Python SDK exposes it. +# attr dotted attribute path on the client +# positional contract param names that are passed positionally +# rename contract param name -> Python keyword name (the hand-written +# resources take `per_page`, the spec's `limit` is only an alias) +# shape "list" (every element must be `model`) or "one" +# model dotted import path of the expected return type +# An operation missing from this table has no SDK method: that is the parity +# failure the live stage reports. +# GET /v1/keys/usage is deliberately absent from live-calls.json and from this +# table: codegen lists GetKeyUsage in SKIP_OPERATIONS (no SDK method by design) +# and the spec declares no 200 schema for it, so there is nothing to call or +# validate. Do not add a usage method just to make a contract entry pass. +BINDINGS: dict[str, dict[str, Any]] = { + "GET /v1/stats": {"attr": "stats.get", "shape": "one", "model": "form4api._generated.CorpusStats"}, + "GET /v1/transactions": { + "attr": "transactions.list", "rename": {"limit": "per_page"}, + "shape": "list", "model": "form4api.Transaction", + }, + "GET /v1/filings": {"attr": "filings.list", "shape": "list", "model": "form4api._generated.FilingResponse"}, + "GET /v1/companies/{ticker}": { + "attr": "companies.get", "positional": ["ticker"], "shape": "one", "model": "form4api.Company", + }, + "GET /v1/companies/{ticker}/insiders": { + "attr": "companies.insiders", "positional": ["ticker"], "shape": "list", "model": "form4api.Insider", + }, + "GET /v1/insiders": { + "attr": "insiders.search", "positional": ["name"], "shape": "list", "model": "form4api.Insider", + }, + "GET /v1/signals": { + "attr": "signals.list", "rename": {"limit": "per_page"}, "shape": "list", "model": "form4api.InsiderSignal", + }, + "GET /v1/congress/trades": { + "attr": "congress.trades", "shape": "list", "model": "form4api._generated.CongressTradeDto", + }, + "GET /v1/search": {"attr": "search", "positional": ["q"], "shape": "one", "model": "form4api.SearchResults"}, +} + + +# --------------------------------------------------------------------------- +# Pure validation (no network, no key). Shared with the pytest suite. +# --------------------------------------------------------------------------- + +def response_schema(spec: dict, path: str, verb: str, status: str = "200") -> dict | None: + """The JSON schema of `status` for an operation, or None when it declares none.""" + op = spec["paths"][path][verb.lower()] + resp = op["responses"][status] + return ((resp.get("content") or {}).get("application/json") or {}).get("schema") + + +def validate_instance(spec: dict, schema: dict, instance: Any, max_errors: int = 8) -> list[str]: + """Validate `instance` against an OpenAPI schema; return human-readable errors. + + Strict about types, required fields, enums and OpenAPI 3.0 `nullable`; + additional properties are allowed (the backend adding a field is not drift + the SDK needs to fail on). `$ref`s resolve against the spec's components. + """ + from openapi_schema_validator import ( + OAS30Validator, + OAS31Validator, + oas30_format_checker, + oas31_format_checker, + ) + + openapi = str(spec.get("openapi", "3.0")) + is31 = openapi.startswith("3.1") + validator_cls = OAS31Validator if is31 else OAS30Validator + # Formats are checked too (date-time must carry an RFC 3339 offset), matching the + # JS gate's ajv-formats. Without this, the 2026-10-05 live run passed a + # timezone-less "quarter" here while the JS gate failed it. + format_checker = oas31_format_checker if is31 else oas30_format_checker + # Embed components in the root so "#/components/schemas/X" resolves. + root = dict(schema) + root["components"] = spec.get("components", {}) + + grouped: dict[tuple[str, str], int] = {} + for err in validator_cls(root, format_checker=format_checker).iter_errors(instance): + where = "/".join("[]" if isinstance(p, int) else str(p) for p in err.absolute_path) or "(root)" + msg = err.message if len(err.message) <= 160 else err.message[:157] + "..." + grouped[(where, msg)] = grouped.get((where, msg), 0) + 1 + + out = [f"{w}: {m}" + (f" (x{n})" if n > 1 else "") for (w, m), n in grouped.items()] + if len(out) > max_errors: + out = out[:max_errors] + [f"... and {len(out) - max_errors} more distinct errors"] + return out + + +def parse_method(method: str) -> tuple[str, str]: + verb, _, path = method.strip().partition(" ") + if not verb or not path.startswith("/"): + raise ValueError(f'bad method {method!r}; expected "GET /v1/path"') + return verb.upper(), path + + +def load_calls(path: Path) -> list[dict]: + calls = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(calls, list): + raise ValueError("live-calls.json must be a JSON array") + seen: set[str] = set() + for c in calls: + if set(c) != {"id", "method", "params"}: + raise ValueError(f"entry {c.get('id', c)!r} must have exactly id, method, params") + if c["id"] in seen: + raise ValueError(f"duplicate id {c['id']!r}") + seen.add(c["id"]) + parse_method(c["method"]) + return calls + + +# --------------------------------------------------------------------------- +# Live stage (runs inside the temp venv). +# --------------------------------------------------------------------------- + +def _resolve(dotted: str) -> Any: + mod, _, name = dotted.rpartition(".") + return getattr(importlib.import_module(mod), name) + + +def _fetch_spec(source: str) -> dict: + if source.startswith("http"): + req = urllib.request.Request(source, headers={"User-Agent": "form4api-py-release-check"}) + with urllib.request.urlopen(req, timeout=60) as fh: + return json.load(fh) + return json.loads(Path(source).read_text(encoding="utf-8")) + + +def _run_call(client: Any, spec: dict, entry: dict, captured: list) -> str: + """Run one entry. Returns a one-line PASS detail; raises AssertionError on failure.""" + verb, path = parse_method(entry["method"]) + key = f"{verb} {path}" + + if path not in spec["paths"] or verb.lower() not in spec["paths"][path]: + raise AssertionError(f"operation {key} is not in the live OpenAPI spec (the API removed or renamed it)") + binding = BINDINGS.get(key) + if binding is None: + raise AssertionError( + f"PARITY: {key} has no SDK method in the installed form4api package " + f"(see BINDINGS in scripts/contract_check.py)" + ) + + obj: Any = client + for part in binding["attr"].split("."): + obj = getattr(obj, part, None) + if obj is None: + raise AssertionError(f"PARITY: SDK method `{binding['attr']}` for {key} does not exist") + + params = dict(entry["params"]) + positional = binding.get("positional", []) + rename = binding.get("rename", {}) + args = [params.pop(name) for name in positional] + kwargs = {rename.get(k, k): v for k, v in params.items()} + + del captured[:] + try: + result = obj(*args, **kwargs) + except Exception as exc: # noqa: BLE001 - report any SDK failure as a FAIL with its type + raise AssertionError(f"SDK call {binding['attr']}() raised {type(exc).__name__}: {exc}") from None + + model = _resolve(binding["model"]) + items = result if binding["shape"] == "list" else [result] + if binding["shape"] == "list" and not isinstance(result, list): + raise AssertionError(f"{binding['attr']}() returned {type(result).__name__}, expected list") + bad = [type(i).__name__ for i in items if not isinstance(i, model)] + if bad: + raise AssertionError(f"{binding['attr']}() returned {bad[0]} items, expected {model.__name__}") + + ok = [r for r in captured if r.status_code == 200] + if not ok: + raise AssertionError("no 200 response was captured for this call") + raw = ok[-1].json() + + schema = response_schema(spec, path, verb) + notes = [] + if schema is None: + notes.append("spec declares no 200 schema, so the response shape is unvalidated") + else: + errors = validate_instance(spec, schema, raw) + if errors: + raise AssertionError("response does not match the 200 schema:\n " + "\n ".join(errors)) + if isinstance(raw, list) and not raw and entry["id"] != "congress-sony": + notes.append("empty array, nothing validated (WARN)") + + if entry["id"] == "congress-sony": + flagged = [r for r in raw if r.get("disclosureLagDays") is None and r.get("dateQuality") is not None] + if not flagged: + raise AssertionError( + "regression: expected at least one row with disclosureLagDays null and dateQuality " + f"non-null among {len(raw)} rows, found none" + ) + parsed = [m for m in items if m.disclosure_lag_days is None and m.date_quality is not None] + if not parsed: + raise AssertionError("SDK model dropped the null lag / date_quality of a flagged row") + notes.append(f"{len(flagged)} flagged row(s) with null lag") + + count = len(raw) if isinstance(raw, list) else 1 + return f"{count} item(s) validated" + ("; " + "; ".join(notes) if notes else "") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--spec", default=os.environ.get("FORM4API_OPENAPI_URL", DEFAULT_SPEC_URL)) + ap.add_argument("--calls", default=str(DEFAULT_CALLS)) + ap.add_argument("--forbid-root", help="fail if form4api is imported from under this directory") + args = ap.parse_args(argv) + + key = os.environ.get(KEY_ENV) + if not key: + print(f"{KEY_ENV} is not set.") + return 2 + + import form4api + + if args.forbid_root: + loc = Path(form4api.__file__).resolve() + root = Path(args.forbid_root).resolve() + if root in loc.parents: + print(f"form4api was imported from the source tree ({loc}), not the installed wheel.") + return 2 + print(f" form4api {form4api.__version__} from {Path(form4api.__file__).resolve().parent}") + + spec = _fetch_spec(args.spec) + print(f" spec: OpenAPI {spec.get('openapi')}, {len(spec['paths'])} paths") + calls = load_calls(Path(args.calls)) + + captured: list = [] + client = form4api.Form4ApiClient(key, max_retries=1, timeout=30.0) + if not hasattr(client, "_http"): + print("The client no longer exposes `_http`; update the capture hook in scripts/contract_check.py.") + return 2 + client._http.event_hooks = {"request": [], "response": [lambda r: (r.read(), captured.append(r))]} + + failures = 0 + try: + for entry in calls: + try: + detail = _run_call(client, spec, entry, captured) + print(f" PASS {entry['id']:<24} {detail}") + except AssertionError as exc: + failures += 1 + print(f" FAIL {entry['id']:<24} {exc}") + finally: + client.close() + + print(f" {len(calls) - failures}/{len(calls)} live calls passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_check.py b/scripts/release_check.py new file mode 100644 index 0000000..f4146de --- /dev/null +++ b/scripts/release_check.py @@ -0,0 +1,384 @@ +"""Pre-publish gate for the form4api Python SDK. + + py scripts/release_check.py [--offline] [--keep] (Windows) + python scripts/release_check.py [--offline] [--keep] (CI) + +Stages, stopping at the first failure: + + 1. Version sync pyproject == __version__ == top CHANGELOG entry + 2. Build sdist + wheel into a fresh temp directory + 3. Static checks twine check --strict, check-wheel-contents, and the wheel + must contain every module under form4api/ and nothing else + 4. Install fresh venv, pip install the WHEEL (not the repo, not -e) + 5. Smoke test import everything from the installed wheel, cwd outside the repo + 6. Live contract real API responses vs the live OpenAPI spec, using the + INSTALLED package. Needs FORM4API_TEST_KEY. + 7. Not published this version must not already be on PyPI (FAIL; only a + WARN under --offline, so CI can still run on a released version) + +--offline skip stage 6 (CI uses this; there is no key there). Also downgrades stage 7 to a warning. +--keep keep the temp directories (they are removed by default). + +The dev tools come from the dev extra: pip install -e ".[dev]" + +This script never uploads anything and never prints the API key. +""" + +from __future__ import annotations + +import argparse +import ast +import os +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCRIPTS = ROOT / "scripts" +PACKAGE = "form4api" +KEY_ENV = "FORM4API_TEST_KEY" +# Directories that must never leak into the build copy: stale artefacts are the +# whole reason the build happens in a temp tree. +COPY_IGNORE = shutil.ignore_patterns( + ".git", ".venv", "venv", "dist", "build", "*.egg-info", "__pycache__", ".pytest_cache", ".github", "node_modules" +) + + +class StageFailed(Exception): + pass + + +class StageWarn(Exception): + """The stage found a problem that only blocks a real release (not --offline).""" + + +class Ctx: + def __init__(self, keep: bool) -> None: + self.keep = keep + self.tmp = Path(tempfile.mkdtemp(prefix="form4api-release-check-")).resolve() + self.version = "" + self.sdist: Path | None = None + self.wheel: Path | None = None + self.venv_py: Path | None = None + + def cleanup(self) -> None: + if self.keep: + print(f"\nTemp directory kept: {self.tmp}") + return + + def _force(func, path, _exc): # Windows: read-only files inside venvs + os.chmod(path, 0o700) + func(path) + + shutil.rmtree(self.tmp, onerror=_force) + + +def run(cmd: list[str], *, cwd: Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess: + proc = subprocess.run( + [str(c) for c in cmd], cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace" + ) + return proc + + +def redact(text: str) -> str: + key = os.environ.get(KEY_ENV) + return text.replace(key, "***") if key else text + + +def tail(text: str, n: int = 40) -> str: + lines = redact(text).strip().splitlines() + return "\n".join(lines[-n:]) + + +def need(proc: subprocess.CompletedProcess, what: str) -> str: + """Raise StageFailed with the real output unless the process succeeded.""" + if proc.returncode != 0: + raise StageFailed(f"{what} exited {proc.returncode}\n{tail(proc.stdout)}\n{tail(proc.stderr)}".rstrip()) + return proc.stdout + + +def pyproject_version() -> str: + return tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + + +# --------------------------------------------------------------------------- +# Stage 1 +# --------------------------------------------------------------------------- + +def stage_version(ctx: Ctx) -> list[str]: + out: list[str] = [] + version = pyproject_version() + ctx.version = version + out.append(f"pyproject version: {version}") + + # __version__: find where it is defined. + tree = ast.parse((ROOT / PACKAGE / "__init__.py").read_text(encoding="utf-8-sig")) + source_version = None + defined = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets): + defined = True + if isinstance(node.value, ast.Constant): + source_version = node.value.value + if isinstance(node, ast.ImportFrom) and any((a.asname or a.name) == "__version__" for a in node.names): + defined = True + if not defined: + raise StageFailed("form4api/__init__.py does not define __version__") + if source_version is not None: + if source_version != version: + raise StageFailed(f"form4api.__version__ is {source_version!r} but pyproject says {version!r}") + out.append(f"__version__ literal: {source_version}") + else: + out.append("__version__ is derived from installed package metadata (pyproject is the single source); stage 5 checks it on the installed wheel") + + # Top CHANGELOG entry: "## 0.9.0 — 2026-10-05"; "## Unreleased" is skipped. + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8-sig") + m = re.search(r"^##\s+\[?v?(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)\]?", changelog, re.MULTILINE) + if not m: + raise StageFailed("CHANGELOG.md has no version heading like '## 0.9.0 — date'") + if m.group(1) != version: + raise StageFailed(f"top CHANGELOG.md entry is {m.group(1)!r} but pyproject says {version!r}") + out.append(f"CHANGELOG top entry: {m.group(1)}") + + return out + + +# --------------------------------------------------------------------------- +# Stage 2 +# --------------------------------------------------------------------------- + +def stage_build(ctx: Ctx) -> list[str]: + if run([sys.executable, "-c", "import build"]).returncode != 0: + raise StageFailed("the `build` package is missing. Install the dev extra: pip install -e \".[dev]\"") + # Build from a copy of the tree minus dist/, build/ and egg-info, so a stale + # artefact (or stale build/lib) can never be what gets tested. + src = ctx.tmp / "src" + shutil.copytree(ROOT, src, ignore=COPY_IGNORE) + dist = ctx.tmp / "dist" + need(run([sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", dist, src]), "python -m build") + wheels = sorted(dist.glob("*.whl")) + sdists = sorted(dist.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise StageFailed(f"expected exactly one wheel and one sdist in {dist}, found {[p.name for p in dist.iterdir()]}") + ctx.wheel, ctx.sdist = wheels[0], sdists[0] + for art in (ctx.wheel, ctx.sdist): + if f"-{ctx.version}" not in art.name: + raise StageFailed(f"{art.name} does not carry version {ctx.version}") + return [f"built {ctx.sdist.name}", f"built {ctx.wheel.name}", f"output dir: {dist}"] + + +# --------------------------------------------------------------------------- +# Stage 3 +# --------------------------------------------------------------------------- + +def stage_static(ctx: Ctx) -> list[str]: + assert ctx.wheel and ctx.sdist + out: list[str] = [] + for mod, hint in (("twine", "twine"), ("check_wheel_contents", "check-wheel-contents")): + if run([sys.executable, "-c", f"import {mod}"]).returncode != 0: + raise StageFailed(f"`{hint}` is missing. Install the dev extra: pip install -e \".[dev]\"") + + need(run([sys.executable, "-m", "twine", "check", "--strict", ctx.sdist, ctx.wheel]), "twine check --strict") + out.append("twine check --strict: both files OK") + + proc = run([sys.executable, "-m", "check_wheel_contents", ctx.wheel]) + need(proc, "check-wheel-contents") + out.append("check-wheel-contents: OK") + + # Source modules <-> wheel contents. + expected = { + p.relative_to(ROOT).as_posix() + for p in (ROOT / PACKAGE).rglob("*") + if p.is_file() and "__pycache__" not in p.parts and p.suffix not in {".pyc", ".pyo"} + } + with zipfile.ZipFile(ctx.wheel) as zf: + names = {n for n in zf.namelist() if not n.endswith("/")} + in_pkg = {n for n in names if n.startswith(PACKAGE + "/")} + missing = sorted(expected - in_pkg) + if missing: + raise StageFailed("wheel is missing files present under form4api/ in the source tree:\n " + "\n ".join(missing)) + extra = sorted(in_pkg - expected) + if extra: + raise StageFailed("wheel contains files under form4api/ that are not in the source tree:\n " + "\n ".join(extra)) + + allowed_top = {PACKAGE, f"{PACKAGE}-{ctx.version}.dist-info"} + top = {n.split("/")[0] for n in names} + unexpected = sorted(top - allowed_top) + if unexpected: + raise StageFailed(f"unexpected top-level entries in the wheel: {unexpected} (tests/codegen/etc. must not ship)") + needed_meta = {f"{PACKAGE}-{ctx.version}.dist-info/{f}" for f in ("METADATA", "WHEEL", "RECORD")} + if needed_meta - names: + raise StageFailed(f"wheel is missing dist-info files: {sorted(needed_meta - names)}") + out.append(f"wheel has all {len(expected)} files from form4api/ and only {sorted(top)}") + return out + + +# --------------------------------------------------------------------------- +# Stage 4 +# --------------------------------------------------------------------------- + +def stage_install(ctx: Ctx) -> list[str]: + assert ctx.wheel + venv_dir = ctx.tmp / "venv" + need(run([sys.executable, "-m", "venv", venv_dir]), "python -m venv") + ctx.venv_py = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + need( + run([ctx.venv_py, "-m", "pip", "install", "--disable-pip-version-check", "--no-input", ctx.wheel]), + "pip install ", + ) + listing = need(run([ctx.venv_py, "-m", "pip", "list", "--format=freeze", "--disable-pip-version-check"]), "pip list") + pkgs = ", ".join(sorted(line.strip() for line in listing.splitlines() if not line.startswith(("pip==", "setuptools==")))) + return [f"installed {ctx.wheel.name} into {venv_dir}", f"venv packages: {pkgs}"] + + +# --------------------------------------------------------------------------- +# Stage 5 +# --------------------------------------------------------------------------- + +def _isolated_cwd(ctx: Ctx) -> Path: + cwd = ctx.tmp / "elsewhere" + cwd.mkdir(exist_ok=True) + return cwd + + +def stage_smoke(ctx: Ctx) -> list[str]: + assert ctx.venv_py + proc = run( + [ctx.venv_py, "-I", SCRIPTS / "smoke_installed.py", "--expected-version", ctx.version, "--forbid-root", ROOT], + cwd=_isolated_cwd(ctx), + ) + if proc.returncode != 0: + raise StageFailed(f"smoke test exited {proc.returncode}\n{tail(proc.stdout)}\n{tail(proc.stderr)}".rstrip()) + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + + +# --------------------------------------------------------------------------- +# Stage 6 +# --------------------------------------------------------------------------- + +def stage_contract(ctx: Ctx) -> list[str]: + assert ctx.venv_py and ctx.wheel + if not os.environ.get(KEY_ENV): + raise StageFailed( + f"{KEY_ENV} is not set. The live contract stage needs an API key (a Business-plan key, " + "because /v1/signals is plan-gated). Set it and re-run, or pass --offline to skip this stage." + ) + print(f" {KEY_ENV} is set (value not shown)") + # The wheel plus its `contract` extra (the OpenAPI validator), in the same temp venv. + need( + run([ctx.venv_py, "-m", "pip", "install", "--disable-pip-version-check", "--no-input", f"{ctx.wheel}[contract]"]), + "pip install [contract]", + ) + proc = run( + [ctx.venv_py, "-I", SCRIPTS / "contract_check.py", "--forbid-root", ROOT], + cwd=_isolated_cwd(ctx), + env=os.environ.copy(), + ) + lines = [line.rstrip() for line in redact(proc.stdout).splitlines() if line.strip()] + if proc.returncode != 0: + raise StageFailed("live contract check failed\n" + "\n".join(lines) + ("\n" + tail(proc.stderr) if proc.stderr.strip() else "")) + return [line.strip() for line in lines] + + +# --------------------------------------------------------------------------- +# Stage 7 +# --------------------------------------------------------------------------- + +def stage_not_published(ctx: Ctx, offline: bool = False) -> list[str]: + """PyPI refuses to re-upload a version, so catch it before twine does.""" + url = f"https://pypi.org/pypi/{PACKAGE}/{ctx.version}/json" + req = urllib.request.Request(url, headers={"User-Agent": "form4api-py-release-check"}) + try: + with urllib.request.urlopen(req, timeout=20): + published = True + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise StageFailed(f"PyPI answered HTTP {exc.code} for {url}; cannot confirm {ctx.version} is unpublished") from None + published = False + except OSError as exc: + if offline: + raise StageWarn(f"could not reach PyPI ({exc}); did not verify that {ctx.version} is unpublished") from None + raise StageFailed(f"could not reach PyPI to verify {ctx.version} is unpublished: {exc}") from None + if published: + msg = f"{PACKAGE} {ctx.version} is already published on PyPI; PyPI will reject a re-upload. Bump the version." + if offline: + raise StageWarn(msg) + raise StageFailed(msg) + return [f"{PACKAGE} {ctx.version} is not on PyPI yet"] + + +# --------------------------------------------------------------------------- + +STAGES = [ + ("Version sync", stage_version), + ("Build", stage_build), + ("Static checks", stage_static), + ("Install in isolation", stage_install), + ("Smoke test installed package", stage_smoke), + ("Live contract", stage_contract), + ("Version not yet published", stage_not_published), +] + + +def main() -> int: + ap = argparse.ArgumentParser(description="Pre-publish gate for the form4api Python SDK.") + ap.add_argument("--offline", action="store_true", help="skip the live contract stage (no API key needed)") + ap.add_argument("--keep", action="store_true", help="keep the temp directories") + args = ap.parse_args() + + ctx = Ctx(keep=args.keep) + results: list[tuple[str, str]] = [] + failed = False + try: + for i, (name, fn) in enumerate(STAGES, start=1): + label = f"[{i}/{len(STAGES)}] {name}" + if failed: + results.append((label, "NOT RUN")) + continue + if name == "Live contract" and args.offline: + print(f"{label}: SKIP (--offline)") + results.append((label, "SKIP")) + continue + print(f"{label} ...") + try: + details = fn(ctx, args.offline) if fn is stage_not_published else fn(ctx) + except StageWarn as exc: + print(f"{label}: WARN (--offline)") + print(f" {exc}") + results.append((label, "WARN")) + continue + except StageFailed as exc: + print(f"{label}: FAIL") + for line in str(exc).splitlines(): + print(f" {line}") + results.append((label, "FAIL")) + failed = True + continue + except Exception as exc: # noqa: BLE001 - a crashing stage is a failing stage + print(f"{label}: FAIL (unexpected {type(exc).__name__}: {redact(str(exc))})") + results.append((label, "FAIL")) + failed = True + continue + for line in details: + print(f" {line}") + print(f"{label}: PASS") + results.append((label, "PASS")) + finally: + print("\nSummary") + for label, status in results: + print(f" {status:<8} {label}") + verdict = "FAILED" if failed else ("PASSED (live contract stage skipped)" if args.offline else "PASSED") + print(f"\nrelease_check {verdict}") + ctx.cleanup() + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_installed.py b/scripts/smoke_installed.py new file mode 100644 index 0000000..816b4ee --- /dev/null +++ b/scripts/smoke_installed.py @@ -0,0 +1,90 @@ +"""Smoke test for the INSTALLED form4api wheel. + +Run by scripts/release_check.py with the temp venv's python, in isolated mode +(-I) and with a working directory outside the repo, so the source tree cannot +be imported by accident. Needs no network and no API key. +""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import pkgutil +import sys +import typing +from pathlib import Path + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--expected-version", required=True) + ap.add_argument("--forbid-root", required=True, help="the repo root; importing from under it is a failure") + args = ap.parse_args() + + import form4api + + loc = Path(form4api.__file__).resolve() + assert Path(args.forbid_root).resolve() not in loc.parents, f"form4api imported from the source tree: {loc}" + print(f" form4api imported from {loc.parent}") + + # 1. every module imports (public and private: a missing dependency or a + # syntax error in any of them breaks users on first use). + failures: list[str] = [] + names = [m.name for m in pkgutil.walk_packages(form4api.__path__, "form4api.", onerror=lambda n: failures.append(n))] + assert not failures, f"packages that failed to walk: {failures}" + for name in names: + importlib.import_module(name) + print(f" imported {len(names)} submodules: {', '.join(n.removeprefix('form4api.') for n in names)}") + for export in form4api.__all__: + assert hasattr(form4api, export), f"__all__ lists {export!r} but it does not resolve" + print(f" all {len(form4api.__all__)} names in __all__ resolve") + + # 2. version + assert form4api.__version__ == args.expected_version, ( + f"form4api.__version__ is {form4api.__version__!r}, pyproject says {args.expected_version!r}" + ) + from importlib.metadata import version + + assert version("form4api") == args.expected_version, "installed distribution metadata disagrees" + print(f" __version__ == {form4api.__version__}") + + # 3. construct both clients + client = form4api.Form4ApiClient("smoke-test-key") + ua = client._http.headers["User-Agent"] + assert ua == f"form4api-py/{args.expected_version}", f"unexpected User-Agent {ua!r}" + aclient = form4api.AsyncForm4ApiClient("smoke-test-key") + asyncio.run(aclient.close()) + print(f" clients construct; User-Agent {ua}") + + # 4. the congress lag field accepts None, both on the type and end to end. + from form4api._generated import CongressTradeDto + + hints = typing.get_type_hints(CongressTradeDto) + for field in ("disclosure_lag_days", "date_quality"): + assert type(None) in typing.get_args(hints[field]), f"CongressTradeDto.{field} does not accept None: {hints[field]}" + + import httpx + + row = { + "politician": {"bioguideId": None, "slug": "x", "fullName": "X", "party": None, "chamber": "House", "state": "CA"}, + "ticker": "SONY", "assetName": "Sony", "assetType": "Stock", "ownerType": "Self", + "transactionType": "Purchase", "amountLow": 1001, "amountHigh": 15000, + "transactionDate": "2026-03-01T00:00:00Z", "disclosureDate": "2026-02-01T00:00:00Z", + "disclosureLagDays": None, "dateQuality": "transaction_after_disclosure", + } + client._http = httpx.Client(transport=httpx.MockTransport(lambda req: httpx.Response(200, json=[row]))) + trades = client.congress.trades(ticker="SONY") + assert isinstance(trades[0], CongressTradeDto) + assert trades[0].disclosure_lag_days is None and trades[0].date_quality == "transaction_after_disclosure" + client.close() + print(" congress lag accepts None (type hint and a mocked response through the client)") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except AssertionError as exc: + print(f" ASSERTION FAILED: {exc}") + sys.exit(1) diff --git a/tests/fixtures/congress_spec.json b/tests/fixtures/congress_spec.json new file mode 100644 index 0000000..339bbb5 --- /dev/null +++ b/tests/fixtures/congress_spec.json @@ -0,0 +1,154 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "trimmed live spec (congress trades only)", + "version": "v1" + }, + "paths": { + "/v1/congress/trades": { + "get": { + "operationId": "ListCongressTrades", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CongressTradeDto" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CongressTradeDto": { + "required": [ + "politician", + "ticker", + "assetName", + "assetType", + "ownerType", + "transactionType", + "amountLow", + "amountHigh", + "transactionDate", + "disclosureDate", + "disclosureLagDays", + "dateQuality" + ], + "type": "object", + "properties": { + "politician": { + "$ref": "#/components/schemas/CongressPoliticianRefDto" + }, + "ticker": { + "type": "string", + "nullable": true + }, + "assetName": { + "type": "string" + }, + "assetType": { + "enum": [ + "Stock", + "Bond", + "Option", + "Fund", + "Crypto", + "Other", + "Unknown" + ], + "type": "string" + }, + "ownerType": { + "enum": [ + "Self", + "Spouse", + "Joint", + "DependentChild", + "Unknown" + ], + "type": "string" + }, + "transactionType": { + "enum": [ + "Purchase", + "Sale", + "PartialSale", + "Exchange", + "Unknown" + ], + "type": "string" + }, + "amountLow": { + "type": "number", + "format": "double", + "nullable": true + }, + "amountHigh": { + "type": "number", + "format": "double", + "nullable": true + }, + "transactionDate": { + "type": "string", + "format": "date-time" + }, + "disclosureDate": { + "type": "string", + "format": "date-time" + }, + "disclosureLagDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "dateQuality": { + "type": "string", + "nullable": true + } + } + }, + "CongressPoliticianRefDto": { + "required": [ + "bioguideId", + "slug", + "fullName", + "party", + "chamber", + "state" + ], + "type": "object", + "properties": { + "bioguideId": { + "type": "string", + "nullable": true + }, + "slug": { + "type": "string" + }, + "fullName": { + "type": "string" + }, + "party": { + "type": "string", + "nullable": true + }, + "chamber": { + "type": "string" + }, + "state": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/congress_trade_null_lag.json b/tests/fixtures/congress_trade_null_lag.json new file mode 100644 index 0000000..2d9befe --- /dev/null +++ b/tests/fixtures/congress_trade_null_lag.json @@ -0,0 +1,21 @@ +{ + "politician": { + "bioguideId": "X000001", + "slug": "example-member", + "fullName": "Example Member", + "party": "D", + "chamber": "House", + "state": "CA" + }, + "ticker": "SONY", + "assetName": "Sony Group Corp", + "assetType": "Stock", + "ownerType": "Self", + "transactionType": "Purchase", + "amountLow": 1001, + "amountHigh": 15000, + "transactionDate": "2026-03-01T00:00:00Z", + "disclosureDate": "2026-02-01T00:00:00Z", + "disclosureLagDays": null, + "dateQuality": "transaction_after_disclosure" +} diff --git a/tests/test_contract_validation.py b/tests/test_contract_validation.py new file mode 100644 index 0000000..62784a1 --- /dev/null +++ b/tests/test_contract_validation.py @@ -0,0 +1,122 @@ +"""Offline tests for the live-contract validator (scripts/contract_check.py). + +The live stage of scripts/release_check.py compares real API responses with the +OpenAPI spec. That needs a key, so CI cannot run it, but the validator itself +can be proven here with a saved row and a trimmed copy of the spec. The case +that motivated it: `disclosureLagDays` became nullable on the API, and nothing +in the mocked suite would have noticed had it not. +""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +from form4api import Form4ApiClient +from form4api._generated import CongressTradeDto + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +_spec = importlib.util.spec_from_file_location("contract_check", ROOT / "scripts" / "contract_check.py") +contract_check = importlib.util.module_from_spec(_spec) +sys.modules["contract_check"] = contract_check +_spec.loader.exec_module(contract_check) # type: ignore[union-attr] + +pytest.importorskip("openapi_schema_validator", reason="install the dev extra: pip install -e '.[dev]'") + +PATH, VERB = "/v1/congress/trades", "GET" + + +@pytest.fixture +def spec() -> dict: + return json.loads((FIXTURES / "congress_spec.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def null_lag_row() -> dict: + row = json.loads((FIXTURES / "congress_trade_null_lag.json").read_text(encoding="utf-8")) + assert row["disclosureLagDays"] is None and row["dateQuality"] is not None # the point of the fixture + return row + + +def _errors(spec: dict, payload: object) -> list[str]: + schema = contract_check.response_schema(spec, PATH, VERB) + assert schema is not None + return contract_check.validate_instance(spec, schema, payload) + + +def test_null_lag_passes_against_the_current_nullable_spec(spec: dict, null_lag_row: dict) -> None: + assert _errors(spec, [null_lag_row]) == [] + + +def test_null_lag_fails_when_the_spec_says_not_nullable(spec: dict, null_lag_row: dict) -> None: + """The regression case: a spec that does not allow null must reject a null lag.""" + stale = copy.deepcopy(spec) + lag = stale["components"]["schemas"]["CongressTradeDto"]["properties"]["disclosureLagDays"] + assert lag.pop("nullable") is True # the live spec really does declare it nullable + + errors = _errors(stale, [null_lag_row]) + assert errors, "a null lag against a non-nullable field must fail validation" + assert any("disclosureLagDays" in e and "integer" in e for e in errors), errors + + +def test_wrong_type_fails(spec: dict, null_lag_row: dict) -> None: + null_lag_row["disclosureLagDays"] = "12" + assert any("disclosureLagDays" in e for e in _errors(spec, [null_lag_row])) + + +def test_missing_required_field_fails(spec: dict, null_lag_row: dict) -> None: + del null_lag_row["dateQuality"] + assert any("dateQuality" in e for e in _errors(spec, [null_lag_row])) + + +def test_nested_ref_is_resolved_and_checked(spec: dict, null_lag_row: dict) -> None: + null_lag_row["politician"]["state"] = None # `state` is required and not nullable + assert any("politician/state" in e for e in _errors(spec, [null_lag_row])) + + +def test_date_time_without_offset_fails() -> None: + """Formats are enforced, matching the JS gate. The live API returned + "2026-06-30T00:00:00" (no offset) for a date-time field on 2026-10-05; RFC 3339 + requires an offset, and JS parses an offset-less value as local time.""" + tiny = {"openapi": "3.0.1", "components": {}} + schema = {"type": "object", "properties": {"quarter": {"type": "string", "format": "date-time"}}} + assert contract_check.validate_instance(tiny, schema, {"quarter": "2026-06-30T00:00:00Z"}) == [] + errors = contract_check.validate_instance(tiny, schema, {"quarter": "2026-06-30T00:00:00"}) + assert any("quarter" in e and "date-time" in e for e in errors), errors + + +def test_additional_properties_are_allowed(spec: dict, null_lag_row: dict) -> None: + null_lag_row["somethingTheBackendAddedLater"] = {"any": "shape"} + assert _errors(spec, [null_lag_row]) == [] + + +def test_sdk_model_keeps_the_null_lag(null_lag_row: dict) -> None: + """Same row through the SDK's own parsing path: None must survive.""" + from form4api._client import _normalise + + trade = CongressTradeDto._from_dict(_normalise(null_lag_row)) + assert trade.disclosure_lag_days is None + assert trade.date_quality == "transaction_after_disclosure" + + +def test_live_calls_file_is_well_formed_and_bound_methods_exist() -> None: + calls = contract_check.load_calls(ROOT / "contract" / "live-calls.json") + assert len(calls) == 9 + client = Form4ApiClient("k") + for call in calls: + verb, path = contract_check.parse_method(call["method"]) + binding = contract_check.BINDINGS.get(f"{verb} {path}") + if binding is None: + continue # reported as a parity gap by the live stage + obj = client + for part in binding["attr"].split("."): + obj = getattr(obj, part) + assert callable(obj) + assert contract_check._resolve(binding["model"]) is not None