From fa90ae91c0bcd297f0ddb9197d44624c41757f7e Mon Sep 17 00:00:00 2001 From: AJ Frio Date: Fri, 14 Aug 2026 12:05:07 -0600 Subject: [PATCH] Add an agent-first engineering harness so future SDK changes stay mechanically enforced. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Cursor --- .editorconfig | 15 + .env.example | 1 + .github/workflows/ci.yml | 25 +- .gitignore | 9 +- .pre-commit-config.yaml | 13 + AGENTS.md | 68 ++++ ARCHITECTURE.md | 78 +++++ Makefile | 22 ++ README.md | 24 +- bild/client.py | 140 +++++--- docs/CONVENTIONS.md | 46 +++ docs/DESIGN.md | 43 +++ docs/INDEX.md | 28 ++ docs/PRODUCT.md | 42 +++ docs/QUALITY_SCORE.md | 27 ++ docs/RELIABILITY.md | 37 +++ docs/SECURITY.md | 31 ++ docs/design-docs/auth.md | 22 ++ docs/design-docs/core-beliefs.md | 13 + docs/design-docs/http-client.md | 40 +++ docs/design-docs/index.md | 10 + docs/exec-plans/active/README.md | 10 + .../2026-08-14-engineering-harness.md | 23 ++ docs/exec-plans/completed/README.md | 4 + docs/exec-plans/tech-debt-tracker.md | 12 + docs/product-specs/index.md | 5 + docs/product-specs/python-sdk.md | 48 +++ docs/references/bild-api.md | 10 + docs/references/eval-harness.md | 17 + docs/references/harness-commands.md | 41 +++ pyproject.toml | 26 ++ tests/test_architecture.py | 50 +++ tests/test_auth.py | 4 +- tests/test_client_routes.py | 31 +- tests/test_docs.py | 23 ++ tests/test_import.py | 4 +- tests/test_live_api.py | 307 ++++++++++++++++++ tools/__init__.py | 0 tools/check.py | 75 +++++ tools/linters/__init__.py | 5 + tools/linters/architecture.py | 161 +++++++++ tools/linters/common.py | 29 ++ tools/linters/docs_structure.py | 114 +++++++ tools/linters/taste.py | 156 +++++++++ 44 files changed, 1816 insertions(+), 73 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 Makefile create mode 100644 docs/CONVENTIONS.md create mode 100644 docs/DESIGN.md create mode 100644 docs/INDEX.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/QUALITY_SCORE.md create mode 100644 docs/RELIABILITY.md create mode 100644 docs/SECURITY.md create mode 100644 docs/design-docs/auth.md create mode 100644 docs/design-docs/core-beliefs.md create mode 100644 docs/design-docs/http-client.md create mode 100644 docs/design-docs/index.md create mode 100644 docs/exec-plans/active/README.md create mode 100644 docs/exec-plans/completed/2026-08-14-engineering-harness.md create mode 100644 docs/exec-plans/completed/README.md create mode 100644 docs/exec-plans/tech-debt-tracker.md create mode 100644 docs/product-specs/index.md create mode 100644 docs/product-specs/python-sdk.md create mode 100644 docs/references/bild-api.md create mode 100644 docs/references/eval-harness.md create mode 100644 docs/references/harness-commands.md create mode 100644 tests/test_architecture.py create mode 100644 tests/test_docs.py create mode 100644 tests/test_live_api.py create mode 100644 tools/__init__.py create mode 100644 tools/check.py create mode 100644 tools/linters/__init__.py create mode 100644 tools/linters/architecture.py create mode 100644 tools/linters/common.py create mode 100644 tools/linters/docs_structure.py create mode 100644 tools/linters/taste.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8e05c79 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,md,toml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99e7f4d --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +BILD_API_KEY=YOUR_JWT_TOKEN diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a9ac1d..213d487 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: branches: [main] jobs: - test: + check: runs-on: ubuntu-latest steps: - name: Checkout @@ -15,13 +15,24 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - - name: Install deps + - name: Install run: | python -m pip install --upgrade pip - pip install requests + pip install -e ".[dev]" - - name: Run tests - run: | - python -m unittest discover -s tests -p 'test_*.py' -v + - name: Format + run: ruff format --check . + + - name: Lint + run: ruff check . + + - name: Typecheck + run: mypy bild + + - name: Harness + run: python tools/check.py + + - name: Tests + run: pytest tests -q diff --git a/.gitignore b/.gitignore index 62bc292..cd4fa6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ -/__pycache__/ +.venv/ +venv/ .env __pycache__/ *.pyc +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +dist/ +build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..93aa33c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + - id: ruff-format + - repo: local + hooks: + - id: harness + name: harness linters + entry: python tools/check.py + language: system + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1d0e05b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# Agent map + +Python SDK for the [Bild External API](https://bildexternalapi.portledocs.com/). +Read this file first, then open only the docs you need for the task. + +## What this is + +`bild` is a source-install client for `https://api.getbild.com`. +Callers use `BildClient` and `client.api.` resource methods. +The package is not on PyPI yet. + +## Start here + +| If you need | Open | +| --- | --- | +| Product intent | [docs/PRODUCT.md](docs/PRODUCT.md) | +| Architecture / layers | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Coding rules | [docs/CONVENTIONS.md](docs/CONVENTIONS.md) | +| Design history | [docs/design-docs/index.md](docs/design-docs/index.md) | +| Active work | [docs/exec-plans/active/](docs/exec-plans/active/) | +| Tech debt | [docs/exec-plans/tech-debt-tracker.md](docs/exec-plans/tech-debt-tracker.md) | +| Quality grades | [docs/QUALITY_SCORE.md](docs/QUALITY_SCORE.md) | +| Security | [docs/SECURITY.md](docs/SECURITY.md) | +| Reliability | [docs/RELIABILITY.md](docs/RELIABILITY.md) | +| Full catalog | [docs/INDEX.md](docs/INDEX.md) | + +## Layout + +- `bild/` — SDK package (transport, errors, resource APIs) +- `tests/` — unit tests always; live API tests only if `BILD_API_KEY` is set +- `docs/` — system of record (do not put long guidance in this file) +- `tools/` — harness linters and `tools/check.py` +- `.github/workflows/ci.yml` — format, lint, typecheck, harness, tests + +## Commands + +```bash +python -m pip install -e ".[dev]" # or: uv pip install -e ".[dev]" +python tools/check.py --all +``` + +Or one at a time: `ruff format .` · `ruff check .` · `mypy bild` · `python tools/check.py` · `pytest tests -q` + +Live tests are read-only and skip unless `BILD_API_KEY` is set (or present in `.env`). + +## Invariants (mechanically enforced) + +1. Do not set `Content-Type` on the shared session. Bild treats that header as "this request has a JSON body"; GET/DELETE then 500. +2. Public exports stay `{BildClient, BildAPIError, BildAuthError}`. +3. Resource classes are named `*API` and attached on `_Resources`. +4. Optional JSON fields go through `_omit_none`. +5. Never commit `.env` or real tokens. Use `.env.example`. +6. Live tests must not write or delete. +7. Keep this file under 130 lines. Put detail in `docs/`. +8. After any change, run `python tools/check.py --all` and follow each `REMEDIATION:` line. + +## How to change the SDK + +1. Read `ARCHITECTURE.md` and the matching file under `docs/design-docs/`. +2. Add or update the method on the correct `*API` class. +3. Add a route assertion in `tests/test_client_routes.py`. +4. If the change is user-facing, update `README.md` and `docs/product-specs/python-sdk.md`. +5. Do not invent endpoints. Confirm against the Bild External API reference. + +## When something fails + +Harness linters print `REMEDIATION:` lines. Follow those before improvising. +If a rule is wrong, update the linter and the doc that states the rule in the same change. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5c2f10c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,78 @@ +# Architecture + +Top-level map of the Bild Python SDK. Layer rules are enforced by +`tools/linters/architecture.py` and `tests/test_architecture.py`. + +## Purpose + +Wrap the Bild External HTTP API in a small, typed-enough Python client so +scripts and apps can list projects, manage files, and call other documented +endpoints without assembling URLs and auth headers by hand. + +## Layers (dependency only flows downward) + +``` +bild/__init__.py public surface + │ + ▼ +bild/client.py transport + resource APIs + │ + ▼ +bild/errors.py exception types (no client import) + │ + ▼ +requests / stdlib +``` + +| Layer | Module | May import | Must not import | +| --- | --- | --- | --- | +| Public surface | `bild/__init__.py` | `client`, `errors` | `requests` directly | +| Transport + resources | `bild/client.py` | `errors`, `requests`, stdlib | nothing outside `bild` except `requests` | +| Errors | `bild/errors.py` | stdlib only | `bild.client`, `requests` | + +New modules under `bild/` are allowed only if they fit this layering and are +wired into the public surface or a resource class. Do not add a second HTTP +client. + +## Runtime shape + +``` +BildClient + token, base_url, timeout, session + request / get / post / put / delete + resolve_branch_id / resolve_file_version + api: _Resources + users, projects, project_users, branches, commits, files, + uploads, checkouts, shared_links, metadata, feedback, + packages, revisions, approvals, boms, search, webhooks +``` + +Each `*API` class holds a `client: BildClient` and only issues HTTP via +`self.client`. Resource classes do not call `requests` themselves. + +## HTTP contract + +- Default host: `https://api.getbild.com` +- Auth: `Authorization: Bearer ` on every request +- `Accept: application/json` is set on the session +- `Content-Type` is **not** set on the session. `requests` adds it only when + `json=` is passed. See [docs/design-docs/http-client.md](docs/design-docs/http-client.md). +- 401/403 → `BildAuthError`; other non-OK → `BildAPIError` + +## Tests + +| Suite | Role | +| --- | --- | +| `tests/test_auth.py` | token required, bearer header, no Content-Type, 401/403 | +| `tests/test_client_routes.py` | every resource method hits the expected path/method | +| `tests/test_import.py` | package import smoke | +| `tests/test_live_api.py` | read-only calls against the real API when a token is present | +| `tests/test_architecture.py` | layer and public-surface invariants | +| `tests/test_docs.py` | knowledge-base files exist and stay linked | + +## Known structural debt + +`bild/client.py` currently holds transport helpers and every resource class. +Splitting resources into `bild/resources/` is tracked in +[docs/exec-plans/tech-debt-tracker.md](docs/exec-plans/tech-debt-tracker.md). +Until that lands, file-size limits treat `client.py` as one module. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a0c2e69 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: install format lint typecheck harness test check + +install: + python -m pip install -e ".[dev]" + +format: + ruff format . + +lint: + ruff check . + +typecheck: + mypy bild + +harness: + python tools/check.py + +test: + pytest tests -q + +check: + python tools/check.py --all diff --git a/README.md b/README.md index c6179b4..a03c763 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,13 @@ The client sends that token on every request as: Authorization: Bearer ``` -Set it in the environment: +Copy `.env.example` to `.env` and set the token (`.env` is gitignored): + +```bash +BILD_API_KEY=YOUR_JWT_TOKEN +``` + +`BildClient()` loads `.env` automatically. You can also set the variable in the shell: ```bash export BILD_API_KEY="YOUR_JWT_TOKEN" @@ -97,7 +103,7 @@ print(files) ```python result = client.api.files.export_universal( project_id="project-id", - branch_id=None, # auto-resolves main/default branch + branch_id=None, # auto-resolves main/default branch file_id="file-id", output_format="stl", ) @@ -155,10 +161,7 @@ These map to the groups in the [Bild External API reference](https://bildexterna ## Advanced: custom base URL ```python -client = BildClient( - token="YOUR_JWT_TOKEN", - base_url="https://api.getbild.com" -) +client = BildClient(token="YOUR_JWT_TOKEN", base_url="https://api.getbild.com") ``` ## Escape hatch for unwrapped endpoints @@ -168,10 +171,13 @@ raw = client.get("projects") print(raw) ``` -## Tests +## Tests and development ```bash -python -m unittest discover -s tests -p "test_*.py" -v +python -m pip install -e ".[dev]" +python tools/check.py --all ``` -If `BILD_API_KEY` is set, a live auth smoke test also runs against `GET /users`. +That runs format check, ruff, mypy, harness linters, and pytest. Agents should start at [AGENTS.md](AGENTS.md); the knowledge base lives in [docs/INDEX.md](docs/INDEX.md). + +If `BILD_API_KEY` is set (or present in `.env`), live read-only tests also run against the real API (`users`, `projects`, `files`, `search`, and the other list/get groups). Write and delete calls are not exercised. diff --git a/bild/client.py b/bild/client.py index f564b2e..b97efc7 100644 --- a/bild/client.py +++ b/bild/client.py @@ -1,7 +1,9 @@ from __future__ import annotations import os +from collections.abc import Sequence from dataclasses import dataclass +from pathlib import Path from typing import Any import requests @@ -11,25 +13,55 @@ DEFAULT_BASE_URL = "https://api.getbild.com" +def _load_env_file() -> None: + """Load KEY=VALUE pairs from a local .env without overwriting existing env vars.""" + candidates = [Path.cwd() / ".env"] + try: + candidates.append(Path(__file__).resolve().parents[1] / ".env") + except IndexError: + pass + + seen: set[Path] = set() + for path in candidates: + resolved = path.resolve() + if resolved in seen or not path.is_file(): + continue + seen.add(resolved) + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.startswith("export "): + line = line[7:].strip() + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'").strip('"') + if key and key not in os.environ: + os.environ[key] = value + + +_load_env_file() + + @dataclass class _Resources: - users: "UsersAPI" - projects: "ProjectsAPI" - project_users: "ProjectUsersAPI" - branches: "BranchesAPI" - commits: "CommitsAPI" - files: "FilesAPI" - uploads: "UploadsAPI" - checkouts: "CheckoutsAPI" - shared_links: "SharedLinksAPI" - metadata: "MetadataAPI" - feedback: "FeedbackAPI" - packages: "PackagesAPI" - revisions: "RevisionsAPI" - approvals: "ApprovalsAPI" - boms: "BOMsAPI" - search: "SearchAPI" - webhooks: "WebhooksAPI" + users: UsersAPI + projects: ProjectsAPI + project_users: ProjectUsersAPI + branches: BranchesAPI + commits: CommitsAPI + files: FilesAPI + uploads: UploadsAPI + checkouts: CheckoutsAPI + shared_links: SharedLinksAPI + metadata: MetadataAPI + feedback: FeedbackAPI + packages: PackagesAPI + revisions: RevisionsAPI + approvals: ApprovalsAPI + boms: BOMsAPI + search: SearchAPI + webhooks: WebhooksAPI class BildClient: @@ -127,10 +159,14 @@ def resolve_branch_id(self, project_id: str, branch_id: str | None = None) -> st if not isinstance(b, dict): continue if b.get("isMain") or b.get("isDefault") or b.get("default"): - return b.get("id") or b.get("branchId") + value = b.get("id") or b.get("branchId") + if value: + return str(value) for b in branches: if isinstance(b, dict) and str(b.get("name", "")).lower() in ("main", "master"): - return b.get("id") or b.get("branchId") + value = b.get("id") or b.get("branchId") + if value: + return str(value) first = branches[0] if isinstance(first, dict): @@ -149,7 +185,9 @@ def resolve_file_version( if file_version: return file_version latest = self.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latest") - value = _pick_from_response(latest, "fileVersion", "fileVersionID", "id", "versionId", "latestFileVersion") + value = _pick_from_response( + latest, "fileVersion", "fileVersionID", "id", "versionId", "latestFileVersion" + ) if value: return str(value) raise ValueError("Could not determine file_version automatically") @@ -166,8 +204,8 @@ def list(self): def invite( self, - emails: list[str], - projects: list[dict] | None = None, + emails: Sequence[str], + projects: Sequence[dict] | None = None, *, company_role: str | None = None, pdm_role: str | None = None, @@ -186,13 +224,13 @@ def invite( ), ) - def remove(self, user_ids: list[str]): + def remove(self, user_ids: Sequence[str]): return self.client.put("users/remove", json={"userIDs": user_ids}) def update( self, - user_ids: list[str], - projects: list[dict] | None = None, + user_ids: Sequence[str], + projects: Sequence[dict] | None = None, *, company_role: str | None = None, pdm_role: str | None = None, @@ -224,19 +262,19 @@ class ProjectUsersAPI(_BaseAPI): def list(self, project_id: str): return self.client.get(f"projects/{project_id}/users") - def add(self, users: list[dict], project_ids: list[str] | None = None): + def add(self, users: Sequence[dict], project_ids: Sequence[str] | None = None): return self.client.post( "projects/users/add", json=_omit_none({"users": users, "projectIDs": project_ids}), ) - def remove(self, project_ids: list[str], user_ids: list[str]): + def remove(self, project_ids: Sequence[str], user_ids: Sequence[str]): return self.client.put( "projects/users/remove", json={"projectIDs": project_ids, "userIDs": user_ids}, ) - def update(self, users: list[dict], project_ids: list[str] | None = None): + def update(self, users: Sequence[dict], project_ids: Sequence[str] | None = None): return self.client.put( "projects/users/update", json=_omit_none({"users": users, "projectIDs": project_ids}), @@ -269,7 +307,9 @@ def list_released(self, from_time: str): def list_versions(self, project_id: str, branch_id: str | None, file_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions" + ) def get_latest(self, project_id: str, branch_id: str | None, file_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) @@ -277,7 +317,9 @@ def get_latest(self, project_id: str, branch_id: str | None, file_id: str): def get_released(self, project_id: str, branch_id: str | None, file_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/released") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/released" + ) def get_version(self, project_id: str, branch_id: str | None, file_id: str, version_id: str): branch_id = self.client.resolve_branch_id(project_id, branch_id) @@ -308,7 +350,9 @@ def export_universal( file_config: str | None = None, ): branch_id = self.client.resolve_branch_id(project_id, branch_id) - file_version = self.client.resolve_file_version(project_id, branch_id, file_id, file_version) + file_version = self.client.resolve_file_version( + project_id, branch_id, file_id, file_version + ) return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/{file_id}/universalFormat", json=_omit_none( @@ -326,13 +370,13 @@ def export_universal_many(self, project_id: str, branch_id: str, payload: dict): json=payload, ) - def move(self, project_id: str, branch_id: str, file_ids: list[str], new_parent_id: str): + def move(self, project_id: str, branch_id: str, file_ids: Sequence[str], new_parent_id: str): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/move", json={"moveFiles": file_ids, "newParentID": new_parent_id}, ) - def delete(self, project_id: str, branch_id: str, file_ids: list[str]): + def delete(self, project_id: str, branch_id: str, file_ids: Sequence[str]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/delete", json={"fileIDs": file_ids}, @@ -340,7 +384,7 @@ def delete(self, project_id: str, branch_id: str, file_ids: list[str]): class UploadsAPI(_BaseAPI): - def initiate(self, project_id: str, branch_id: str, files: list[dict]): + def initiate(self, project_id: str, branch_id: str, files: Sequence[dict]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/initiateUpload", json={"files": files}, @@ -350,7 +394,7 @@ def complete( self, project_id: str, branch_id: str, - files: list[dict], + files: Sequence[dict], *, keep_checked_out: bool | None = None, ): @@ -361,19 +405,19 @@ def complete( class CheckoutsAPI(_BaseAPI): - def checkout(self, project_id: str, branch_id: str, file_ids: list[str]): + def checkout(self, project_id: str, branch_id: str, file_ids: Sequence[str]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/checkout", json={"fileIDs": file_ids}, ) - def cancel(self, project_id: str, branch_id: str, file_ids: list[str]): + def cancel(self, project_id: str, branch_id: str, file_ids: Sequence[str]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/cancelCheckout", json={"fileIDs": file_ids}, ) - def initiate_checkin(self, project_id: str, branch_id: str, files: list[dict]): + def initiate_checkin(self, project_id: str, branch_id: str, files: Sequence[dict]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/initiateCheckin", json={"files": files}, @@ -383,7 +427,7 @@ def complete_checkin( self, project_id: str, branch_id: str, - files: list[dict], + files: Sequence[dict], *, message: str | None = None, ): @@ -406,9 +450,9 @@ def create_live( project_id: str, branch_id: str, name: str, - file_ids: list[str], + file_ids: Sequence[str], *, - types: list[str] | None = None, + types: Sequence[str] | None = None, config_map: dict | None = None, ): return self.client.post( @@ -441,7 +485,7 @@ def refresh(self, project_id: str, branch_id: str, link_id: str): f"projects/{project_id}/branches/{branch_id}/sharedLinks/{link_id}/refresh" ) - def delete(self, project_id: str, branch_id: str, link_ids: list[str]): + def delete(self, project_id: str, branch_id: str, link_ids: Sequence[str]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/sharedLinks/delete", json={"sharedLinkIDs": link_ids}, @@ -453,7 +497,9 @@ def list_fields(self): return self.client.get("metadataFields") def get(self, project_id: str, branch_id: str, file_id: str): - return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/metadata") + return self.client.get( + f"projects/{project_id}/branches/{branch_id}/files/{file_id}/metadata" + ) def get_for_version(self, project_id: str, branch_id: str, file_id: str, version_id: str): return self.client.get( @@ -525,7 +571,9 @@ def list( ): if file_id: if not project_id or not branch_id: - raise ValueError("project_id and branch_id are required when listing file revisions") + raise ValueError( + "project_id and branch_id are required when listing file revisions" + ) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions" ) @@ -547,13 +595,13 @@ def get_closure(self, project_id: str, branch_id: str, file_id: str): f"projects/{project_id}/branches/{branch_id}/files/{file_id}/closure" ) - def release(self, project_id: str, branch_id: str, revisions: list[dict]): + def release(self, project_id: str, branch_id: str, revisions: Sequence[dict]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/revisions/release", json=revisions, ) - def cancel(self, project_id: str, branch_id: str, revision_ids: list[str]): + def cancel(self, project_id: str, branch_id: str, revision_ids: Sequence[str]): return self.client.put( f"projects/{project_id}/branches/{branch_id}/revisions/cancel", json={"revisionIDs": revision_ids}, diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md new file mode 100644 index 0000000..41103a5 --- /dev/null +++ b/docs/CONVENTIONS.md @@ -0,0 +1,46 @@ +# Conventions + +## Python + +- Require Python 3.10+. +- Use `from __future__ import annotations` in new modules. +- Public names: `BildClient`, `BildAPIError`, `BildAuthError`. +- Resource classes: `UsersAPI`, `FilesAPI`, … — suffix `API`, attached on + `_Resources`. +- Methods: `snake_case`. JSON body keys: Bild `camelCase`. +- Drop unset optional fields with `_omit_none`. +- Resource methods named `list` must annotate collections as `Sequence[...]` + (from `collections.abc`), not `list[...]`. The method name shadows the + builtin and mypy then rejects `list[str]` as a type. + +## Files + +| Path | Role | +| --- | --- | +| `bild/__init__.py` | re-exports only | +| `bild/errors.py` | exceptions only; no `requests`, no client import | +| `bild/client.py` | transport + resource classes until the split lands | +| `tests/test_*.py` | one concern per file | +| `tools/linters/` | harness rules with remediation text | + +Do not add `bild/utils.py` dumping grounds. Shared helpers stay next to the +only caller, or become a named module with a design doc. + +## HTTP methods + +Match the External API. Several "write" operations are `PUT` (invite, move, +delete files, search). Do not "fix" them to `POST`. + +## Tests + +- Unit tests use a fake session. They must not need a network or token. +- Route tests assert path suffix, method, and important JSON/query fields. +- Live tests (`tests/test_live_api.py`) are read-only and skip without + `BILD_API_KEY`. +- Prefer `unittest` for class-scoped live setup; pytest collects both. + +## Docs + +- Update `docs/INDEX.md` when adding a doc. +- Keep `AGENTS.md` under 130 lines. +- User-facing examples live in `README.md`. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..60ad4c6 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,43 @@ +# Design + +Principles that should survive individual PRs. Encode new ones in linters +when they stop being optional. + +## Progressive disclosure + +Agents start at `AGENTS.md` (~100 lines) and open docs on demand. Do not +grow `AGENTS.md` into a manual. Put history and detail under `docs/`. + +## Mechanical enforcement over prose + +If a rule matters, a linter or structural test must fail when it is broken, +and the error must tell the agent how to fix it. Docs explain *why*. + +## Thin client, thick API docs + +The SDK is a faithful, boring mapping of the HTTP API: + +- Python methods are `snake_case`. +- JSON keys stay Bild's `camelCase` at the wire. +- Helpers exist only where the API is awkward (default branch, latest + file version, omitting null optional fields). + +## One session, one host + +`BildClient` owns one `requests.Session`, one base URL, and one token. +Resource classes do not create their own sessions. + +## Fail at the boundary + +Auth and HTTP errors are raised as typed exceptions with `status_code` and +`payload`. Do not swallow errors or return `None` for failed calls. + +## Taste invariants + +Enforced in `tools/linters/taste.py`: + +- Library code does not `print`. +- Library code does not `time.sleep`. +- No secrets in tracked files. +- File size stays under the configured limit (see tech-debt tracker if + `client.py` is the outlier). diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..b5ca446 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,28 @@ +# Knowledge base catalog + +`docs/` is the system of record. `AGENTS.md` is only a map. + +| Doc | Status | What it covers | +| --- | --- | --- | +| [../ARCHITECTURE.md](../ARCHITECTURE.md) | current | Layers, HTTP contract, test map | +| [PRODUCT.md](PRODUCT.md) | current | Who the SDK is for and what "done" means | +| [DESIGN.md](DESIGN.md) | current | Design principles and taste | +| [CONVENTIONS.md](CONVENTIONS.md) | current | Naming, files, errors, JSON | +| [QUALITY_SCORE.md](QUALITY_SCORE.md) | current | Grades and gaps per area | +| [SECURITY.md](SECURITY.md) | current | Tokens, secrets, live-test rules | +| [RELIABILITY.md](RELIABILITY.md) | current | Timeouts, errors, live vs unit | +| [design-docs/index.md](design-docs/index.md) | current | Design-doc catalog | +| [design-docs/core-beliefs.md](design-docs/core-beliefs.md) | current | Agent-first operating principles | +| [design-docs/http-client.md](design-docs/http-client.md) | current | Transport, headers, resolvers | +| [design-docs/auth.md](design-docs/auth.md) | current | JWT loading and error mapping | +| [exec-plans/active/README.md](exec-plans/active/README.md) | current | How to file an active plan | +| [exec-plans/completed/README.md](exec-plans/completed/README.md) | current | Finished plans | +| [exec-plans/tech-debt-tracker.md](exec-plans/tech-debt-tracker.md) | current | Known debt | +| [product-specs/index.md](product-specs/index.md) | current | Spec catalog | +| [product-specs/python-sdk.md](product-specs/python-sdk.md) | current | Intended SDK UX | +| [references/bild-api.md](references/bild-api.md) | current | Upstream API pointer | +| [references/harness-commands.md](references/harness-commands.md) | current | Local and CI commands | +| [references/eval-harness.md](references/eval-harness.md) | current | How we evaluate the SDK | + +When you add a doc, add a row here and a pointer in `AGENTS.md` if agents +should discover it on every task. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..bed88da --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,42 @@ +# Product + +Bild-Python is the official-in-spirit Python client for the Bild External API. +Bild is a PDM/PLM product for CAD files, projects, revisions, approvals, and +related collaboration features. + +## Who uses this + +- Engineers scripting against a Bild account (list projects, export STL/STEP, + manage shared links, search files). +- Internal tools that need a stable import (`from bild import BildClient`) + rather than raw `requests` calls. +- Agents implementing or extending those scripts. + +## What "done" looks like + +A caller can: + +1. Authenticate with a JWT personal access token (`BILD_API_KEY` or `token=`). +2. Reach every documented External API group through `client.api.`. +3. Get `BildAuthError` on 401/403 and `BildAPIError` on other failures. +4. Rely on branch/version auto-resolution where the SDK documents it + (`resolve_branch_id`, `resolve_file_version`, `files.export_universal`). + +The package is used from source (`pip install -e .`) until it is published +to PyPI. + +## Non-goals + +- A second HTTP stack or async client (unless a design doc and exec plan + land first). +- Wrapping undocumented endpoints. Use `client.get` / `post` / `put` / + `delete` as the escape hatch. +- Storing or refreshing tokens. The app issues JWTs; this client only sends + them. +- Write/delete live tests against a shared account. + +## Source of truth for endpoints + +The [Bild External API reference](https://bildexternalapi.portledocs.com/) +wins when SDK method names and HTTP paths disagree. Update the SDK to match +the reference, then update `README.md` and `docs/product-specs/python-sdk.md`. diff --git a/docs/QUALITY_SCORE.md b/docs/QUALITY_SCORE.md new file mode 100644 index 0000000..fee8536 --- /dev/null +++ b/docs/QUALITY_SCORE.md @@ -0,0 +1,27 @@ +# Quality score + +Grades are for agents and humans deciding where to invest. Update this file +when a grade changes. A = solid and enforced; B = works, some gaps; +C = usable but thin; D = missing or stale. + +| Area | Grade | Notes | +| --- | --- | --- | +| Auth / token loading | B | Env + `.env` + constructor. No expiry awareness. | +| HTTP transport | B | Header contract tested. No retries or pagination helpers. | +| Resource coverage | A | Route tests cover the documented groups. | +| Types | C | Runtime hints only; mypy is not strict on untyped defs. | +| Unit tests | B | Auth + full route table. Helpers are duplicated. | +| Live tests | B | Read-only, skip without key. Account-data dependent. | +| Docs / agent map | A | `AGENTS.md` + `docs/` catalog, linted. | +| Packaging | B | setuptools, source install. Not on PyPI. | +| Lint / format | A | ruff + custom harness linters in CI. | +| Security | B | `.env` gitignored; no token refresh or scoped helpers. | + +## Gaps to close next + +1. Split `bild/client.py` into transport + `bild/resources/` (tech debt). +2. Deduplicate fake session helpers in tests. +3. Tighten mypy (`check_untyped_defs`) after the split. +4. Publish to PyPI when the public surface is stable. + +See [exec-plans/tech-debt-tracker.md](exec-plans/tech-debt-tracker.md). diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md new file mode 100644 index 0000000..9308ecc --- /dev/null +++ b/docs/RELIABILITY.md @@ -0,0 +1,37 @@ +# Reliability + +## Client defaults + +- Timeout: 30 seconds per request (`timeout=` on `BildClient`). +- Live tests use 60 seconds. +- No automatic retries. Callers retry if they need to. +- JSON parse failures become `{"raw": response.text}` rather than raising + from the transport layer. + +## Error mapping + +| HTTP | Exception | +| --- | --- | +| 401, 403 | `BildAuthError` | +| other non-OK | `BildAPIError` | +| missing token at construct | `ValueError` | +| cannot resolve branch/version | `ValueError` | + +Both API errors expose `status_code` and `payload`. + +## Test strategy + +- **Unit:** fake session, no network. These are the merge gate. +- **Live:** optional, read-only, skip without `BILD_API_KEY`. They catch + drift between this client and the hosted API. +- **Harness:** docs layout, layering, taste invariants. Failures include + remediation text. + +A flake in live tests is not a reason to weaken unit tests. Skip or narrow +the live assertion; keep the route table strict. + +## Header pitfall + +Setting `Content-Type: application/json` on the session makes Bild 500 on +GET/DELETE (`Unexpected end of JSON input`). This is tested and linted. +See [design-docs/http-client.md](design-docs/http-client.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..77a482b --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +## Tokens + +Bild personal access tokens are JWTs. Treat them as secrets. + +- Load from `BILD_API_KEY` or `BildClient(token=...)`. +- Local files: copy `.env.example` → `.env`. `.env` is gitignored. +- Never commit a real token, paste one into docs, or log the raw value. +- The client sends `Authorization: Bearer ` and does not persist + tokens beyond process memory. + +## What this client does not do + +- Refresh, rotate, or introspect JWTs (except `users.create_token` as an + API wrapper). +- Encrypt tokens at rest. +- Support OAuth browser flows. + +## Live tests + +`tests/test_live_api.py` and `TestLiveAuth` run only when a token is present. +They must stay read-only (list/get/search). Do not add invite, upload, +delete, checkout, or webhook-create calls to the live suite. + +CI does not receive `BILD_API_KEY`. + +## Dependency surface + +Runtime dependency is `requests` only. Do not add packages that pull in +unrelated network or crypto stacks without a design doc. diff --git a/docs/design-docs/auth.md b/docs/design-docs/auth.md new file mode 100644 index 0000000..6c0a110 --- /dev/null +++ b/docs/design-docs/auth.md @@ -0,0 +1,22 @@ +# Auth + +## Token sources (first match wins) + +1. `BildClient(token=...)` +2. Process env `BILD_API_KEY` +3. `.env` in the current working directory or the repo root, loaded at + import time by `_load_env_file`. Existing env vars are not overwritten. + +A missing token raises `ValueError` at construct time. + +## Errors + +HTTP 401 and 403 raise `BildAuthError` (subclass of `BildAPIError`) with +`status_code` and `payload`. The client does not attempt a retry or a +second token. + +## Issuing tokens + +`client.api.users.create_token` wraps `POST users/apiToken`. That is an +account operation, not SDK configuration. Do not auto-call it from +`BildClient.__init__`. diff --git a/docs/design-docs/core-beliefs.md b/docs/design-docs/core-beliefs.md new file mode 100644 index 0000000..090250c --- /dev/null +++ b/docs/design-docs/core-beliefs.md @@ -0,0 +1,13 @@ +# Core beliefs + +1. **The repo is the system of record.** If it is not in git, agents cannot + see it. Prompts and chat decisions belong in a design doc or exec plan. +2. **`AGENTS.md` is a table of contents.** Long rules go in `docs/` and are + enforced by linters. +3. **Invariants beat style debates.** Encode the ones that prevent API + breakage (headers, public exports, read-only live tests). +4. **Do not invent Bild endpoints.** The External API reference is upstream. +5. **Corrections are cheap; waiting is expensive.** Prefer a follow-up PR + over blocking on perfect structure, then pay debt via the tracker. +6. **Remediation text is part of the interface.** A failing lint that only + says "error" is a harness bug. diff --git a/docs/design-docs/http-client.md b/docs/design-docs/http-client.md new file mode 100644 index 0000000..c8a3eb6 --- /dev/null +++ b/docs/design-docs/http-client.md @@ -0,0 +1,40 @@ +# HTTP client + +## Session + +`BildClient` uses one `requests.Session` (injected via `session=` for tests). + +Session headers: + +- `Authorization: Bearer ` +- `Accept: application/json` + +Do **not** set `Content-Type` on the session. Bild's API treats that header +as "this request has a JSON body". GET and DELETE then fail with HTTP 500 +and `Unexpected end of JSON input`. `requests` sets `Content-Type` only +when `json=` is passed to `session.request`. + +`BildClient.request` passes `json=` only when the caller supplied a body. + +## URL building + +`{base_url}/{path}` with `base_url` stripped of a trailing slash and `path` +stripped of a leading slash. Default `base_url` is `https://api.getbild.com`. + +## Resolvers + +- `resolve_branch_id(project_id, branch_id=None)` — uses the given id, else + the branch marked main/default, else a branch named main/master, else the + first branch. +- `resolve_file_version(...)` — uses the given version, else + `GET .../files/{file_id}/latest`. + +Resource methods that accept `branch_id: str | None` should call +`resolve_branch_id` rather than inventing their own lookup. + +## Response helper + +`_safe_json` returns parsed JSON or `{"raw": response.text}`. +`_pick_list` / `_pick_from_response` tolerate `{data: ...}` and `{items: ...}` +envelopes. Keep that tolerance at the helper layer, not copied into every +resource method. diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md new file mode 100644 index 0000000..c7bc2f8 --- /dev/null +++ b/docs/design-docs/index.md @@ -0,0 +1,10 @@ +# Design docs + +| Doc | Status | Topic | +| --- | --- | --- | +| [core-beliefs.md](core-beliefs.md) | current | Agent-first operating principles | +| [http-client.md](http-client.md) | current | Session, headers, resolvers | +| [auth.md](auth.md) | current | Token sources and auth errors | + +Add a row when you add a design doc. Mark superseded docs `obsolete` and +leave them in place until a gardening pass deletes them. diff --git a/docs/exec-plans/active/README.md b/docs/exec-plans/active/README.md new file mode 100644 index 0000000..007083b --- /dev/null +++ b/docs/exec-plans/active/README.md @@ -0,0 +1,10 @@ +# Active execution plans + +Put one markdown file per in-flight change that needs more than a single +PR of context. Name it `YYYY-MM-DD-short-slug.md`. + +Each plan should include: goal, non-goals, files likely to change, how to +verify (`python tools/check.py --all`), and a decision log. + +When the work merges, move the file to `../completed/` and add a line to +`../tech-debt-tracker.md` if anything was deferred. diff --git a/docs/exec-plans/completed/2026-08-14-engineering-harness.md b/docs/exec-plans/completed/2026-08-14-engineering-harness.md new file mode 100644 index 0000000..714a79a --- /dev/null +++ b/docs/exec-plans/completed/2026-08-14-engineering-harness.md @@ -0,0 +1,23 @@ +# Completed: engineering harness scaffold + +**Date:** 2026-08-14 + +## Goal + +Make this repository agent-legible using OpenAI's harness-engineering +pattern: short `AGENTS.md`, `docs/` as system of record, mechanical +enforcement (ruff, mypy, custom linters, structural tests), and CI that +runs the full loop. + +## Shipped + +- `AGENTS.md` map and `ARCHITECTURE.md` +- `docs/` catalog, design docs, product spec, quality grades, plans +- `tools/linters/` + `tools/check.py` +- ruff / mypy / pytest via `.[dev]` +- structural tests and expanded GitHub Actions CI + +## Deferred + +See [../tech-debt-tracker.md](../tech-debt-tracker.md) (split `client.py`, +shared test fakes, stricter mypy). diff --git a/docs/exec-plans/completed/README.md b/docs/exec-plans/completed/README.md new file mode 100644 index 0000000..09d5707 --- /dev/null +++ b/docs/exec-plans/completed/README.md @@ -0,0 +1,4 @@ +# Completed execution plans + +Finished plans live here so agents can see why the tree looks the way it +does without relying on chat history. diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md new file mode 100644 index 0000000..2bd8544 --- /dev/null +++ b/docs/exec-plans/tech-debt-tracker.md @@ -0,0 +1,12 @@ +# Tech debt tracker + +| ID | Item | Why it exists | Suggested fix | Severity | +| --- | --- | --- | --- | --- | +| TD-1 | `bild/client.py` holds transport and every `*API` class | Fast first implementation | Split transport vs `bild/resources/*.py`; keep public imports stable | Medium | +| TD-2 | `FakeResponse` / fake session duplicated in tests | Tests grew independently | Shared `tests/fakes.py` | Low | +| TD-3 | mypy does not use `check_untyped_defs` | Current helpers are loosely typed | Annotate helpers after TD-1, then tighten | Low | +| TD-4 | Package not published to PyPI | Still source-install | Release process + version policy | Low | +| TD-5 | No retries / pagination helpers | API wrappers stay thin | Add only with a design doc and tests | Low | + +Do not add debt here without an owner-less next step. Close rows when the +fix merges. diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md new file mode 100644 index 0000000..5a1efcf --- /dev/null +++ b/docs/product-specs/index.md @@ -0,0 +1,5 @@ +# Product specs + +| Spec | Status | +| --- | --- | +| [python-sdk.md](python-sdk.md) | current | diff --git a/docs/product-specs/python-sdk.md b/docs/product-specs/python-sdk.md new file mode 100644 index 0000000..7cba149 --- /dev/null +++ b/docs/product-specs/python-sdk.md @@ -0,0 +1,48 @@ +# Spec: Python SDK + +## Intent + +A Python 3.10+ library named `bild` that maps the Bild External API into +resource objects on `BildClient.api`. + +## Public surface + +```python +from bild import BildClient, BildAPIError, BildAuthError +``` + +No other names are exported. Escape hatch: `client.get/post/put/delete`. + +## Resource groups + +Must stay aligned with `BildClient.api` and the README "API groups" list: + +`users`, `projects`, `project_users`, `branches`, `commits`, `files`, +`uploads`, `checkouts`, `shared_links`, `metadata`, `feedback`, +`packages`, `revisions`, `approvals`, `boms`, `search`, `webhooks`. + +## Auth + +- Required token from `token=` or `BILD_API_KEY`. +- Bearer header on every request. +- `BildAuthError` on 401/403. + +## Convenience (allowed) + +- Load `.env` without overriding existing env. +- Auto-resolve default branch and latest file version where documented. +- Omit `None` optional JSON fields. + +## Convenience (not allowed without a new spec) + +- Async client +- Automatic pagination objects +- Retry/backoff middleware +- Code generation from an OpenAPI file (unless an exec plan replaces this + hand-written mapping) + +## Acceptance + +- `python tools/check.py --all` passes. +- `tests/test_client_routes.py` covers each new method. +- README example still runs from a source install. diff --git a/docs/references/bild-api.md b/docs/references/bild-api.md new file mode 100644 index 0000000..025bc21 --- /dev/null +++ b/docs/references/bild-api.md @@ -0,0 +1,10 @@ +# Bild External API + +Upstream reference (wins on path, method, and payload shape): + +https://bildexternalapi.portledocs.com/#/docs/apireference?api_page=introduction&product_version=77 + +Default host used by this SDK: `https://api.getbild.com`. + +When the reference and this client disagree, change the client and tests +first, then the README examples. diff --git a/docs/references/eval-harness.md b/docs/references/eval-harness.md new file mode 100644 index 0000000..dc4e914 --- /dev/null +++ b/docs/references/eval-harness.md @@ -0,0 +1,17 @@ +# Evaluation harness + +This SDK is evaluated by executable checks, not by a separate model-eval +framework. + +| Layer | Command | What it proves | +| --- | --- | --- | +| Format | `ruff format --check .` | Mechanical style | +| Lint | `ruff check .` | Standard Python defects | +| Types | `mypy bild` | Import and annotation consistency | +| Architecture / docs / taste | `python tools/check.py` | Harness invariants | +| Unit + route + live-skip | `pytest tests -q` | Client behavior | +| Live (optional) | `BILD_API_KEY=... pytest tests -q` | Hosted API still matches | + +A change is not done until `python tools/check.py --all` is green. +If you add a new class of mistake agents keep making, add a linter with a +`REMEDIATION:` line rather than another paragraph in `AGENTS.md`. diff --git a/docs/references/harness-commands.md b/docs/references/harness-commands.md new file mode 100644 index 0000000..7e725a8 --- /dev/null +++ b/docs/references/harness-commands.md @@ -0,0 +1,41 @@ +# Harness commands + +Install once: + +```bash +python -m pip install -e ".[dev]" +``` + +If the interpreter is uv-managed (PEP 668), use the project venv instead: + +```bash +uv pip install -e ".[dev]" --python .venv/Scripts/python.exe +.\.venv\Scripts\python.exe tools\check.py --all +``` + +One-shot (format check, lint, types, custom linters, tests): + +```bash +python tools/check.py --all +``` + +Apply formatting (not used in CI; CI only `--check`s): + +```bash +ruff format . +``` + +Individual gates: + +```bash +ruff format --check . +ruff check . +mypy bild +python tools/check.py +pytest tests -q +``` + +Unix shortcut: `make check` (same as `python tools/check.py --all`). + +Live API tests run automatically when `BILD_API_KEY` is set. They are +read-only. CI does not set that variable. diff --git a/pyproject.toml b/pyproject.toml index 9059596..361ff08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,12 @@ dependencies = ["requests>=2.31.0"] [project.optional-dependencies] test = ["pytest>=8.0.0"] +dev = [ + "pytest>=8.0.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "types-requests>=2.31.0", +] [project.urls] Homepage = "https://github.com/AJFrio/Bild-Python" @@ -25,3 +31,23 @@ package-dir = {"" = "."} [tool.setuptools.packages.find] where = ["."] include = ["bild*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["bild", "tests", "tools"] +exclude = ["bild_python.egg-info"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.mypy] +python_version = "3.10" +packages = ["bild"] +ignore_missing_imports = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-q" diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..37daaec --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import unittest + +from bild import BildAPIError, BildAuthError, BildClient +from bild.client import _Resources +from tools.linters import check_architecture, check_docs_structure, check_taste + + +class TestPublicSurface(unittest.TestCase): + def test_exports(self): + import bild + + self.assertEqual( + set(bild.__all__), + {"BildClient", "BildAPIError", "BildAuthError"}, + ) + self.assertIs(bild.BildClient, BildClient) + self.assertIs(bild.BildAPIError, BildAPIError) + self.assertIs(bild.BildAuthError, BildAuthError) + + def test_resources_match_client(self): + expected = {name for name in _Resources.__annotations__} + client = BildClient(token="test-token") + actual = {name for name in vars(client.api) if not name.startswith("_")} + self.assertEqual(expected, actual) + for name in expected: + resource = getattr(client.api, name) + self.assertTrue( + type(resource).__name__.endswith("API"), + f"{name} should be an *API class, got {type(resource).__name__}", + ) + + +class TestHarnessLinters(unittest.TestCase): + def test_docs_structure(self): + violations = check_docs_structure() + self.assertEqual([], violations, "\n".join(v.format() for v in violations)) + + def test_architecture(self): + violations = check_architecture() + self.assertEqual([], violations, "\n".join(v.format() for v in violations)) + + def test_taste(self): + violations = check_taste() + self.assertEqual([], violations, "\n".join(v.format() for v in violations)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auth.py b/tests/test_auth.py index bc229bb..0920e5c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -7,7 +7,9 @@ from dataclasses import dataclass from urllib.parse import urlparse -if "requests" not in sys.modules: +try: + import requests # noqa: F401 +except ImportError: fake_requests = types.ModuleType("requests") fake_requests.Session = object fake_requests.Response = object diff --git a/tests/test_client_routes.py b/tests/test_client_routes.py index 39834b1..04919ac 100644 --- a/tests/test_client_routes.py +++ b/tests/test_client_routes.py @@ -1,12 +1,14 @@ from __future__ import annotations +import sys +import types import unittest from dataclasses import dataclass from urllib.parse import urlparse -import sys -import types -if "requests" not in sys.modules: +try: + import requests # noqa: F401 +except ImportError: fake_requests = types.ModuleType("requests") fake_requests.Session = object fake_requests.Response = object @@ -103,13 +105,19 @@ def test_full_route_coverage(self): self.assertTrue(self.last()["path"].endswith("/files/released")) self.assertEqual(self.last()["params"]["fromTime"], "2024-01-01T00:00:00Z") c.api.files.list_versions("p1", None, "f1") - self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/versions")) + self.assertTrue( + self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/versions") + ) c.api.files.get_latest("p1", None, "f1") - self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/latest")) + self.assertTrue( + self.last()["path"].endswith("/projects/p1/branches/branch-main/files/f1/latest") + ) c.api.files.get_released("p1", "b1", "f1") self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/files/f1/released")) c.api.files.get_version("p1", "b1", "f1", "v1") - self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/files/f1/versions/v1")) + self.assertTrue( + self.last()["path"].endswith("/projects/p1/branches/b1/files/f1/versions/v1") + ) c.api.files.get_thumbnail("p1", "b1", "f1", "v1") self.assertTrue(self.last()["path"].endswith("/thumbnail")) c.api.files.get_children("p1", "b1", "f1", "v1") @@ -118,7 +126,9 @@ def test_full_route_coverage(self): self.assertTrue(self.last()["path"].endswith("/fileActions/f1/universalFormat")) self.assertEqual(self.last()["method"], "PUT") self.assertEqual(self.last()["json"]["fileVersionID"], "v-latest") - c.api.files.export_universal_many("p1", "b1", {"fileIDs": ["f1"], "formats": {"CAD": ["STL"]}}) + c.api.files.export_universal_many( + "p1", "b1", {"fileIDs": ["f1"], "formats": {"CAD": ["STL"]}} + ) self.assertTrue(self.last()["path"].endswith("/files/exportUniversalFiles")) c.api.files.move("p1", "b1", ["f1"], "parent-1") self.assertTrue(self.last()["path"].endswith("/fileActions/move")) @@ -219,7 +229,12 @@ def test_full_route_coverage(self): self.assertTrue(self.last()["path"].endswith("/projects/p1/branches/b1/boms")) c.api.boms.get("p1", "b1", "bom1") self.assertTrue(self.last()["path"].endswith("/boms/bom1")) - c.api.boms.download("p1", "b1", "bom1", {"version_id": "v", "view_id": "w", "type": "Indented", "formats": {}}) + c.api.boms.download( + "p1", + "b1", + "bom1", + {"version_id": "v", "view_id": "w", "type": "Indented", "formats": {}}, + ) self.assertTrue(self.last()["path"].endswith("/boms/bom1/download")) c.api.search.files("bolt") diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..e9f6528 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class TestDocsPresent(unittest.TestCase): + def test_agents_is_a_map(self): + text = (ROOT / "AGENTS.md").read_text(encoding="utf-8") + self.assertLessEqual(len(text.splitlines()), 130) + self.assertIn("docs/INDEX.md", text) + self.assertIn("python tools/check.py --all", text) + + def test_architecture_describes_layers(self): + text = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") + self.assertIn("bild/errors.py", text) + self.assertIn("Content-Type", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_import.py b/tests/test_import.py index c4a55ae..3e8da09 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,7 +1,9 @@ import sys import types -if "requests" not in sys.modules: +try: + import requests # noqa: F401 +except ImportError: fake_requests = types.ModuleType("requests") fake_requests.Session = object fake_requests.Response = object diff --git a/tests/test_live_api.py b/tests/test_live_api.py new file mode 100644 index 0000000..e08d91d --- /dev/null +++ b/tests/test_live_api.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import os +import unittest +from typing import Any + +from bild import BildAPIError, BildClient + + +def _as_list(payload: Any, *keys: str) -> list: + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + + preferred = keys or ( + "data", + "items", + "commits", + "files", + "sharedLinks", + "packages", + "revisions", + "approvals", + "boms", + "feedbackItems", + ) + for key in preferred: + value = payload.get(key) + if isinstance(value, list): + return value + if isinstance(value, dict): + nested = _as_list(value, *keys) + if nested: + return nested + + data = payload.get("data") + if isinstance(data, list): + return data + if isinstance(data, dict): + return _as_list(data, *keys) + return [] + + +def _first_id(items: list, *keys: str) -> str | None: + for item in items: + if not isinstance(item, dict): + continue + for key in keys or ("id",): + value = item.get(key) + if value: + return str(value) + return None + + +@unittest.skipUnless(os.getenv("BILD_API_KEY"), "BILD_API_KEY not set") +class TestLiveAPI(unittest.TestCase): + """Hit the real Bild API with read-only calls. Write/delete endpoints are skipped.""" + + client: BildClient + project_id: str + branch_id: str + file_id: str | None + version_id: str | None + commit_id: str | None + + @classmethod + def setUpClass(cls): + cls.client = BildClient(timeout=60.0) + projects = _as_list(cls.client.api.projects.list()) + if not projects: + raise unittest.SkipTest("No projects available for live tests") + + project = next((p for p in projects if p.get("name") == "Sandboxes"), projects[0]) + cls.project_id = project["id"] + cls.branch_id = cls.client.resolve_branch_id(cls.project_id) + cls.file_id = None + cls.version_id = None + cls.commit_id = None + + commits = _as_list(cls.client.api.commits.list(cls.project_id, cls.branch_id), "commits") + cls.commit_id = _first_id(commits, "id") + if cls.commit_id: + detail = cls.client.api.commits.get(cls.project_id, cls.branch_id, cls.commit_id) + files = [] + if isinstance(detail, dict): + data = detail.get("data") if isinstance(detail.get("data"), dict) else detail + files = data.get("files") or [] + cls.file_id = _first_id(files, "fileID", "fileId") + cls.version_id = _first_id(files, "fileVersionID", "versionID", "id") + + if not cls.file_id: + released = _as_list(cls.client.api.files.list_released("2026-01-01T00:00:00Z")) + match = next( + ( + item + for item in released + if isinstance(item, dict) and item.get("projectID") == cls.project_id + ), + None, + ) + if match: + cls.file_id = match.get("fileID") or match.get("id") + cls.version_id = match.get("versionID") or match.get("fileVersionID") + + if cls.file_id and not cls.version_id: + latest = cls.client.api.files.get_latest(cls.project_id, cls.branch_id, cls.file_id) + if isinstance(latest, dict): + data = latest.get("data") if isinstance(latest.get("data"), dict) else latest + cls.version_id = ( + data.get("fileVersionID") or data.get("fileVersion") or data.get("versionId") + ) + + def test_list_users(self): + users = _as_list(self.client.api.users.list()) + self.assertGreater(len(users), 0) + self.assertTrue(users[0].get("id") or users[0].get("email")) + + def test_list_projects(self): + projects = _as_list(self.client.api.projects.list()) + self.assertGreater(len(projects), 0) + self.assertTrue(any(p.get("id") == self.project_id for p in projects)) + + def test_list_project_users(self): + users = _as_list(self.client.api.project_users.list(self.project_id)) + self.assertGreater(len(users), 0) + + def test_list_branches(self): + branches = _as_list(self.client.api.branches.list(self.project_id)) + self.assertGreater(len(branches), 0) + self.assertTrue(any((b.get("id") or b.get("branchId")) == self.branch_id for b in branches)) + + def test_list_commits(self): + commits = _as_list(self.client.api.commits.list(self.project_id, self.branch_id), "commits") + self.assertIsInstance(commits, list) + + def test_get_commit(self): + if not self.commit_id: + self.skipTest("No commits available") + detail = self.client.api.commits.get(self.project_id, self.branch_id, self.commit_id) + self.assertIsInstance(detail, dict) + data = detail.get("data") if isinstance(detail.get("data"), dict) else detail + self.assertEqual(data.get("id"), self.commit_id) + + def test_list_files(self): + payload = self.client.api.files.list(self.project_id, self.branch_id) + self.assertIsInstance(payload, dict) + self.assertTrue( + isinstance(payload.get("data"), (list, dict)) + or payload.get("s3Url") + or payload.get("items") + ) + + def test_list_released_files(self): + payload = self.client.api.files.list_released("2026-01-01T00:00:00Z") + self.assertIsInstance(payload, (dict, list)) + files = _as_list(payload) + self.assertIsInstance(files, list) + + def test_file_versions_and_latest(self): + if not self.file_id: + self.skipTest("No file available") + versions = _as_list( + self.client.api.files.list_versions(self.project_id, self.branch_id, self.file_id) + ) + self.assertGreater(len(versions), 0) + latest = self.client.api.files.get_latest(self.project_id, self.branch_id, self.file_id) + self.assertIsInstance(latest, dict) + data = latest.get("data") if isinstance(latest.get("data"), dict) else latest + self.assertTrue(data.get("fileVersionID") or data.get("fileID")) + + def test_file_version_detail(self): + if not self.file_id or not self.version_id: + self.skipTest("No file version available") + detail = self.client.api.files.get_version( + self.project_id, self.branch_id, self.file_id, self.version_id + ) + self.assertIsInstance(detail, dict) + + def test_file_thumbnail(self): + if not self.file_id or not self.version_id: + self.skipTest("No file version available") + payload = self.client.api.files.get_thumbnail( + self.project_id, self.branch_id, self.file_id, self.version_id + ) + self.assertIsInstance(payload, dict) + data = payload.get("data") if isinstance(payload.get("data"), dict) else payload + self.assertTrue(data.get("thumbnailURL") or data.get("url") or payload) + + def test_file_children(self): + if not self.file_id or not self.version_id: + self.skipTest("No file version available") + payload = self.client.api.files.get_children( + self.project_id, self.branch_id, self.file_id, self.version_id + ) + self.assertIsInstance(payload, (dict, list)) + + def test_shared_links(self): + account = self.client.api.shared_links.list() + project = self.client.api.shared_links.list(self.project_id) + branch = self.client.api.shared_links.list(self.project_id, self.branch_id) + self.assertIsInstance(account, dict) + self.assertIsInstance(project, dict) + self.assertIsInstance(branch, dict) + + def test_metadata_fields(self): + fields = _as_list(self.client.api.metadata.list_fields()) + self.assertGreater(len(fields), 0) + self.assertTrue(fields[0].get("id") or fields[0].get("name")) + + def test_file_metadata(self): + if not self.file_id: + self.skipTest("No file available") + payload = self.client.api.metadata.get(self.project_id, self.branch_id, self.file_id) + self.assertIsInstance(payload, dict) + if self.version_id: + versioned = self.client.api.metadata.get_for_version( + self.project_id, self.branch_id, self.file_id, self.version_id + ) + self.assertIsInstance(versioned, dict) + + def test_feedback(self): + payload = self.client.api.feedback.list(self.project_id) + self.assertIsInstance(payload, dict) + items = _as_list(payload, "feedbackItems") + self.assertIsInstance(items, list) + if items: + item_id = _first_id(items, "id") + detail = self.client.api.feedback.get(self.project_id, item_id) + self.assertIsInstance(detail, dict) + + def test_packages(self): + account = self.client.api.packages.list() + project = self.client.api.packages.list(self.project_id) + self.assertIsInstance(account, dict) + self.assertIsInstance(project, dict) + packages = _as_list(account, "packages") + if packages: + package_id = _first_id(packages, "id") + package_project = packages[0].get("projectID") or self.project_id + detail = self.client.api.packages.get(package_project, package_id) + self.assertIsInstance(detail, dict) + + def test_revisions(self): + account = self.client.api.revisions.list() + project = self.client.api.revisions.list(self.project_id) + branch = self.client.api.revisions.list(self.project_id, self.branch_id) + self.assertIsInstance(account, (dict, list)) + self.assertIsInstance(project, (dict, list)) + self.assertIsInstance(branch, (dict, list)) + if self.file_id: + file_revs = self.client.api.revisions.list( + self.project_id, self.branch_id, self.file_id + ) + revisions = _as_list(file_revs, "revisions") + self.assertIsInstance(revisions, list) + revision_id = _first_id(revisions, "id") + if revision_id: + detail = self.client.api.revisions.get( + self.project_id, self.branch_id, self.file_id, revision_id + ) + self.assertIsInstance(detail, dict) + closure = self.client.api.revisions.get_closure( + self.project_id, self.branch_id, self.file_id + ) + self.assertIsInstance(closure, dict) + + def test_approvals(self): + account = self.client.api.approvals.list() + project = self.client.api.approvals.list(self.project_id) + self.assertIsInstance(account, dict) + self.assertIsInstance(project, dict) + + def test_boms(self): + payload = self.client.api.boms.list(self.project_id, self.branch_id) + self.assertIsInstance(payload, dict) + boms = _as_list(payload, "boms") + if boms: + bom_id = _first_id(boms, "id") + detail = self.client.api.boms.get(self.project_id, self.branch_id, bom_id) + self.assertIsInstance(detail, dict) + + def test_search_files(self): + payload = self.client.api.search.files("bolt", page_size=5) + self.assertIsInstance(payload, dict) + files = _as_list(payload, "files") + self.assertGreater(len(files), 0) + + def test_webhooks(self): + payload = self.client.api.webhooks.list() + self.assertIsInstance(payload, (dict, list)) + + def test_released_file_is_optional(self): + if not self.file_id: + self.skipTest("No file available") + try: + payload = self.client.api.files.get_released( + self.project_id, self.branch_id, self.file_id + ) + except BildAPIError as exc: + self.assertEqual(exc.status_code, 404) + return + self.assertIsInstance(payload, dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/check.py b/tools/check.py new file mode 100644 index 0000000..7352d16 --- /dev/null +++ b/tools/check.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Run harness linters, or the full local gate with --all.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tools.linters import ( # noqa: E402 + check_architecture, + check_docs_structure, + check_taste, +) + + +def run_harness() -> int: + violations = [] + violations.extend(check_docs_structure()) + violations.extend(check_architecture()) + violations.extend(check_taste()) + if not violations: + print("Harness linters: ok") + return 0 + print(f"Harness linters: {len(violations)} violation(s)\n") + for item in violations: + print(item.format()) + return 1 + + +def run_command(args: list[str]) -> int: + print("+", " ".join(args)) + completed = subprocess.run(args, cwd=ROOT) + return completed.returncode + + +def run_all() -> int: + steps = [ + [sys.executable, "-m", "ruff", "format", "--check", "."], + [sys.executable, "-m", "ruff", "check", "."], + [sys.executable, "-m", "mypy", "bild"], + ] + for step in steps: + code = run_command(step) + if code != 0: + print( + "REMEDIATION: Fix the tool output above, then re-run " + "`python tools/check.py --all`. " + "Use `ruff format .` to apply formatting." + ) + return code + code = run_harness() + if code != 0: + return code + return run_command([sys.executable, "-m", "pytest", "tests", "-q"]) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bild-Python engineering harness") + parser.add_argument( + "--all", + action="store_true", + help="format check, ruff, mypy, harness linters, pytest", + ) + args = parser.parse_args() + return run_all() if args.all else run_harness() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/linters/__init__.py b/tools/linters/__init__.py new file mode 100644 index 0000000..97ca303 --- /dev/null +++ b/tools/linters/__init__.py @@ -0,0 +1,5 @@ +from .architecture import check_architecture +from .docs_structure import check_docs_structure +from .taste import check_taste + +__all__ = ["check_architecture", "check_docs_structure", "check_taste"] diff --git a/tools/linters/architecture.py b/tools/linters/architecture.py new file mode 100644 index 0000000..ec04295 --- /dev/null +++ b/tools/linters/architecture.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import ast +import re + +from .common import ROOT, Violation + +PUBLIC_EXPORTS = ("BildClient", "BildAPIError", "BildAuthError") +ALLOWED_BILD_MODULES = {"__init__", "client", "errors"} + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def check_architecture() -> list[Violation]: + violations: list[Violation] = [] + bild_dir = ROOT / "bild" + if not bild_dir.is_dir(): + return [ + Violation( + rule="arch.package_missing", + path="bild/", + message="The bild package directory is missing.", + remediation="Restore the bild/ package with __init__.py, client.py, and errors.py.", + ) + ] + + for path in sorted(bild_dir.glob("*.py")): + if path.stem not in ALLOWED_BILD_MODULES and path.stem != "__init__": + violations.append( + Violation( + rule="arch.unexpected_module", + path=f"bild/{path.name}", + message=f"Unexpected top-level module {path.name}.", + remediation=( + "New modules need a design doc and an update to " + "ALLOWED_BILD_MODULES in tools/linters/architecture.py plus " + "ARCHITECTURE.md. Prefer adding a resource method on an " + "existing *API class, or land the bild/resources/ split " + "(TD-1) instead of a one-off file." + ), + ) + ) + + init_src = _read("bild/__init__.py") + if "import requests" in init_src or "from requests" in init_src: + violations.append( + Violation( + rule="arch.init_requests", + path="bild/__init__.py", + message="Public surface imports requests.", + remediation="Keep bild/__init__.py as re-exports of BildClient and errors only.", + ) + ) + for name in PUBLIC_EXPORTS: + if name not in init_src: + violations.append( + Violation( + rule="arch.public_export", + path="bild/__init__.py", + message=f"{name} is not exported from bild/__init__.py.", + remediation=( + f"Import and include {name} in __all__. Public surface must stay " + f"{set(PUBLIC_EXPORTS)} unless docs/product-specs/python-sdk.md " + "and this linter are updated together." + ), + ) + ) + + errors_src = _read("bild/errors.py") + if "bild.client" in errors_src or "from .client" in errors_src: + violations.append( + Violation( + rule="arch.errors_import_client", + path="bild/errors.py", + message="errors.py imports the client (layering violation).", + remediation=( + "errors.py may only use the stdlib. Move any client-aware logic to client.py." + ), + ) + ) + if "import requests" in errors_src or "from requests" in errors_src: + violations.append( + Violation( + rule="arch.errors_import_requests", + path="bild/errors.py", + message="errors.py imports requests.", + remediation="Keep exception types independent of the HTTP library.", + ) + ) + + client_src = _read("bild/client.py") + if re.search(r"session\.headers\.update\([\s\S]*Content-Type", client_src): + violations.append( + Violation( + rule="arch.session_content_type", + path="bild/client.py", + message="Session headers set Content-Type.", + remediation=( + "Remove Content-Type from session.headers. Bild treats that header " + "as 'this request has a JSON body' and GET/DELETE then 500. Let " + "requests set Content-Type only when json= is passed. See " + "docs/design-docs/http-client.md." + ), + ) + ) + + tree = ast.parse(client_src) + api_classes: set[str] = set() + resources_fields: set[str] = set() + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name.endswith("API"): + api_classes.add(node.name) + if isinstance(node, ast.ClassDef) and node.name == "_Resources": + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + resources_fields.add(item.target.id) + + if not api_classes: + violations.append( + Violation( + rule="arch.no_api_classes", + path="bild/client.py", + message="No *API resource classes found.", + remediation=( + "Resource wrappers must be classes named API attached on _Resources." + ), + ) + ) + + readme = _read("README.md") + spec = _read("docs/product-specs/python-sdk.md") + for field in sorted(resources_fields): + token = f"client.api.{field}" + if token not in readme and f"`{field}`" not in readme and field not in readme: + violations.append( + Violation( + rule="arch.readme_resource", + path="README.md", + message=f"Resource {field} is not mentioned in README.md.", + remediation=( + f"Add client.api.{field} to the README API groups list so " + "humans and agents discover it." + ), + ) + ) + if field not in spec: + violations.append( + Violation( + rule="arch.spec_resource", + path="docs/product-specs/python-sdk.md", + message=f"Resource {field} is not listed in the product spec.", + remediation=( + f"Add `{field}` to the resource groups list in " + "docs/product-specs/python-sdk.md." + ), + ) + ) + + return violations diff --git a/tools/linters/common.py b/tools/linters/common.py new file mode 100644 index 0000000..8ce7584 --- /dev/null +++ b/tools/linters/common.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +@dataclass(frozen=True) +class Violation: + rule: str + path: str + message: str + remediation: str + + def format(self) -> str: + return ( + f"RULE: {self.rule}\n" + f"PATH: {self.path}\n" + f"MESSAGE: {self.message}\n" + f"REMEDIATION: {self.remediation}\n" + ) + + +def rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() diff --git a/tools/linters/docs_structure.py b/tools/linters/docs_structure.py new file mode 100644 index 0000000..13c431f --- /dev/null +++ b/tools/linters/docs_structure.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from .common import ROOT, Violation + +AGENTS_MAX_LINES = 130 + +REQUIRED_DOCS = ( + "AGENTS.md", + "ARCHITECTURE.md", + "docs/INDEX.md", + "docs/PRODUCT.md", + "docs/DESIGN.md", + "docs/CONVENTIONS.md", + "docs/QUALITY_SCORE.md", + "docs/SECURITY.md", + "docs/RELIABILITY.md", + "docs/design-docs/index.md", + "docs/design-docs/core-beliefs.md", + "docs/design-docs/http-client.md", + "docs/design-docs/auth.md", + "docs/exec-plans/active/README.md", + "docs/exec-plans/completed/README.md", + "docs/exec-plans/tech-debt-tracker.md", + "docs/product-specs/index.md", + "docs/product-specs/python-sdk.md", + "docs/references/bild-api.md", + "docs/references/harness-commands.md", + "docs/references/eval-harness.md", +) + +AGENTS_MUST_MENTION = ( + "docs/PRODUCT.md", + "ARCHITECTURE.md", + "docs/CONVENTIONS.md", + "docs/INDEX.md", + "tools/check.py", + "Content-Type", +) + +INDEX_MUST_LINK = ( + "ARCHITECTURE.md", + "PRODUCT.md", + "DESIGN.md", + "CONVENTIONS.md", + "QUALITY_SCORE.md", + "SECURITY.md", + "RELIABILITY.md", +) + + +def check_docs_structure() -> list[Violation]: + violations: list[Violation] = [] + + for relative in REQUIRED_DOCS: + path = ROOT / relative + if not path.is_file(): + violations.append( + Violation( + rule="docs.required_file", + path=relative, + message="Required knowledge-base file is missing.", + remediation=( + f"Create {relative} with the topic implied by its path, " + "then add a row in docs/INDEX.md. See docs/INDEX.md and " + "AGENTS.md for the catalog pattern." + ), + ) + ) + + agents = ROOT / "AGENTS.md" + if agents.is_file(): + text = agents.read_text(encoding="utf-8") + line_count = len(text.splitlines()) + if line_count > AGENTS_MAX_LINES: + violations.append( + Violation( + rule="docs.agents_length", + path="AGENTS.md", + message=f"AGENTS.md has {line_count} lines; limit is {AGENTS_MAX_LINES}.", + remediation=( + "Move detailed guidance into docs/ and leave AGENTS.md as a " + "map with pointers. Update docs/INDEX.md if you add a file." + ), + ) + ) + for needle in AGENTS_MUST_MENTION: + if needle not in text: + violations.append( + Violation( + rule="docs.agents_pointer", + path="AGENTS.md", + message=f"AGENTS.md does not mention {needle}.", + remediation=( + f"Add a pointer to {needle} in the Start here table or " + "Invariants section so agents can find it without a full-repo search." + ), + ) + ) + + index = ROOT / "docs" / "INDEX.md" + if index.is_file(): + index_text = index.read_text(encoding="utf-8") + for needle in INDEX_MUST_LINK: + if needle not in index_text: + violations.append( + Violation( + rule="docs.index_link", + path="docs/INDEX.md", + message=f"docs/INDEX.md does not reference {needle}.", + remediation=(f"Add a catalog row in docs/INDEX.md that links to {needle}."), + ) + ) + + return violations diff --git a/tools/linters/taste.py b/tools/linters/taste.py new file mode 100644 index 0000000..f9eeaff --- /dev/null +++ b/tools/linters/taste.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import re + +from .common import ROOT, Violation + +MAX_LINES = { + "bild/__init__.py": 40, + "bild/errors.py": 40, + "bild/client.py": 800, +} + +SKIP_DIR_NAMES = { + ".git", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + "bild_python.egg-info", + "dist", + "build", +} + +JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") +ASSIGNED_KEY_RE = re.compile( + r"BILD_API_KEY\s*=\s*['\"](?!YOUR_JWT_TOKEN)(?!your_token)(?!$)[^'\"]+['\"]", + re.IGNORECASE, +) + + +def check_taste() -> list[Violation]: + violations: list[Violation] = [] + + for relative, limit in MAX_LINES.items(): + path = ROOT / relative + if not path.is_file(): + continue + lines = path.read_text(encoding="utf-8").splitlines() + if len(lines) > limit: + violations.append( + Violation( + rule="taste.file_size", + path=relative, + message=f"{relative} has {len(lines)} lines; limit is {limit}.", + remediation=( + f"Split {relative} or raise the limit in tools/linters/taste.py " + "and document why in docs/exec-plans/tech-debt-tracker.md. " + "For client.py, prefer the bild/resources/ split (TD-1)." + ), + ) + ) + + client = ROOT / "bild" / "client.py" + if client.is_file(): + src = client.read_text(encoding="utf-8") + if re.search(r"^\s*print\(", src, re.MULTILINE): + violations.append( + Violation( + rule="taste.no_print", + path="bild/client.py", + message="Library code uses print().", + remediation=( + "Remove print() from bild/. Raise exceptions or return data to the caller." + ), + ) + ) + if "time.sleep" in src: + violations.append( + Violation( + rule="taste.no_sleep", + path="bild/client.py", + message="Library code uses time.sleep.", + remediation=( + "Do not sleep inside the client. Let callers retry with their own backoff." + ), + ) + ) + + for path in ROOT.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIR_NAMES for part in path.parts): + continue + if path.suffix not in {".py", ".md", ".yml", ".yaml", ".toml", ".txt", ".env"}: + continue + if path.name == ".env" or (path.name.startswith(".env.") and path.name != ".env.example"): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + rel = path.resolve().relative_to(ROOT).as_posix() + if JWT_RE.search(text): + violations.append( + Violation( + rule="taste.jwt_literal", + path=rel, + message="File looks like it contains a real JWT.", + remediation=( + "Remove the token. Use YOUR_JWT_TOKEN in examples and test-token in tests." + ), + ) + ) + if path.name != ".env.example" and ASSIGNED_KEY_RE.search(text): + violations.append( + Violation( + rule="taste.api_key_literal", + path=rel, + message=( + "File assigns a literal BILD_API_KEY that is not the example placeholder." + ), + remediation=( + "Delete the literal. Read the token from the environment " + "in tests and examples." + ), + ) + ) + + live = ROOT / "tests" / "test_live_api.py" + if live.is_file(): + src = live.read_text(encoding="utf-8") + forbidden = ( + ".invite(", + ".remove(", + ".delete(", + ".create(", + ".create_live(", + ".create_static(", + ".create_token(", + ".checkout(", + ".initiate(", + ".complete(", + ".release(", + ".cancel(", + ".update(", + ".close(", + ".move(", + ".export_universal(", + ) + for token in forbidden: + if token in src: + violations.append( + Violation( + rule="taste.live_readonly", + path="tests/test_live_api.py", + message=f"Live tests call a write/delete-style method: {token}.", + remediation=( + "Keep tests/test_live_api.py read-only (list/get/search). " + "Exercise writes only with a fake session in test_client_routes.py." + ), + ) + ) + + return violations