From d9cba7cb4e17818c0acf28ec7fc318fa336fc056 Mon Sep 17 00:00:00 2001 From: Alfred Hall Date: Tue, 7 Jul 2026 12:19:35 +0000 Subject: [PATCH 1/3] feat: initial Python client for the ISMS API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 18 ++ README.md | 4 +- pyproject.toml | 65 +++++++ src/isms/__init__.py | 36 ++++ src/isms/_version.py | 1 + src/isms/client.py | 416 +++++++++++++++++++++++++++++++++++++++++ src/isms/env.py | 46 +++++ src/isms/exceptions.py | 29 +++ src/isms/py.typed | 0 tests/__init__.py | 0 tests/test_client.py | 177 ++++++++++++++++++ tests/test_env.py | 47 +++++ 12 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/isms/__init__.py create mode 100644 src/isms/_version.py create mode 100644 src/isms/client.py create mode 100644 src/isms/env.py create mode 100644 src/isms/exceptions.py create mode 100644 src/isms/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_client.py create mode 100644 tests/test_env.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3dcff12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.eggs/ +.venv/ +venv/ +.env +.envrc +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +*.log diff --git a/README.md b/README.md index 39b4895..d11a36d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ Point the client at your ISMS deployment and give it an API token: ```sh export ISMS_API_URL=https://your-org.isms.sh export ISMS_API_TOKEN= -export ISMS_ORGANIZATION= # only if your token spans multiple orgs +# Only needed for a multi-org token on a bare domain; a subdomain URL +# (https://your-org.isms.sh) already selects the org server-side. +export ISMS_ORGANIZATION_UUID= ``` Create the token from an admin session with `isms server api-key create` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1365b4f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "isms" +version = "0.1.0" +description = "Official Python client for the ISMS platform (isms.sh)" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +authors = [ + { name = "ISMS", email = "hello@isms.sh" }, +] +keywords = ["isms", "iso-27001", "nis2", "information-security", "compliance", "sdk"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Security", +] +dependencies = [ + "requests>=2.31", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "responses>=0.25", + "ruff>=0.5", +] + +[project.urls] +Homepage = "https://isms.sh" +Documentation = "https://isms.sh/docs" +Repository = "https://github.com/unidoc/isms-python" +Issues = "https://github.com/unidoc/isms-python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/isms"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/isms", + "tests", + "README.md", + "LICENSE", + "pyproject.toml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py310" diff --git a/src/isms/__init__.py b/src/isms/__init__.py new file mode 100644 index 0000000..2ae64ed --- /dev/null +++ b/src/isms/__init__.py @@ -0,0 +1,36 @@ +"""Official Python client for the ISMS platform (isms.sh). + +Quickstart: + + from isms import IsmsClient + + client = IsmsClient.from_env() + supplier = client.suppliers.add({ + "name": "MaintMaster", + "supplier_type": "saas", + "criticality": "high", + "data_access": True, + }) +""" + +from isms._version import __version__ +from isms.client import IsmsClient +from isms.env import load_env_file +from isms.exceptions import ( + IsmsAuthError, + IsmsError, + IsmsHTTPError, + IsmsNotFoundError, + IsmsValidationError, +) + +__all__ = [ + "IsmsClient", + "IsmsError", + "IsmsAuthError", + "IsmsHTTPError", + "IsmsNotFoundError", + "IsmsValidationError", + "load_env_file", + "__version__", +] diff --git a/src/isms/_version.py b/src/isms/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/isms/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/isms/client.py b/src/isms/client.py new file mode 100644 index 0000000..ee59de1 --- /dev/null +++ b/src/isms/client.py @@ -0,0 +1,416 @@ +"""HTTP client for the ISMS platform. + +The client speaks the ``/v1/*`` REST API of an ISMS deployment. Point it at +your instance and give it an API token: + + from isms import IsmsClient + client = IsmsClient.from_env() + + supplier = client.suppliers.add({ + "name": "MaintMaster", + "supplier_type": "saas", + "criticality": "high", + "data_access": True, + }) + +Configuration precedence, high to low: + + 1. Explicit constructor arguments. + 2. Environment variables ``ISMS_API_URL``/``ISMS_BASE_URL`` and + ``ISMS_API_TOKEN``/``ISMS_API_KEY``. + 3. Optional env file pointed at by ``ISMS_ENV``. +""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + +from isms._version import __version__ +from isms.env import load_env_file +from isms.exceptions import ( + IsmsAuthError, + IsmsHTTPError, + IsmsNotFoundError, + IsmsValidationError, +) + +_DEFAULT_TIMEOUT = 30.0 +_USER_AGENT = f"isms-python/{__version__}" + + +def _resolve_base_url(url: str) -> str: + url = url.rstrip("/") + if url.endswith("/api") or "/api/" in url + "/": + return url + return url + "/api" + + +def _as_list(payload: Any) -> list[dict]: + """Normalize a list response to a plain list. + + The ISMS list endpoints return a ``{"data": [...], "total": N}`` envelope; + a few return a bare array. Both collapse to the list of items here, so a + caller always gets a ``list`` from ``.list()``. + """ + if payload is None: + return [] + if isinstance(payload, dict): + data = payload.get("data") + return data if isinstance(data, list) else [] + if isinstance(payload, list): + return payload + return [] + + +class _HTTP: + """Thin wrapper around ``requests.Session`` with ISMS conventions.""" + + def __init__( + self, + base_url: str, + token: str, + *, + organization_uuid: str | None = None, + cf_client_id: str | None = None, + cf_client_secret: str | None = None, + timeout: float = _DEFAULT_TIMEOUT, + session: requests.Session | None = None, + ) -> None: + self.base_url = _resolve_base_url(base_url) + self.timeout = timeout + self._session = session or requests.Session() + self._session.headers.update( + { + "Authorization": f"Bearer {token}", + "User-Agent": _USER_AGENT, + "Accept": "application/json", + } + ) + # Org selection: a subdomain URL (https://your-org.isms.sh) already scopes + # the request server-side. This header is only needed for a multi-org token + # on a bare domain — the server resolves it via X-Organization-UUID. + if organization_uuid: + self._session.headers["X-Organization-UUID"] = organization_uuid + if cf_client_id and cf_client_secret: + self._session.headers["CF-Access-Client-Id"] = cf_client_id + self._session.headers["CF-Access-Client-Secret"] = cf_client_secret + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def _raise(self, resp: requests.Response) -> None: + try: + payload = resp.json() + message = payload.get("error") or payload.get("message") or resp.text + except ValueError: + payload = None + message = resp.text or resp.reason + + cls: type[IsmsHTTPError] + if resp.status_code in (401, 403): + cls = IsmsAuthError + elif resp.status_code == 404: + cls = IsmsNotFoundError + elif resp.status_code in (400, 422): + cls = IsmsValidationError + else: + cls = IsmsHTTPError + raise cls(resp.status_code, str(message), body=resp.text) + + def request( + self, + method: str, + path: str, + *, + json_body: Any | None = None, + params: dict[str, Any] | None = None, + ) -> Any: + resp = self._session.request( + method, + self._url(path), + json=json_body, + params=params, + timeout=self.timeout, + ) + if resp.status_code >= 400: + self._raise(resp) + if resp.status_code == 204 or not resp.content: + return None + try: + return resp.json() + except ValueError as exc: + raise IsmsHTTPError(resp.status_code, f"invalid JSON response: {exc}", body=resp.text) + + def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any: + return self.request("GET", path, params=params) + + def post(self, path: str, body: Any | None = None) -> Any: + return self.request("POST", path, json_body=body) + + def put(self, path: str, body: Any | None = None) -> Any: + return self.request("PUT", path, json_body=body) + + def delete(self, path: str) -> Any: + return self.request("DELETE", path) + + +class _Resource: + """Base class for entity resources.""" + + def __init__(self, http: _HTTP) -> None: + self._http = http + + +class SupplierResource(_Resource): + """Suppliers register (``/v1/suppliers``).""" + + def list(self) -> list[dict]: + return _as_list(self._http.get("/v1/suppliers")) + + def get(self, supplier_id: str) -> dict: + return self._http.get(f"/v1/suppliers/{supplier_id}") + + def add(self, data: dict) -> dict: + return self._http.post("/v1/suppliers", data) + + def update(self, supplier_id: str, data: dict) -> dict: + return self._http.put(f"/v1/suppliers/{supplier_id}", data) + + def delete(self, supplier_id: str) -> None: + self._http.delete(f"/v1/suppliers/{supplier_id}") + + +class RiskResource(_Resource): + """Risk register (``/v1/risks``).""" + + def list(self) -> list[dict]: + return _as_list(self._http.get("/v1/risks")) + + def get(self, risk_id: str) -> dict: + return self._http.get(f"/v1/risks/{risk_id}") + + def add(self, data: dict, references: list[dict] | None = None) -> dict: + body = dict(data) + if references: + body["references"] = references + return self._http.post("/v1/risks", body) + + def update(self, risk_id: str, data: dict) -> dict: + return self._http.put(f"/v1/risks/{risk_id}", data) + + def delete(self, risk_id: str) -> None: + self._http.delete(f"/v1/risks/{risk_id}") + + +class IncidentResource(_Resource): + """Incidents and events (``/v1/incidents``).""" + + def list(self) -> list[dict]: + return _as_list(self._http.get("/v1/incidents")) + + def get(self, incident_id: str) -> dict: + return self._http.get(f"/v1/incidents/{incident_id}") + + def create(self, data: dict, references: list[dict] | None = None) -> dict: + body = dict(data) + if references: + body["references"] = references + return self._http.post("/v1/incidents", body) + + def update(self, incident_id: str, data: dict) -> dict: + return self._http.put(f"/v1/incidents/{incident_id}", data) + + def delete(self, incident_id: str) -> None: + self._http.delete(f"/v1/incidents/{incident_id}") + + +class CorrectiveResource(_Resource): + """Corrective actions (``/v1/corrective-actions``).""" + + _base = "/v1/corrective-actions" + + def list(self) -> list[dict]: + return _as_list(self._http.get(self._base)) + + def get(self, ca_id: str | int) -> dict: + return self._http.get(f"{self._base}/{ca_id}") + + def create(self, data: dict, references: list[dict] | None = None) -> dict: + body = dict(data) + if references: + body["references"] = references + return self._http.post(self._base, body) + + def update(self, ca_id: str | int, data: dict) -> dict: + return self._http.put(f"{self._base}/{ca_id}", data) + + def set_status(self, ca_id: str | int, status: str) -> dict: + return self._http.put(f"{self._base}/{ca_id}/status", {"status": status}) + + def delete(self, ca_id: str | int) -> None: + self._http.delete(f"{self._base}/{ca_id}") + + +class TaskResource(_Resource): + """Tasks register (``/v1/tasks``).""" + + def list(self) -> list[dict]: + return _as_list(self._http.get("/v1/tasks")) + + def get(self, task_id: str | int) -> dict: + return self._http.get(f"/v1/tasks/{task_id}") + + def create(self, data: dict, references: list[dict] | None = None) -> dict: + body = dict(data) + if references: + body["references"] = references + return self._http.post("/v1/tasks", body) + + def update(self, task_id: str | int, data: dict) -> dict: + return self._http.put(f"/v1/tasks/{task_id}", data) + + def set_status(self, task_id: str | int, status: str) -> dict: + return self._http.put(f"/v1/tasks/{task_id}/status", {"status": status}) + + def delete(self, task_id: str | int) -> None: + self._http.delete(f"/v1/tasks/{task_id}") + + +class ReferenceResource(_Resource): + """Cross-entity references (``/v1/references``). + + Used to link risks, incidents, corrective actions, tasks, documents, and + other registered entities to each other after they have been created. + """ + + def list(self, *, entity_type: str | None = None, entity_id: str | None = None) -> list[dict]: + params: dict[str, Any] = {} + if entity_type: + params["type"] = entity_type + if entity_id: + params["id"] = entity_id + return _as_list(self._http.get("/v1/references", params=params or None)) + + def create( + self, + source_type: str, + source_id: str, + target_type: str, + target_id: str, + ) -> dict: + return self._http.post( + "/v1/references", + { + "source_type": source_type, + "source_id": source_id, + "target_type": target_type, + "target_id": target_id, + }, + ) + + def delete(self, reference_id: str | int) -> None: + self._http.delete(f"/v1/references/{reference_id}") + + +class DocumentResource(_Resource): + """Documents (``/v1/documents``).""" + + def list(self) -> list[dict]: + """The document tree (folders with their documents).""" + return _as_list(self._http.get("/v1/documents/all")) + + def search(self, query: str) -> list[dict]: + return _as_list(self._http.get("/v1/documents/search", params={"q": query})) + + def body(self, document_id: str) -> dict: + """A document's rendered body + metadata, by document_id.""" + return self._http.get(f"/v1/documents/{document_id}/body") + + +class WhoamiResource(_Resource): + """Identity of the current API token (``/v1/me``).""" + + def get(self) -> dict: + return self._http.get("/v1/me") + + +class IsmsClient: + """Client for the ISMS platform.""" + + def __init__( + self, + base_url: str, + token: str, + *, + organization_uuid: str | None = None, + cf_client_id: str | None = None, + cf_client_secret: str | None = None, + timeout: float = _DEFAULT_TIMEOUT, + session: requests.Session | None = None, + ) -> None: + if not base_url: + raise ValueError("base_url is required") + if not token: + raise ValueError("token is required") + self._http = _HTTP( + base_url, + token, + organization_uuid=organization_uuid, + cf_client_id=cf_client_id, + cf_client_secret=cf_client_secret, + timeout=timeout, + session=session, + ) + self.suppliers = SupplierResource(self._http) + self.risks = RiskResource(self._http) + self.incidents = IncidentResource(self._http) + self.correctives = CorrectiveResource(self._http) + self.tasks = TaskResource(self._http) + self.references = ReferenceResource(self._http) + self.documents = DocumentResource(self._http) + self.whoami = WhoamiResource(self._http) + + @property + def base_url(self) -> str: + return self._http.base_url + + @classmethod + def from_env( + cls, + *, + env_file: str | os.PathLike[str] | None = None, + override: bool = False, + ) -> IsmsClient: + """Construct a client from environment variables. + + Reads ``ISMS_API_URL`` (or ``ISMS_BASE_URL``) and ``ISMS_API_TOKEN`` + (or ``ISMS_API_KEY``). Optionally loads an env file first: if + ``env_file`` is passed it is used, otherwise the path in + ``ISMS_ENV`` is used if set. + """ + env_path = env_file or os.environ.get("ISMS_ENV") + if env_path: + load_env_file(env_path, override=override) + + base_url = os.environ.get("ISMS_API_URL") or os.environ.get("ISMS_BASE_URL") + token = os.environ.get("ISMS_API_TOKEN") or os.environ.get("ISMS_API_KEY") + if not base_url or not token: + raise EnvironmentError( + "ISMS_API_URL (or ISMS_BASE_URL) and ISMS_API_TOKEN (or ISMS_API_KEY) " + "must be set, either directly or via an env file pointed at by ISMS_ENV." + ) + return cls( + base_url, + token, + organization_uuid=os.environ.get("ISMS_ORGANIZATION_UUID") or None, + cf_client_id=os.environ.get("CF_ACCESS_CLIENT_ID") or None, + cf_client_secret=os.environ.get("CF_ACCESS_CLIENT_SECRET") or None, + ) + + def __repr__(self) -> str: # pragma: no cover + return f"IsmsClient(base_url={self._http.base_url!r})" diff --git a/src/isms/env.py b/src/isms/env.py new file mode 100644 index 0000000..0d89c3c --- /dev/null +++ b/src/isms/env.py @@ -0,0 +1,46 @@ +"""Environment-file loader compatible with the ISMS Go CLI's `loadEnvFile`. + +Reads simple `KEY=VALUE` lines from a file, stripping surrounding quotes and +ignoring blank lines and comments. Sets values into `os.environ` unless +`override=False` and the key is already set. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def load_env_file(path: str | os.PathLike[str], *, override: bool = True) -> dict[str, str]: + """Load KEY=VALUE lines from ``path`` into ``os.environ``. + + Returns the mapping of keys that were read from the file (whether or not + they were set into the environment). If ``override`` is ``False``, existing + environment values are not replaced. + + Blank lines and lines starting with ``#`` are ignored. Values wrapped in + single or double quotes have their outer quotes stripped, mirroring the + Go implementation. + """ + p = Path(path) + if not p.is_file(): + return {} + + read: dict[str, str] = {} + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip() + if len(val) >= 2 and ( + (val[0] == '"' and val[-1] == '"') or (val[0] == "'" and val[-1] == "'") + ): + val = val[1:-1] + read[key] = val + if override or key not in os.environ: + os.environ[key] = val + return read diff --git a/src/isms/exceptions.py b/src/isms/exceptions.py new file mode 100644 index 0000000..c17a662 --- /dev/null +++ b/src/isms/exceptions.py @@ -0,0 +1,29 @@ +"""ISMS client exceptions.""" + +from __future__ import annotations + + +class IsmsError(Exception): + """Base class for all ISMS client errors.""" + + +class IsmsHTTPError(IsmsError): + """Raised when the ISMS API returns a non-2xx status.""" + + def __init__(self, status: int, message: str, body: str | None = None) -> None: + super().__init__(f"{status}: {message}") + self.status = status + self.message = message + self.body = body + + +class IsmsAuthError(IsmsHTTPError): + """Raised on 401/403 responses.""" + + +class IsmsNotFoundError(IsmsHTTPError): + """Raised on 404 responses.""" + + +class IsmsValidationError(IsmsHTTPError): + """Raised on 400/422 responses.""" diff --git a/src/isms/py.typed b/src/isms/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..02f3593 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import pytest +import responses + +from isms import ( + IsmsAuthError, + IsmsClient, + IsmsHTTPError, + IsmsNotFoundError, + IsmsValidationError, +) + + +BASE = "https://demo.isms.sh" +API = f"{BASE}/api" + + +@pytest.fixture() +def client() -> IsmsClient: + return IsmsClient(BASE, "test-token") + + +def test_from_env_requires_credentials(monkeypatch): + monkeypatch.delenv("ISMS_API_URL", raising=False) + monkeypatch.delenv("ISMS_BASE_URL", raising=False) + monkeypatch.delenv("ISMS_API_TOKEN", raising=False) + monkeypatch.delenv("ISMS_API_KEY", raising=False) + monkeypatch.delenv("ISMS_ENV", raising=False) + with pytest.raises(EnvironmentError): + IsmsClient.from_env() + + +def test_from_env_reads_variables(monkeypatch): + monkeypatch.setenv("ISMS_API_URL", "https://example.isms.sh") + monkeypatch.setenv("ISMS_API_TOKEN", "abc") + monkeypatch.delenv("ISMS_ENV", raising=False) + c = IsmsClient.from_env() + assert c.base_url.endswith("/api") + + +def test_base_url_appends_api(): + c = IsmsClient(BASE, "t") + assert c.base_url == API + + +def test_base_url_preserves_existing_api_suffix(): + c = IsmsClient(f"{BASE}/api", "t") + assert c.base_url == f"{BASE}/api" + + +@responses.activate +def test_supplier_add_posts_payload(client): + payload = { + "name": "MaintMaster", + "supplier_type": "saas", + "criticality": "high", + } + responses.post( + f"{API}/v1/suppliers", + json={"id": 1, "identifier": "SUP-001", **payload}, + ) + + result = client.suppliers.add(payload) + + assert result["identifier"] == "SUP-001" + call = responses.calls[0] + assert call.request.headers["Authorization"] == "Bearer test-token" + assert call.request.headers["User-Agent"].startswith("isms-python/") + + +@responses.activate +def test_risk_add_with_references(client): + responses.post( + f"{API}/v1/risks", + json={"id": 1, "identifier": "RISK-001", "title": "X"}, + ) + refs = [{"type": "document", "id": "esg-policy"}] + client.risks.add({"title": "X"}, references=refs) + + body = responses.calls[0].request.body + assert b'"references"' in body + assert b"esg-policy" in body + + +@responses.activate +def test_reference_create(client): + responses.post( + f"{API}/v1/references", + json={"id": 42}, + ) + client.references.create( + source_type="corrective_action", + source_id="7", + target_type="risk", + target_id="RISK-001", + ) + body = responses.calls[0].request.body + assert b"corrective_action" in body + assert b"RISK-001" in body + + +@responses.activate +def test_not_found_raises(client): + responses.get(f"{API}/v1/suppliers/nope", status=404, json={"error": "not found"}) + with pytest.raises(IsmsNotFoundError): + client.suppliers.get("nope") + + +@responses.activate +def test_auth_error_raises(client): + responses.get(f"{API}/v1/suppliers", status=401, json={"error": "unauthorized"}) + with pytest.raises(IsmsAuthError): + client.suppliers.list() + + +@responses.activate +def test_validation_error_raises(client): + responses.post(f"{API}/v1/suppliers", status=422, json={"error": "missing name"}) + with pytest.raises(IsmsValidationError): + client.suppliers.add({}) + + +@responses.activate +def test_generic_http_error_raises(client): + responses.get(f"{API}/v1/suppliers", status=500, json={"error": "server down"}) + with pytest.raises(IsmsHTTPError): + client.suppliers.list() + + +@responses.activate +def test_delete_returns_none(client): + responses.delete(f"{API}/v1/suppliers/SUP-001", status=204) + assert client.suppliers.delete("SUP-001") is None + + +@responses.activate +def test_list_unwraps_data_envelope(client): + # ISMS list endpoints return {"data": [...], "total": N} — list() must + # yield the items, not the envelope dict. + responses.get(f"{API}/v1/suppliers", json={"data": [{"id": 1}, {"id": 2}], "total": 2}) + result = client.suppliers.list() + assert result == [{"id": 1}, {"id": 2}] + + +@responses.activate +def test_list_handles_bare_array(client): + responses.get(f"{API}/v1/risks", json=[{"id": 9}]) + assert client.risks.list() == [{"id": 9}] + + +@responses.activate +def test_whoami_hits_me_endpoint(client): + # The identity endpoint is /me, not /whoami. + responses.get(f"{API}/v1/me", json={"email": "admin@example.com"}) + assert client.whoami.get()["email"] == "admin@example.com" + + +@responses.activate +def test_documents_list_uses_all(client): + responses.get(f"{API}/v1/documents/all", json={"data": [{"name": "iso27001"}]}) + assert client.documents.list() == [{"name": "iso27001"}] + + +@responses.activate +def test_document_body_by_id(client): + responses.get(f"{API}/v1/documents/iso27001-4-1/body", json={"body": "# Context"}) + assert client.documents.body("iso27001-4-1")["body"] == "# Context" + + +@responses.activate +def test_organization_uuid_header_sent(): + # Org selection on a bare domain goes through X-Organization-UUID. + c = IsmsClient(BASE, "t", organization_uuid="org-uuid-123") + responses.get(f"{API}/v1/suppliers", json={"data": []}) + c.suppliers.list() + assert responses.calls[0].request.headers["X-Organization-UUID"] == "org-uuid-123" diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..66a6e4f --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os + +from isms.env import load_env_file + + +def test_load_env_file(tmp_path, monkeypatch): + monkeypatch.delenv("ISMS_API_URL", raising=False) + monkeypatch.delenv("ISMS_API_TOKEN", raising=False) + f = tmp_path / "isms.env" + f.write_text( + """ + # comment line + ISMS_API_URL=https://example.isms.sh + ISMS_API_TOKEN="secret-token" + ISMS_ORGANIZATION='acme' + + BLANK_LINE_ABOVE=ok + """.strip() + ) + + result = load_env_file(f) + + assert result == { + "ISMS_API_URL": "https://example.isms.sh", + "ISMS_API_TOKEN": "secret-token", + "ISMS_ORGANIZATION": "acme", + "BLANK_LINE_ABOVE": "ok", + } + assert os.environ["ISMS_API_URL"] == "https://example.isms.sh" + assert os.environ["ISMS_API_TOKEN"] == "secret-token" + + +def test_load_env_file_missing(tmp_path): + result = load_env_file(tmp_path / "does-not-exist.env") + assert result == {} + + +def test_load_env_file_no_override(tmp_path, monkeypatch): + monkeypatch.setenv("KEEP_ME", "original") + f = tmp_path / "isms.env" + f.write_text("KEEP_ME=changed\n") + + load_env_file(f, override=False) + + assert os.environ["KEEP_ME"] == "original" From f56e763df2ca5af0f4f8feba546c60d9638dd495 Mon Sep 17 00:00:00 2001 From: Alfred Hall Date: Tue, 7 Jul 2026 12:34:50 +0000 Subject: [PATCH 2/3] chore: CI + repo policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .github/ISSUE_TEMPLATE/bug_report.yml | 71 ++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 9 +++ .github/ISSUE_TEMPLATE/documentation.yml | 27 +++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 26 +++++++ .github/workflows/tests.yml | 50 +++++++++++++ .gitignore | 3 + CODE_OF_CONDUCT.md | 87 ++++++++++++++++++++++ CONTRIBUTING.md | 41 ++++++++++ SECURITY.md | 25 +++++++ scripts/apply-branch-protection.sh | 50 +++++++++++++ 10 files changed, 389 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100755 scripts/apply-branch-protection.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3a9c0da --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: Bug report +description: Report something that isn't working as expected +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. The fields below are what we'd otherwise have to + ask for — filling them in gets your issue triaged and fixed faster. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug — what you observed. + placeholder: When I called …, the client … + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: A minimal code snippet so we can see it too. + placeholder: | + from isms import IsmsClient + client = IsmsClient.from_env() + client.suppliers.list() # → ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected vs. actual + description: What you expected to happen, and what actually happened instead. + validations: + required: true + - type: dropdown + id: component + attributes: + label: Component / surface + description: Which part of the client is affected? + options: + - "Client / resources (suppliers, risks, incidents, …)" + - Authentication / tokens + - from_env / env-file loader + - Exceptions / error handling + - Packaging / install / types + - Other + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: isms-python version (`pip show isms`) and the ISMS server version if known. + placeholder: isms-python 0.1.0 / server 0.7.x + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "3.12" + validations: + required: true + - type: textarea + id: context + attributes: + label: Anything else? + description: Tracebacks, request/response details, and deployment specifics all help. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..fd21c20 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +# Maintainers still file rich free-form issues — keep the blank option. +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: mailto:security@isms.sh + about: Do NOT open a public issue for security problems. Email security@isms.sh directly — see SECURITY.md for what to include and our coordinated-disclosure policy. + - name: Question or support + url: https://github.com/unidoc/isms-python/discussions + about: Ask questions and get help in GitHub Discussions. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..8de9871 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,27 @@ +name: Documentation +description: Report a gap or inaccuracy in the documentation +title: "[Docs]: " +labels: ["documentation"] +body: + - type: input + id: location + attributes: + label: Where? + description: Which doc, page, or file — a link or path. + placeholder: README.md / docs/releasing.md / docs page URL + validations: + required: true + - type: textarea + id: gap + attributes: + label: What's missing or wrong? + description: The gap, inaccuracy, or point of confusion. + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested fix + description: Optional — how you'd improve it. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c9112cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature request +description: Suggest an improvement or a new capability +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What are you trying to do, and what's getting in the way? Lead with the why, not the solution. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What you'd like to see happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you thought about, and why they fall short. + validations: + required: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c8df82f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +# CI — lint + unit tests for the Python client. +# +# Security model (public repo, fork PRs welcome): +# - `pull_request` trigger only — NEVER `pull_request_target`. Fork PRs run in +# an isolated ephemeral VM with a read-only GITHUB_TOKEN and no secrets. +# - This workflow uses no secrets; there is nothing to exfiltrate. +# - `permissions: contents: read` pins the token to read-only even on push. + +name: tests + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install --quiet -e ".[dev]" + - name: ruff + run: ruff check src tests + + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install --quiet -e ".[dev]" + - name: pytest + run: pytest -q diff --git a/.gitignore b/.gitignore index 3dcff12..ab04a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ htmlcov/ .mypy_cache/ .ruff_cache/ *.log + +# Local scratch / torun scripts — never committed. +temp/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d022f93 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,87 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +Community spaces for this project include the GitHub repository, issue tracker, pull requests, code reviews, and any other project communication channels such as mailing lists, chat platforms, or forums affiliated with the project. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **conduct@isms.sh**. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +If the reported party is a project maintainer or owner, reporters may escalate directly to **hr@isms.sh** instead of using the standard enforcement contact, to ensure an impartial review free from conflict of interest. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9156a1b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to isms-python + +Thank you for your interest in contributing. + +## Process + +1. **Open an issue first** to discuss the change you'd like to make. +2. Fork the repository and create a feature branch. +3. Make your changes and ensure tests pass. +4. Submit a pull request referencing the issue. + +## Development Setup + +- Python 3.10+ + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Running Tests + +```bash +ruff check src tests +pytest +``` + +Both run in CI on every pull request across Python 3.10–3.13. + +## Code Style + +- **Ruff** for linting and formatting (`ruff check src tests`). Line length 100. +- Full type hints; the package ships a `py.typed` marker. +- Keep commits focused. One logical change per commit. +- The client mirrors the ISMS REST API — new resources/endpoints must match the + server's actual routes. + +## License + +By contributing, you agree that your contributions will be licensed under the +[Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b5d9cb9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open a public issue for security vulnerabilities.** + +Please report security issues by emailing **security@isms.sh**. Include: + +- Description of the vulnerability +- Steps to reproduce +- Potential impact + +## Response Timeline + +- **Acknowledgement** within 2 business days. +- **Initial assessment** within 5 business days. +- **Fix or mitigation** timeline communicated after assessment. + +## Supported Versions + +Security fixes are applied to the latest release only. We recommend always running the most recent version. + +## Disclosure + +We follow coordinated disclosure. We will credit reporters (unless anonymity is requested) once a fix is released. diff --git a/scripts/apply-branch-protection.sh b/scripts/apply-branch-protection.sh new file mode 100755 index 0000000..fb5ca6b --- /dev/null +++ b/scripts/apply-branch-protection.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Enforce the repo policy on master via the GitHub API — version-controlled and +# reproducible instead of hand-clicked in Settings. Idempotent (PUT), re-runnable. +# +# Requires: `gh` authenticated with admin on the repo. +# Run this AFTER the first PR's CI has run at least once, so the required check +# contexts below are known to GitHub (otherwise they'd block every PR). +# +# Policy enforced: +# - PRs only (no direct pushes), ≥1 approving review, stale reviews dismissed +# - CI must pass (strict = branch up to date): lint + pytest on 3.10–3.13 +# - Signed commits required +# - Linear history; no force-pushes, no branch deletion +# - Conversation resolution required before merge + +REPO="${1:-unidoc/isms-python}" +BRANCH="master" + +echo "→ applying branch protection to ${REPO}@${BRANCH}" + +gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ + -H "Accept: application/vnd.github+json" \ + --input - <<'JSON' +{ + "required_status_checks": { + "strict": true, + "contexts": ["lint", "test (3.10)", "test (3.11)", "test (3.12)", "test (3.13)"] + }, + "enforce_admins": false, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false + }, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": true +} +JSON + +# Signed-commit enforcement lives on its own endpoint. +gh api -X POST "repos/${REPO}/branches/${BRANCH}/protection/required_signatures" \ + -H "Accept: application/vnd.github+json" >/dev/null + +echo "✅ ${REPO}@${BRANCH}: PR + 1 review, CI required (strict), signed commits, linear history, no force-push/deletion" +echo " (enforce_admins is false so an owner keeps an emergency escape hatch — flip to true to bind admins too.)" From 5c329a583fed746352e30abb1d53100494f9a4a2 Mon Sep 17 00:00:00 2001 From: Alfred Hall Date: Tue, 7 Jul 2026 14:43:55 +0000 Subject: [PATCH 3/3] fix: references.list requires both args; base-url matches last segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/isms/client.py | 18 ++++++++++-------- tests/test_client.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/isms/client.py b/src/isms/client.py index ee59de1..0ddc3f3 100644 --- a/src/isms/client.py +++ b/src/isms/client.py @@ -43,7 +43,9 @@ def _resolve_base_url(url: str) -> str: url = url.rstrip("/") - if url.endswith("/api") or "/api/" in url + "/": + # Only treat the URL as already-mounted if its LAST segment is "api" — a raw + # substring check would wrongly match a proxy path like /api/docs. + if url.rsplit("/", 1)[-1] == "api": return url return url + "/api" @@ -288,13 +290,13 @@ class ReferenceResource(_Resource): other registered entities to each other after they have been created. """ - def list(self, *, entity_type: str | None = None, entity_id: str | None = None) -> list[dict]: - params: dict[str, Any] = {} - if entity_type: - params["type"] = entity_type - if entity_id: - params["id"] = entity_id - return _as_list(self._http.get("/v1/references", params=params or None)) + def list(self, entity_type: str, entity_id: str) -> list[dict]: + # The server requires both query params (handleListReferences returns 400 + # if either is missing), so both are required here — fail fast rather than + # round-trip to discover the 400. + return _as_list( + self._http.get("/v1/references", params={"type": entity_type, "id": entity_id}) + ) def create( self, diff --git a/tests/test_client.py b/tests/test_client.py index 02f3593..4dbf8a4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -168,6 +168,32 @@ def test_document_body_by_id(client): assert client.documents.body("iso27001-4-1")["body"] == "# Context" +def test_references_list_requires_both_args(client): + # The server 400s without both type+id, so the client must require them — + # a bare list() should fail fast with TypeError, not round-trip. + with pytest.raises(TypeError): + client.references.list() + + +def test_resolve_base_url_only_matches_last_segment(): + from isms.client import _resolve_base_url + + assert _resolve_base_url("https://demo.isms.sh") == "https://demo.isms.sh/api" + assert _resolve_base_url("https://demo.isms.sh/") == "https://demo.isms.sh/api" + assert _resolve_base_url("https://demo.isms.sh/api") == "https://demo.isms.sh/api" + assert _resolve_base_url("https://demo.isms.sh/api/") == "https://demo.isms.sh/api" + # a path that merely contains /api/ is not the mount point + assert _resolve_base_url("https://demo.isms.sh/api/docs") == "https://demo.isms.sh/api/docs/api" + + +@responses.activate +def test_references_list_sends_both_params(client): + responses.get(f"{API}/v1/references", json={"data": []}) + client.references.list(entity_type="risk", entity_id="RISK-1") + q = responses.calls[0].request.url + assert "type=risk" in q and "id=RISK-1" in q + + @responses.activate def test_organization_uuid_header_sent(): # Org selection on a bare domain goes through X-Organization-UUID.