feat: initial Python client for the ISMS API#2
Conversation
The official Python client (isms.sh), matching the isms CLI / Go client surface.
- IsmsClient + from_env (env vars / ISMS_ENV file loader compatible with the Go
CLI's loadEnvFile; documented precedence).
- Register namespaces: suppliers, risks, incidents, corrective actions, tasks,
cross-entity references, documents, and identity (whoami → /me).
- list() unwraps the {"data":[...]} envelope the API returns.
- Typed exceptions (auth / not-found / validation / http) + py.typed marker.
- Endpoints verified against the live /api/v1 routes; tests use responses.
Closes #1
- GitHub Actions (tests.yml): ruff lint + pytest on Python 3.10–3.13, public-repo security model (pull_request only, read-only token, no secrets). - Governance mirrored from the isms repo: CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, and issue templates (bug / feature / docs + config). - scripts/apply-branch-protection.sh: version-controlled enforcement of the master policy (PR + review, required CI, signed commits, linear history) via the GH API. - gitignore temp/ (local scratch / torun scripts).
unidoc-alip
left a comment
There was a problem hiding this comment.
Solid first version of the Python client. The endpoint correctness claims in the PR description were cross-checked directly against the ISMS Go server routes and handlers (/me, /documents/all + /body + /search, the list envelope shape, X-Organization-UUID, and every resource's CRUD and status routes), and all of them check out. A security pass over the HTTP client, env loader, CI workflow, and branch protection script found no exploitable issues.
Two things worth fixing before or shortly after merge:
1. [should-fix] ReferenceResource.list() — src/isms/client.py:291
Takes entity_type and entity_id as optional keyword arguments, but the server's handleListReferences handler requires both query params and returns 400 Bad Request if either is missing. Confirmed by reading the handler directly (internal/isms/api/api_references.go, lines 36-41). As written, client.references.list() with no arguments will always raise IsmsValidationError.
Suggested fix — make both parameters required instead of optional:
def list(self, entity_type: str, entity_id: str) -> list[dict]:
return _as_list(
self._http.get(
"/v1/references",
params={"type": entity_type, "id": entity_id},
)
)Worth adding a test that calling list() with no arguments fails fast (TypeError) instead of round-tripping to the server to discover the 400.
2. [nit] _resolve_base_url — src/isms/client.py:46
Decides whether a URL already has an /api suffix using a raw substring check: "/api/" in url + "/". Reproduced the bug directly:
_resolve_base_url("https://demo.isms.sh/api/docs")
# -> "https://demo.isms.sh/api/docs" (unchanged, should have /api appended)The substring /api/ happens to appear in the path even though it's not the actual API mount point, so a deployment reverse-proxied under a path containing /api/ would silently get the wrong base URL. This is a minor, low-likelihood edge case given the documented usage is always a bare subdomain URL, but it's cheap to fix by checking only the last path segment instead of doing a substring search:
def _resolve_base_url(url: str) -> str:
url = url.rstrip("/")
if url.rsplit("/", 1)[-1] == "api":
return url
return url + "/api"Verified this produces the same result for the documented cases (https://demo.isms.sh becomes .../api, and https://demo.isms.sh/api is left unchanged) while correctly handling the /api/docs-style edge case.
Neither issue blocks merge; the first is a real bug a user will hit on first use of the unfiltered call, the second is a defensive fix for an edge case that is unlikely in practice.
Addresses Alip's review on PR #2: - ReferenceResource.list() now takes entity_type and entity_id as required arguments. The server's handleListReferences returns 400 without both, so a bare list() call would always fail against the server — now it fails fast with TypeError at the call site instead of round-tripping. - _resolve_base_url matches only the final path segment (== 'api') instead of a raw '/api/' substring, so a deployment proxied under a path like /api/docs no longer silently skips appending the API mount. Tests: references.list() with no args raises TypeError; it sends both params when given; _resolve_base_url covers the documented cases and the /api/docs edge.
|
Thanks @unidoc-alip — both done. 1. references.list() required args — made ruff + pytest green (24). |
First version of the official Python client + CI + repo policy.
What
IsmsClientover the/api/v1REST surface — register namespaces (suppliers, risks, incidents, corrective actions, tasks, references, documents, identity), typed exceptions, and a Go-CLI-compatible env-file loader.from_env()for zero-boilerplate setup.Endpoint correctness
Every route was checked against the server's actual handlers before opening this, and four mismatches were corrected:
GET /me(there is no/whoami).GET /documents/all(tree) +GET /documents/:id/body; there is noGET /documentsor/documents/:id.{"data":[...], "total":N}envelope, so.list()yields a list, not the envelope dict.X-Organization-UUID(a UUID); a subdomain URL already scopes the org server-side.CI + policy (mirrors the isms repo)
Tests
pytest(21) +ruffgreen, viaresponses-mocked HTTP; a regression test for each of the four fixes above.