From adcd0de8593ccb06d4717625da91f4e7c7fa0694 Mon Sep 17 00:00:00 2001 From: Gergely Lendvay <28988586+gegelendvay@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:59:30 +0200 Subject: [PATCH 1/9] ops: Add pip Dependabot updates and GitHub release job --- .github/dependabot.yaml | 12 ++++++++++++ .github/workflows/release.yml | 20 ++++++++++++++++++++ .ruff.toml | 3 +++ README.md | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 1388a7f..42d8691 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,5 +1,17 @@ version: 2 updates: + - package-ecosystem: "pip" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + groups: + patch-updates: + update-types: ["patch"] + minor-updates: + update-types: ["minor"] + open-pull-requests-limit: 5 + - package-ecosystem: "github-actions" directory: "/" target-branch: "develop" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ad0930..b5cdb4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,3 +52,23 @@ jobs: path: dist/ - name: Publish uses: pypa/gh-action-pypi-publish@release/v1 + + release: + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes diff --git a/.ruff.toml b/.ruff.toml index 53def54..6b8ea4c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -27,6 +27,9 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # Increase maximum number of return statements in a function from 6 to 7 pylint.max-returns = 7 +[lint.per-file-ignores] +"tests/**" = ["INP001", "S101"] + [format] quote-style = "double" indent-style = "space" diff --git a/README.md b/README.md index 923cbd0..ea8ef1d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - [License](#license) # Overview -GitHub repository for the Python API wrapper for [QCloud](https://qcloud.dtu.dk), the quantum computing service operated by [QPIT](https://www.fysik.dtu.dk/english/research/qpit/) at the Technical University of Denmark. +GitHub repository for the Python API wrapper for [QCloud](https://qcloud.dtu.dk), the quantum computing service operated by QPIT at the Technical University of Denmark. > [!WARNING] > The package is in early development and is not yet ready for stable public use. Expect breaking changes between minor versions until `1.0.0`. From b3bf000be8b83fe5458ac120613e0ec3e88e1188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:01:12 +0000 Subject: [PATCH 2/9] build(deps): Bump the action-updates group with 2 updates Bumps the action-updates group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) Updates `actions/download-artifact` from 4 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: action-updates - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: action-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5cdb4f..98674f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Check metadata run: python -m twine check dist/* - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -46,7 +46,7 @@ jobs: id-token: write steps: - name: Download distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist path: dist/ @@ -60,7 +60,7 @@ jobs: contents: write steps: - name: Download distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist path: dist/ From ea087fb3c2e59fc9180c2ef7ecf9735ff4ef95b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:01:41 +0000 Subject: [PATCH 3/9] build(deps): Bump ruff from 0.16.0 to 0.16.7 in the patch-updates group Bumps the patch-updates group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.0 to 0.16.7 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.0...0.16.7) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5d8800d..5f2308e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.18 -ruff==0.16.0 +ruff==0.16.7 typing_extensions==4.16.0 From 14e286e1e95ca7553d42f16d3742d430610a4df1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:05:42 +0000 Subject: [PATCH 4/9] build(deps): Bump the minor-updates group across 1 directory with 3 updates Bumps the minor-updates group with 3 updates in the / directory: [anyio](https://github.com/agronholm/anyio), [certifi](https://github.com/certifi/python-certifi) and [idna](https://github.com/kjd/idna). Updates `anyio` from 4.14.1 to 4.15.1 - [Release notes](https://github.com/agronholm/anyio/releases) - [Commits](https://github.com/agronholm/anyio/compare/4.14.1...4.15.1) Updates `certifi` from 2026.6.17 to 2026.7.22 - [Commits](https://github.com/certifi/python-certifi/compare/2026.06.17...2026.07.22) Updates `idna` from 3.18 to 3.19 - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.18...v3.19) --- updated-dependencies: - dependency-name: anyio dependency-version: 4.15.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: certifi dependency-version: 2026.7.22 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: idna dependency-version: '3.19' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5f2308e..8104412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -anyio==4.14.1 -certifi==2026.6.17 +anyio==4.15.1 +certifi==2026.7.22 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 -idna==3.18 +idna==3.19 ruff==0.16.7 typing_extensions==4.16.0 From 01f3c02e0c3b4adfd341dd75cec6d73478b77e88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:13:17 +0000 Subject: [PATCH 5/9] build(deps): Bump ruff from 0.16.7 to 0.16.8 in the patch-updates group Bumps the patch-updates group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.7 to 0.16.8 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8104412..b25d867 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.19 -ruff==0.16.7 +ruff==0.16.8 typing_extensions==4.16.0 From c7727bf09f91280b5976ca869bb5f3b10f17266f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:26:19 +0000 Subject: [PATCH 6/9] build(deps): Bump idna in the minor-updates group across 1 directory Bumps the minor-updates group with 1 update in the / directory: [idna](https://github.com/kjd/idna). Updates `idna` from 3.19 to 3.20 - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.19...v3.20) --- updated-dependencies: - dependency-name: idna dependency-version: '3.20' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b25d867..1aa80cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ certifi==2026.7.22 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 -idna==3.19 +idna==3.20 ruff==0.16.8 typing_extensions==4.16.0 From 368b2fee53bd6d0d4a4ad75abf6522447425a063 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:13:20 +0000 Subject: [PATCH 7/9] build(deps): Bump ruff from 0.16.8 to 0.16.9 in the patch-updates group Bumps the patch-updates group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.8 to 0.16.9 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.8...0.16.9) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1aa80cc..ac045f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 -ruff==0.16.8 +ruff==0.16.9 typing_extensions==4.16.0 From d463785a3833dd4a3abc701c9bbd15c18ff4201d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:14:56 +0000 Subject: [PATCH 8/9] build(deps): Bump ruff from 0.16.9 to 0.16.10 in the patch-updates group Bumps the patch-updates group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.9 to 0.16.10 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/0.16.10/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.9...0.16.10) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ac045f2..ef9a2ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 -ruff==0.16.9 +ruff==0.16.10 typing_extensions==4.16.0 From d73cb50fb4cae2311f368b77e78d99906858e811 Mon Sep 17 00:00:00 2001 From: Gergely Lendvay <28988586+gegelendvay@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:10:37 +0200 Subject: [PATCH 9/9] feat: Working client with job submission --- .github/CODEOWNERS | 2 +- .ruff.toml | 3 +- README.md | 2 +- pyproject.toml | 8 ++- src/qcloud_client/__init__.py | 14 +++++ src/qcloud_client/client.py | 89 ++++++++++++++++++++++++++++ src/qcloud_client/core.py | 100 ++++++++++++++++++++++++++++++++ src/qcloud_client/exceptions.py | 29 +++++++++ src/qcloud_client/models.py | 65 +++++++++++++++++++++ 9 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src/qcloud_client/exceptions.py create mode 100644 src/qcloud_client/models.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3009bca..3f6754f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @gegelendvay @neago +* @gegelendvay @neago @abregnsbo diff --git a/.ruff.toml b/.ruff.toml index 6b8ea4c..5f0da89 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -10,12 +10,13 @@ exclude = [ "src/qcloud_client/_version.py" ] +target-version = "py311" line-length = 200 indent-width = 4 [lint] select = ["ALL"] -ignore = ["ANN001", "ARG002", "BLE001", "C901", "CPY001", "D100", "D102", "D107", "D203", "D213", "D413", "E501", "EM102", "N806", "PLR2004", "PLW0108", "T201", "TRY003", "TRY301", "UP015"] +ignore = ["ANN001", "ANN401", "ARG002", "BLE001", "C901", "CPY001", "D100", "D102", "D107", "D203", "D213", "D413", "E501", "EM102", "N806", "PLR2004", "PLW0108", "T201", "TRY003", "TRY301", "UP015"] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] diff --git a/README.md b/README.md index ea8ef1d..3345495 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ GitHub repository for the Python API wrapper for [QCloud](https://qcloud.dtu.dk) # Setup ## Requirements -- Python 3.9 or newer +- Python 3.11 or newer - An account on [QCloud](https://qcloud.dtu.dk) ## Installation diff --git a/pyproject.toml b/pyproject.toml index f510195..24313bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,17 @@ name = "qcloud-client" dynamic = ["version"] description = "Python API wrapper for QCloud." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = "MIT" license-files = ["LICENSE"] authors = [{name = "QPIT"}] keywords = ["qcloud", "quantum", "simulation", "api-client"] classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Physics", ] @@ -18,7 +22,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = [ +testing = [ "pytest>=8.3.0", "pytest-cov>=6.0.0", "ruff>=0.16.0", diff --git a/src/qcloud_client/__init__.py b/src/qcloud_client/__init__.py index 420ac7e..9ca4be7 100644 --- a/src/qcloud_client/__init__.py +++ b/src/qcloud_client/__init__.py @@ -1 +1,15 @@ """Python API wrapper for QCloud.""" + +from ._version import __version__ +from .client import Client +from .exceptions import APIStatusError, QCloudError +from .models import Job, JobStatus + +__all__ = [ + "APIStatusError", + "Client", + "Job", + "JobStatus", + "QCloudError", + "__version__", +] diff --git a/src/qcloud_client/client.py b/src/qcloud_client/client.py index e69de29..e65c5f1 100644 --- a/src/qcloud_client/client.py +++ b/src/qcloud_client/client.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Self + +import httpx + +from . import core +from .exceptions import QCloudError + +if TYPE_CHECKING: + from collections.abc import Mapping + from types import TracebackType + + from .models import Job + +__all__ = ["Client"] + +VALID_JOB_TYPES = frozenset({"tn-covariance", "tn-sampling", "redpitaya"}) + + +class Client: + """A connection to a QCloud service. + + Keep one around for as long as you need it:: + + client = Client("https://qcloud.dtu.dk", api_key="...") + job = client.submit_job("tn-covariance", {...}) + client.close() + + The client owns an :class:`httpx.Client` holding a connection pool, so + calling :meth:`close` when you are done is good practice. Using the client + as a context manager does that for you, including on error:: + + with Client("https://qcloud.dtu.dk", api_key="...") as client: + job = client.submit_job("tn-covariance", {...}) + """ + + def __init__(self, base_url: str, api_key: str | None = None, timeout: float = core.DEFAULT_TIMEOUT, headers: Mapping[str, str] | None = None) -> None: + if not base_url: + msg = "base_url is required" + raise ValueError(msg) + self._http = httpx.Client( + base_url=base_url.rstrip("/"), + headers=core.build_headers(api_key, "Bearer", headers), + timeout=timeout, + ) + + def close(self) -> None: + """Release the underlying connection pool.""" + self._http.close() + + def __enter__(self) -> Self: + """Enter the context manager.""" + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None: + """Close the client on exit.""" + self.close() + + def get_jobs(self) -> list[Job]: + """Fetch all jobs.""" + method, url = core.get_jobs() + return core.parse_jobs(self._request_json(method, url)) + + def submit_job(self, job_type: str, params: Mapping[str, Any] | None = None) -> Job: + """Submit a new job and return it as accepted by the server. + + Args: + job_type (str): One of "tn-covariance", "tn-sampling", "redpitaya". + params (Mapping[str, Any] | None): Mapping of job parameters. + """ + if job_type not in VALID_JOB_TYPES: + raise ValueError(f"Unknown job_type {job_type!r}. It must be one of: {', '.join(sorted(VALID_JOB_TYPES))}") + method, url, body = core.post_job(job_type, params) + return core.parse_job(self._request_json(method, url, json=body)) + + def get_job(self, job_id: str) -> Job: + """Fetch a single job by its identifier.""" + method, url = core.get_job(job_id) + return core.parse_job(self._request_json(method, url)) + + def _request_json(self, method: str, url: str, **kwargs: Any) -> Any: + """Send a request and return its decoded body.""" + try: + response = self._http.request(method, url, **kwargs) + except httpx.HTTPError as exc: + msg = f"{method} {url} failed: {exc}" + raise QCloudError(msg) from exc + return core.parse_response(response) diff --git a/src/qcloud_client/core.py b/src/qcloud_client/core.py index e69de29..5b404ae 100644 --- a/src/qcloud_client/core.py +++ b/src/qcloud_client/core.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from urllib.parse import quote + +import httpx + +from ._version import __version__ +from .exceptions import APIStatusError, QCloudError +from .models import Job + +__all__ = [ + "DEFAULT_TIMEOUT", + "USER_AGENT", + "get_job", + "get_jobs", + "post_job", +] + +DEFAULT_TIMEOUT = 30 +_SERVICE_PREFIX = "/api/v1/service" + +USER_AGENT = f"qcloud-python/{__version__} httpx/{httpx.__version__}" + +_MISSING = object() + + +def build_headers(api_key: str | None, scheme: str, extra: Mapping[str, str] | None) -> dict[str, str]: + """Assemble the default request headers, letting `extra` override them.""" + headers = {"Accept": "application/json", "User-Agent": USER_AGENT} + if api_key: + headers["Authorization"] = f"{scheme} {api_key}" + if extra: + headers.update(extra) + return headers + + +def get_jobs() -> tuple[str, str]: + """Get a full list of jobs.""" + return "GET", f"{_SERVICE_PREFIX}/jobs" + + +def post_job(job_type: str, params: Mapping[str, Any] | None) -> tuple[str, str, dict[str, Any]]: + """Describe the request that creates a new job.""" + return "POST", f"{_SERVICE_PREFIX}/jobs", {"job_type": job_type, "params": dict(params or {})} + + +def get_job(job_id: str) -> tuple[str, str]: + """Describe the request that fetches a single job.""" + return "GET", f"{_SERVICE_PREFIX}/jobs/{quote(job_id, safe='')}" + + +def parse_response(response: httpx.Response) -> Any: + """Return the decoded body, raising :class:`APIStatusError` on error statuses.""" + if response.is_error: + raise APIStatusError(_error_message(response), status_code=response.status_code, response=response, payload=_decode(response, default=None)) + if not response.content: + return None + return _decode(response) + + +def parse_job(payload: Any) -> Job: + """Coerce a decoded job payload into a :class:`~qcloud_client.models.Job`.""" + if not isinstance(payload, Mapping): + msg = f"Expected a job object, got {type(payload).__name__}" + raise QCloudError(msg) + return Job.from_dict(payload) + + +def parse_jobs(payload: Any) -> list[Job]: + """Coerce a decoded job list into :class:`~qcloud_client.models.Job` objects.""" + if isinstance(payload, Mapping): + payload = payload.get("jobs", []) + if not isinstance(payload, list): + msg = f"Expected a list of jobs, got {type(payload).__name__}" + raise QCloudError(msg) + return [parse_job(item) for item in payload] + + +def _decode(response: httpx.Response, default: Any = _MISSING) -> Any: + """Decode a JSON body, returning `default` instead of raising when given.""" + try: + return response.json() + except ValueError as exc: + if default is not _MISSING: + return default + msg = "Response body is not valid JSON" + raise QCloudError(msg) from exc + + +def _error_message(response: httpx.Response) -> str: + """Pull the server's error message out of the body, falling back to the status text.""" + payload = _decode(response, default=None) + if isinstance(payload, Mapping): + for key in ("detail", "message", "error"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + return response.reason_phrase or "request failed" diff --git a/src/qcloud_client/exceptions.py b/src/qcloud_client/exceptions.py new file mode 100644 index 0000000..5269466 --- /dev/null +++ b/src/qcloud_client/exceptions.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import httpx + +__all__ = [ + "APIStatusError", + "QCloudError", +] + +class QCloudError(Exception): + """Base exception for every error raised by this package.""" + + +class APIStatusError(QCloudError): + """An error response from the API (any 4xx/5xx status).""" + + def __init__(self, message: str, *, status_code: int, response: httpx.Response | None = None, payload: Any = None) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + self.response = response + self.payload = payload + + def __str__(self) -> str: + """Render as ``[HTTP ] ``.""" + return f"[HTTP {self.status_code}] {self.message}" diff --git a/src/qcloud_client/models.py b/src/qcloud_client/models.py new file mode 100644 index 0000000..75cc7ca --- /dev/null +++ b/src/qcloud_client/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = [ + "Job", + "JobStatus", +] + + +class JobStatus: + """Terminal job states reported by the API.""" + + COMPLETED: Final = "completed" + FAILED: Final = "failed" + + +@dataclass(frozen=True) +class Job: + """A single job submitted to the service.""" + + job_id: str + status: str + job_type: str | None = None + params: dict[str, Any] = field(default_factory=dict) + result: Any = None + error: str | None = None + created_at: int | None = None + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> Job: + """Build a job from a decoded API payload, ignoring unknown fields.""" + job_id = payload.get("job_id") + if not job_id: + msg = "Job payload is missing 'job_id'" + raise ValueError(msg) + params = payload.get("params") or {} + return cls( + job_id=str(job_id), + status=str(payload.get("status", "")), + job_type=payload.get("job_type"), + params=dict(params), + result=payload.get("result"), + error=payload.get("error"), + created_at=payload.get("created_at"), + ) + + @property + def succeeded(self) -> bool: + """Whether the job finished successfully.""" + return self.status == JobStatus.COMPLETED + + @property + def failed(self) -> bool: + """Whether the job finished with an error.""" + return self.status == JobStatus.FAILED + + @property + def finished(self) -> bool: + """Whether the job reached a terminal state and will not change again.""" + return self.succeeded or self.failed