From d721a2bac50beb66c857c313a90d4aba255ab02d Mon Sep 17 00:00:00 2001 From: AJ Frio Date: Fri, 14 Aug 2026 15:09:32 -0600 Subject: [PATCH] Stop agents from guessing main/master for Bild default branches. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Cursor --- AGENTS.md | 1 + AGENT_SETUP.md | 18 ++++- AGENT_USAGE.md | 134 ++++++++++++++++++++++++++++++ README.md | 27 ++++++- bild/client.py | 135 +++++++++++++++++++++---------- docs/INDEX.md | 1 + docs/design-docs/http-client.md | 14 +++- docs/product-specs/python-sdk.md | 13 ++- tests/test_client_routes.py | 27 +++++++ tests/test_docs.py | 14 ++++ tests/test_resolvers.py | 8 ++ tools/linters/docs_structure.py | 2 + 12 files changed, 342 insertions(+), 52 deletions(-) create mode 100644 AGENT_USAGE.md diff --git a/AGENTS.md b/AGENTS.md index f393938..90ff13e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ The package is not on PyPI yet. | Reliability | [docs/RELIABILITY.md](docs/RELIABILITY.md) | | Full catalog | [docs/INDEX.md](docs/INDEX.md) | | End-user agent setup | [AGENT_SETUP.md](AGENT_SETUP.md) | +| End-user agent usage | [AGENT_USAGE.md](AGENT_USAGE.md) | ## Layout diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index eba6e4f..3e15880 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -1,8 +1,10 @@ # Bild Python — agent setup playbook You are an agent setting up the **Bild Python SDK** for a human. This file is -the only document you need. Follow it in order. Do not declare success until -the handshake at the end returns a value. +the only document you need for **install and verify**. After a successful +handshake, open [AGENT_USAGE.md](AGENT_USAGE.md) for day-to-day calls. +Follow this file in order. Do not declare success until the handshake at +the end returns a value. Bild is a PDM/PLM product. This library talks to `https://api.getbild.com` using a JWT personal access token. The package is used from source (not on @@ -152,6 +154,18 @@ Do not declare setup complete without a successful `verify()` return value. ## After setup You may keep using the same `BildClient()` for the human's next request. +Open [AGENT_USAGE.md](AGENT_USAGE.md) before guessing IDs or routes. + +**Default branch:** do not look for a branch named `main` or `master`. +Bild names are often the project name. Call: + +```python +branch_id = client.resolve_branch_id(project_id) +``` + +Or pass `branch_id=None` on branch-scoped methods. The helper lists +`GET /projects/{id}/branches` and picks the flagged default, else the +first branch. Documented groups: `users`, `projects`, `project_users`, `branches`, `commits`, `files`, `uploads`, `checkouts`, `shared_links`, `metadata`, diff --git a/AGENT_USAGE.md b/AGENT_USAGE.md new file mode 100644 index 0000000..7dc528b --- /dev/null +++ b/AGENT_USAGE.md @@ -0,0 +1,134 @@ +# Bild Python — agent usage + +You are an agent using the **Bild Python SDK** after setup. Host is always +`https://api.getbild.com`. Auth is always a Bearer JWT (`BILD_API_KEY` / +`token=`). Do not invent a host, auth scheme, or endpoint. + +If setup is not done, follow [AGENT_SETUP.md](AGENT_SETUP.md) first. Call +`BildClient.verify()` before other work. + +## Start every task this way + +```python +from bild import BildClient + +client = BildClient() # reads BILD_API_KEY from .env +projects = client.api.projects.list() +``` + +Pick IDs from API responses. They are UUID v4 values. Do not invent IDs. +List payloads are often `{ "data": [ ... ], "message": "success" }`. Read +`id` (or `projectID` / `fileID` / `branchID` when that is what the item uses). + +## Default branch — do not guess `main` or `master` + +Bild branches are often named after the project. Names like `main` and +`master` frequently do not exist. Never ask the human for a branch id +until `resolve_branch_id` has failed. + +```python +branch_id = client.resolve_branch_id(project_id) +``` + +That lists `GET /projects/{project_id}/branches` and picks, in order: + +1. A branch flagged `isMain`, `isDefault`, `isDefaultBranch`, or `default` +2. A branch named `main` or `master` (case-insensitive) +3. The first branch in the list + +You can also pass `branch_id=None` into any branch-scoped method. The SDK +resolves it the same way. + +```python +files = client.api.files.list_versions(project_id, None, file_id) +boms = client.api.boms.list(project_id) # None is the default +meta = client.api.metadata.get(project_id, None, file_id) +``` + +These list calls are different: omitting `branch_id` hits a **project-level** +route (the API's own default-branch / account-wide list), not a resolved +branch path: + +- `client.api.files.list(project_id)` → `GET /projects/{id}/files` +- `client.api.commits.list(project_id)` → `GET /projects/{id}/commits` +- `client.api.shared_links.list(project_id)` → `GET /projects/{id}/sharedLinks` +- `client.api.revisions.list(project_id)` → `GET /projects/{id}/revisions` +- `client.api.feedback.list(project_id)` → `GET /projects/{id}/feedbackItems` + +To force a specific branch on those, pass the id from `resolve_branch_id` +or `client.api.branches.list(project_id)`. + +## Latest file version + +```python +version_id = client.resolve_file_version(project_id, branch_id, file_id) +# or +latest = client.api.files.get_latest(project_id, None, file_id) +``` + +`files.export_universal(..., branch_id=None, file_version=None)` resolves +both the default branch and the latest version. + +## Resource groups + +Use `client.api.`. Do not invent methods. + +| Group | Typical calls | +| --- | --- | +| `users` | `list` | +| `projects` | `list` | +| `project_users` | `list` | +| `branches` | `list` | +| `commits` | `list`, `get` | +| `files` | `list`, `list_versions`, `get_latest`, `get_version`, `export_universal` | +| `shared_links` | `list` | +| `metadata` | `list_fields`, `get` | +| `feedback` | `list`, `get` | +| `packages` | `list`, `get` | +| `revisions` | `list`, `get`, `get_closure` | +| `approvals` | `list`, `get` | +| `boms` | `list`, `get` | +| `search` | `files("query")` — this is `PUT /search` | +| `webhooks` | `list`, `get` | + +Writes (invite, upload, checkout, move, delete, release, create shared +link, create webhook) only if the human explicitly asked. Prefer +list/get/search. + +## Errors + +| Exception | When | +| --- | --- | +| `ValueError` | Missing token, or no branch/version could be resolved | +| `BildAuthError` | HTTP 401/403 — ask for a new JWT; do not retry blindly | +| `BildAPIError` | Other HTTP failures — show `status_code` and `payload` | + +```python +from bild import BildAPIError, BildAuthError +``` + +## Response envelopes + +- Success items usually live under `data` (sometimes `items`). +- Large lists may return `{ "s3Url": "..." }` instead of an inline array. + GET that URL yourself. Do **not** send the Bild `Authorization` header + to S3. +- Some writes are async. A 200 means accepted; the change may finish later. + +## Escape hatch + +Unwrapped path only. Do not set `Content-Type` on a shared session if you +drop to raw `requests` — Bild treats that header as "this request has a +JSON body" and GET/DELETE then 500. + +```python +raw = client.get("projects") +``` + +## Do not + +- Guess branch names (`main`, `master`, …). +- Invent hosts, tokens, or endpoints. +- Commit `.env` or print the JWT. +- Write or delete unless the human asked. +- Follow `s3Url` with the Bild bearer token. diff --git a/README.md b/README.md index 020c6b1..c1a6ea8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,12 @@ library, call `BildClient.verify()`, and show you the return value. https://github.com/AJFrio/Bild-Python/blob/main/AGENT_SETUP.md ``` -Or hand it the file in this repo: [AGENT_SETUP.md](AGENT_SETUP.md). +After setup, hand it [AGENT_USAGE.md](AGENT_USAGE.md) (or +`https://github.com/AJFrio/Bild-Python/blob/main/AGENT_USAGE.md`) so it +resolves the default branch instead of guessing `main` / `master`. + +Or hand it the files in this repo: [AGENT_SETUP.md](AGENT_SETUP.md) and +[AGENT_USAGE.md](AGENT_USAGE.md). ## 1) Clone and set up (manual) @@ -100,9 +105,25 @@ client.api.users.invite( ) ``` +### Default branch + +Bild branches are often **not** named `main` or `master`. Do not guess +those names. Resolve the id, or pass `branch_id=None` on branch-scoped +methods: + +```python +branch_id = client.resolve_branch_id("project-id") +branches = client.api.branches.list("project-id") +``` + +`resolve_branch_id` lists the project's branches and prefers a flagged +default (`isMain` / `isDefault` / `isDefaultBranch`), then a main/master +name, then the first branch. + ### List files in a project ```python +# Official default-branch file list (no branch id needed) files = client.api.files.list("project-id") print(files) ``` @@ -112,7 +133,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, # resolve_branch_id — not a guessed "main" name file_id="file-id", output_format="stl", ) @@ -127,7 +148,7 @@ print(links) new_link = client.api.shared_links.create_live( "project-id", - "branch-id", + None, # default branch name="Review Link", file_ids=["file-id"], ) diff --git a/bild/client.py b/bild/client.py index a62a6a4..35bf603 100644 --- a/bild/client.py +++ b/bild/client.py @@ -162,6 +162,13 @@ def delete(self, path: str, *, params=None): return self.request("DELETE", path, params=params) def resolve_branch_id(self, project_id: str, branch_id: str | None = None) -> str: + """Return an explicit branch id, else the project's default branch. + + Bild branches are often not named ``main`` or ``master``. Do not guess + those names. This helper lists ``GET /projects/{id}/branches`` and + prefers ``isMain`` / ``isDefault`` / ``isDefaultBranch`` / ``default``, + then a main/master name, then the first branch. + """ if branch_id: return branch_id branches_payload = self.get(f"projects/{project_id}/branches") @@ -172,19 +179,24 @@ def resolve_branch_id(self, project_id: str, branch_id: str | None = None) -> st for b in branches: if not isinstance(b, dict): continue - if b.get("isMain") or b.get("isDefault") or b.get("default"): - value = b.get("id") or b.get("branchId") + if ( + b.get("isMain") + or b.get("isDefault") + or b.get("isDefaultBranch") + or b.get("default") + ): + value = _branch_record_id(b) if value: - return str(value) + return value for b in branches: if isinstance(b, dict) and str(b.get("name", "")).lower() in ("main", "master"): - value = b.get("id") or b.get("branchId") + value = _branch_record_id(b) if value: - return str(value) + return value first = branches[0] if isinstance(first, dict): - value = first.get("id") or first.get("branchId") + value = _branch_record_id(first) if value: return value raise ValueError("Could not determine default branch_id") @@ -211,6 +223,9 @@ class _BaseAPI: def __init__(self, client: BildClient): self.client = client + def _resolve_branch(self, project_id: str, branch_id: str | None) -> str: + return self.client.resolve_branch_id(project_id, branch_id) + class UsersAPI(_BaseAPI): def list(self): @@ -306,7 +321,8 @@ def list(self, project_id: str, branch_id: str | None = None): return self.client.get(f"projects/{project_id}/branches/{branch_id}/commits") return self.client.get(f"projects/{project_id}/commits") - def get(self, project_id: str, branch_id: str, commit_id: str): + def get(self, project_id: str, branch_id: str | None, commit_id: str): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get(f"projects/{project_id}/branches/{branch_id}/commits/{commit_id}") @@ -320,35 +336,35 @@ def list_released(self, from_time: str): return self.client.get("files/released", params={"fromTime": from_time}) 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) + branch_id = self._resolve_branch(project_id, branch_id) 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) + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get(f"projects/{project_id}/branches/{branch_id}/files/{file_id}/latest") 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) + branch_id = self._resolve_branch(project_id, branch_id) 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) + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}" ) def get_thumbnail(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) + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/thumbnail" ) def get_children(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) + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/children" ) @@ -363,7 +379,7 @@ def export_universal( file_version: str | None = None, file_config: str | None = None, ): - branch_id = self.client.resolve_branch_id(project_id, branch_id) + branch_id = self._resolve_branch(project_id, branch_id) file_version = self.client.resolve_file_version( project_id, branch_id, file_id, file_version ) @@ -378,19 +394,28 @@ def export_universal( ), ) - def export_universal_many(self, project_id: str, branch_id: str, payload: dict): + def export_universal_many(self, project_id: str, branch_id: str | None, payload: dict): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/files/exportUniversalFiles", json=payload, ) - def move(self, project_id: str, branch_id: str, file_ids: Sequence[str], new_parent_id: str): + def move( + self, + project_id: str, + branch_id: str | None, + file_ids: Sequence[str], + new_parent_id: str, + ): + branch_id = self._resolve_branch(project_id, branch_id) 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: Sequence[str]): + def delete(self, project_id: str, branch_id: str | None, file_ids: Sequence[str]): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/delete", json={"fileIDs": file_ids}, @@ -398,7 +423,8 @@ def delete(self, project_id: str, branch_id: str, file_ids: Sequence[str]): class UploadsAPI(_BaseAPI): - def initiate(self, project_id: str, branch_id: str, files: Sequence[dict]): + def initiate(self, project_id: str, branch_id: str | None, files: Sequence[dict]): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/initiateUpload", json={"files": files}, @@ -407,11 +433,12 @@ def initiate(self, project_id: str, branch_id: str, files: Sequence[dict]): def complete( self, project_id: str, - branch_id: str, + branch_id: str | None, files: Sequence[dict], *, keep_checked_out: bool | None = None, ): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/fileActions/completeUpload", json=_omit_none({"files": files, "keepFilesCheckedOut": keep_checked_out}), @@ -419,19 +446,22 @@ def complete( class CheckoutsAPI(_BaseAPI): - def checkout(self, project_id: str, branch_id: str, file_ids: Sequence[str]): + def checkout(self, project_id: str, branch_id: str | None, file_ids: Sequence[str]): + branch_id = self._resolve_branch(project_id, branch_id) 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: Sequence[str]): + def cancel(self, project_id: str, branch_id: str | None, file_ids: Sequence[str]): + branch_id = self._resolve_branch(project_id, branch_id) 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: Sequence[dict]): + def initiate_checkin(self, project_id: str, branch_id: str | None, files: Sequence[dict]): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/fileActions/initiateCheckin", json={"files": files}, @@ -440,11 +470,12 @@ def initiate_checkin(self, project_id: str, branch_id: str, files: Sequence[dict def complete_checkin( self, project_id: str, - branch_id: str, + branch_id: str | None, files: Sequence[dict], *, message: str | None = None, ): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/fileActions/completeCheckin", json=_omit_none({"files": files, "message": message}), @@ -462,13 +493,14 @@ def list(self, project_id: str | None = None, branch_id: str | None = None): def create_live( self, project_id: str, - branch_id: str, + branch_id: str | None, name: str, file_ids: Sequence[str], *, types: Sequence[str] | None = None, config_map: dict | None = None, ): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/files/sharedLink", json=_omit_none( @@ -484,22 +516,25 @@ def create_live( def create_static( self, project_id: str, - branch_id: str, + branch_id: str | None, file_id: str, version_id: str, payload: dict | None = None, ): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/fileVersion/{version_id}/sharedLink", json=payload or {}, ) - def refresh(self, project_id: str, branch_id: str, link_id: str): + def refresh(self, project_id: str, branch_id: str | None, link_id: str): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/sharedLinks/{link_id}/refresh" ) - def delete(self, project_id: str, branch_id: str, link_ids: Sequence[str]): + def delete(self, project_id: str, branch_id: str | None, link_ids: Sequence[str]): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/sharedLinks/delete", json={"sharedLinkIDs": link_ids}, @@ -510,17 +545,22 @@ class MetadataAPI(_BaseAPI): def list_fields(self): return self.client.get("metadataFields") - def get(self, project_id: str, branch_id: str, file_id: str): + def get(self, project_id: str, branch_id: str | None, file_id: str): + branch_id = self._resolve_branch(project_id, branch_id) 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): + def get_for_version( + self, project_id: str, branch_id: str | None, file_id: str, version_id: str + ): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/versions/{version_id}/metadata" ) - def update(self, project_id: str, branch_id: str, payload: dict): + def update(self, project_id: str, branch_id: str | None, payload: dict): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/files/updateMetadata", json=payload, @@ -530,7 +570,7 @@ def update(self, project_id: str, branch_id: str, payload: dict): class FeedbackAPI(_BaseAPI): def list(self, project_id: str, *, branch_id: str | None = None, file_id: str | None = None): if file_id: - branch_id = self.client.resolve_branch_id(project_id, branch_id) + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/feedbackItems" ) @@ -584,10 +624,9 @@ def list( file_id: str | None = None, ): if file_id: - if not project_id or not branch_id: - raise ValueError( - "project_id and branch_id are required when listing file revisions" - ) + if not project_id: + raise ValueError("project_id is required when listing file revisions") + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions" ) @@ -599,23 +638,27 @@ def list( return self.client.get(f"projects/{project_id}/revisions") return self.client.get("revisions") - def get(self, project_id: str, branch_id: str, file_id: str, revision_id: str): + def get(self, project_id: str, branch_id: str | None, file_id: str, revision_id: str): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/revisions/{revision_id}" ) - def get_closure(self, project_id: str, branch_id: str, file_id: str): + def get_closure(self, project_id: str, branch_id: str | None, file_id: str): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get( f"projects/{project_id}/branches/{branch_id}/files/{file_id}/closure" ) - def release(self, project_id: str, branch_id: str, revisions: Sequence[dict]): + def release(self, project_id: str, branch_id: str | None, revisions: Sequence[dict]): + branch_id = self._resolve_branch(project_id, branch_id) 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: Sequence[str]): + def cancel(self, project_id: str, branch_id: str | None, revision_ids: Sequence[str]): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.put( f"projects/{project_id}/branches/{branch_id}/revisions/cancel", json={"revisionIDs": revision_ids}, @@ -639,13 +682,16 @@ def close(self, project_id: str, approval_id: str, status: str): class BOMsAPI(_BaseAPI): - def list(self, project_id: str, branch_id: str): + def list(self, project_id: str, branch_id: str | None = None): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get(f"projects/{project_id}/branches/{branch_id}/boms") - def get(self, project_id: str, branch_id: str, bom_id: str): + def get(self, project_id: str, branch_id: str | None, bom_id: str): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.get(f"projects/{project_id}/branches/{branch_id}/boms/{bom_id}") - def download(self, project_id: str, branch_id: str, bom_id: str, payload: dict): + def download(self, project_id: str, branch_id: str | None, bom_id: str, payload: dict): + branch_id = self._resolve_branch(project_id, branch_id) return self.client.post( f"projects/{project_id}/branches/{branch_id}/boms/{bom_id}/download", json=payload, @@ -691,6 +737,11 @@ def _omit_none(data: dict) -> dict: return {key: value for key, value in data.items() if value is not None} +def _branch_record_id(branch: dict) -> str | None: + value = branch.get("id") or branch.get("branchId") or branch.get("branchID") + return str(value) if value else None + + def _pick_from_response(payload: Any, *keys: str): if isinstance(payload, dict): for k in keys: diff --git a/docs/INDEX.md b/docs/INDEX.md index 6e35fa2..14d7a06 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -6,6 +6,7 @@ | --- | --- | --- | | [../ARCHITECTURE.md](../ARCHITECTURE.md) | current | Layers, HTTP contract, test map | | [../AGENT_SETUP.md](../AGENT_SETUP.md) | current | Playbook to hand another agent for end-user install + `verify()` | +| [../AGENT_USAGE.md](../AGENT_USAGE.md) | current | Playbook for agents calling the SDK after setup (branches, IDs, writes) | | [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 | diff --git a/docs/design-docs/http-client.md b/docs/design-docs/http-client.md index 9f7ab7f..110c295 100644 --- a/docs/design-docs/http-client.md +++ b/docs/design-docs/http-client.md @@ -24,13 +24,19 @@ stripped of a leading slash. The host 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. + the branch marked `isMain` / `isDefault` / `isDefaultBranch` / `default`, + else a branch named main/master, else the first branch. IDs are read from + `id`, `branchId`, or `branchID`. - `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. +Branch-scoped resource methods accept `branch_id: str | None` and call +`resolve_branch_id`. Do not invent a name lookup (`main` / `master`). + +These list methods keep a project-level route when `branch_id` is omitted +(`files.list`, `commits.list`, `shared_links.list`, `revisions.list`, +`feedback.list`). Pass an explicit id (or the result of `resolve_branch_id`) +to hit the branch path. ## Response helper diff --git a/docs/product-specs/python-sdk.md b/docs/product-specs/python-sdk.md index e2a4caf..1a2e79f 100644 --- a/docs/product-specs/python-sdk.md +++ b/docs/product-specs/python-sdk.md @@ -30,11 +30,22 @@ Must stay aligned with `BildClient.api` and the README "API groups" list: ## Convenience (allowed) - Load `.env` without overriding existing env. -- Auto-resolve default branch and latest file version where documented. +- `resolve_branch_id(project_id, branch_id=None)` — do not guess `main` / + `master`. Lists project branches and prefers a flagged default, then + those names, then the first branch. Also reads `id` / `branchId` / + `branchID`. +- Branch-scoped resource methods accept `branch_id=None` and call that + helper. Project-level list routes (`files.list`, `commits.list`, + `shared_links.list`, `revisions.list`, `feedback.list`) still omit the + branch path when `branch_id` is omitted. +- `resolve_file_version` / `files.export_universal` auto-resolve latest + version when `file_version` is omitted. - Omit `None` optional JSON fields. - `BildClient.verify()` — read-only handshake (`users.list` + `projects.list`) used by [AGENT_SETUP.md](../../AGENT_SETUP.md). Return shape is `{ok, function, base_url, users, projects}`. +- Consumer-agent playbooks: [AGENT_SETUP.md](../../AGENT_SETUP.md) then + [AGENT_USAGE.md](../../AGENT_USAGE.md). ## Convenience (not allowed without a new spec) diff --git a/tests/test_client_routes.py b/tests/test_client_routes.py index 3e80d2f..9d5e16c 100644 --- a/tests/test_client_routes.py +++ b/tests/test_client_routes.py @@ -317,6 +317,33 @@ def test_revisions_list_requires_ids(self): self.client.api.revisions.list(file_id="f1") with self.assertRaises(ValueError): self.client.api.revisions.list(branch_id="b1") + self.client.api.revisions.list("p1", None, "f1") + self.assert_call("GET", "/projects/p1/branches/branch-main/files/f1/revisions") + + def test_branch_id_none_resolves(self): + c = self.client + c.api.commits.get("p1", None, "c1") + self.assert_call("GET", "/projects/p1/branches/branch-main/commits/c1") + c.api.files.move("p1", None, ["f1"], "parent-1") + self.assert_call( + "PUT", + "/fileActions/move", + json={"moveFiles": ["f1"], "newParentID": "parent-1"}, + ) + c.api.uploads.initiate("p1", None, [{"name": "x"}]) + self.assert_call("PUT", "/fileActions/initiateUpload", json={"files": [{"name": "x"}]}) + c.api.checkouts.checkout("p1", None, ["f1"]) + self.assert_call("PUT", "/fileActions/checkout", json={"fileIDs": ["f1"]}) + c.api.shared_links.create_live("p1", None, "Review Link", ["f1"]) + self.assert_call( + "POST", "/files/sharedLink", json={"name": "Review Link", "fileIDs": ["f1"]} + ) + c.api.metadata.get("p1", None, "f1") + self.assert_call("GET", "/files/f1/metadata") + c.api.revisions.get_closure("p1", None, "f1") + self.assert_call("GET", "/files/f1/closure") + c.api.boms.list("p1") + self.assert_call("GET", "/projects/p1/branches/branch-main/boms") def test_approvals(self): c = self.client diff --git a/tests/test_docs.py b/tests/test_docs.py index 74c395f..4f6141d 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -25,6 +25,20 @@ def test_agent_setup_does_not_invite_alternate_host_or_auth(self): self.assertIn("jwt", lowered) self.assertIn("do not invent", lowered) self.assertIn("ask about", lowered) + self.assertIn("resolve_branch_id", text) + self.assertIn("AGENT_USAGE.md", text) + for banned in ("custom host", "non-production", "their-host"): + self.assertNotIn(banned, lowered) + + def test_agent_usage_covers_branch_resolution(self): + text = (ROOT / "AGENT_USAGE.md").read_text(encoding="utf-8") + lowered = text.lower() + self.assertIn("https://api.getbild.com", text) + self.assertIn("resolve_branch_id", text) + self.assertIn("resolve_file_version", text) + self.assertIn("do not invent", lowered) + self.assertIn("main", lowered) + self.assertIn("master", lowered) for banned in ("custom host", "non-production", "their-host"): self.assertNotIn(banned, lowered) diff --git a/tests/test_resolvers.py b/tests/test_resolvers.py index e0f2084..1a73ccd 100644 --- a/tests/test_resolvers.py +++ b/tests/test_resolvers.py @@ -46,6 +46,14 @@ def test_prefers_default_flag(self): client = self._client({"items": [{"id": "flag-id", "default": True}]}) self.assertEqual(client.resolve_branch_id("p1"), "flag-id") + def test_prefers_is_default_branch(self): + client = self._client({"data": [{"id": "def-branch", "isDefaultBranch": True}]}) + self.assertEqual(client.resolve_branch_id("p1"), "def-branch") + + def test_reads_branch_id_capital_id(self): + client = self._client({"data": [{"branchID": "capital-id", "name": "Production"}]}) + self.assertEqual(client.resolve_branch_id("p1"), "capital-id") + def test_falls_back_to_name_main(self): client = self._client({"data": [{"id": "named", "name": "Main"}]}) self.assertEqual(client.resolve_branch_id("p1"), "named") diff --git a/tools/linters/docs_structure.py b/tools/linters/docs_structure.py index 6826dd9..f195db3 100644 --- a/tools/linters/docs_structure.py +++ b/tools/linters/docs_structure.py @@ -7,6 +7,7 @@ REQUIRED_DOCS = ( "AGENTS.md", "AGENT_SETUP.md", + "AGENT_USAGE.md", "ARCHITECTURE.md", "docs/INDEX.md", "docs/PRODUCT.md", @@ -35,6 +36,7 @@ "docs/CONVENTIONS.md", "docs/INDEX.md", "AGENT_SETUP.md", + "AGENT_USAGE.md", "tools/check.py", "Content-Type", )