Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
adcd0de
ops: Add pip Dependabot updates and GitHub release job
gegelendvay Sep 18, 2026
cbcf1c7
Merge pull request #4 from qpit/ops/update-workflows
gegelendvay Sep 18, 2026
b3bf000
build(deps): Bump the action-updates group with 2 updates
dependabot[bot] Sep 18, 2026
ea087fb
build(deps): Bump ruff from 0.16.0 to 0.16.7 in the patch-updates group
dependabot[bot] Sep 18, 2026
7d58023
Merge pull request #5 from qpit/dependabot/github_actions/develop/act…
gegelendvay Sep 18, 2026
2cddadc
Merge pull request #6 from qpit/dependabot/pip/develop/patch-updates-…
gegelendvay Sep 18, 2026
14e286e
build(deps): Bump the minor-updates group across 1 directory with 3 u…
dependabot[bot] Sep 18, 2026
262022e
Merge pull request #7 from qpit/dependabot/pip/develop/minor-updates-…
gegelendvay Sep 18, 2026
01f3c02
build(deps): Bump ruff from 0.16.7 to 0.16.8 in the patch-updates group
dependabot[bot] Sep 20, 2026
b3e4f90
Merge pull request #8 from qpit/dependabot/pip/develop/patch-updates-…
gegelendvay Sep 21, 2026
c7727bf
build(deps): Bump idna in the minor-updates group across 1 directory
dependabot[bot] Sep 21, 2026
4bccc11
Merge pull request #9 from qpit/dependabot/pip/develop/minor-updates-…
gegelendvay Sep 21, 2026
368b2fe
build(deps): Bump ruff from 0.16.8 to 0.16.9 in the patch-updates group
dependabot[bot] Oct 4, 2026
3c3fc24
Merge pull request #10 from qpit/dependabot/pip/develop/patch-updates…
gegelendvay Oct 5, 2026
d463785
build(deps): Bump ruff from 0.16.9 to 0.16.10 in the patch-updates group
dependabot[bot] Oct 11, 2026
d89faf1
Merge pull request #11 from qpit/dependabot/pip/develop/patch-updates…
gegelendvay Oct 11, 2026
d73cb50
feat: Working client with job submission
gegelendvay Oct 11, 2026
57a1f2a
Merge pull request #12 from qpit/feat/client
gegelendvay Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @gegelendvay @neago
* @gegelendvay @neago @abregnsbo
12 changes: 12 additions & 0 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -46,9 +46,29 @@ jobs:
id-token: write
steps:
- name: Download distributions
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: dist
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@v8
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
6 changes: 5 additions & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -27,6 +28,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"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
- [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`.

# Setup
## Requirements
- Python 3.9 or newer
- Python 3.11 or newer
- An account on [QCloud](https://qcloud.dtu.dk)

## Installation
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -18,7 +22,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = [
testing = [
"pytest>=8.3.0",
"pytest-cov>=6.0.0",
"ruff>=0.16.0",
Expand Down
8 changes: 4 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
ruff==0.16.0
idna==3.20
ruff==0.16.10
typing_extensions==4.16.0
14 changes: 14 additions & 0 deletions src/qcloud_client/__init__.py
Original file line number Diff line number Diff line change
@@ -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__",
]
89 changes: 89 additions & 0 deletions src/qcloud_client/client.py
Original file line number Diff line number Diff line change
@@ -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)
100 changes: 100 additions & 0 deletions src/qcloud_client/core.py
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 29 additions & 0 deletions src/qcloud_client/exceptions.py
Original file line number Diff line number Diff line change
@@ -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 <status>] <message>``."""
return f"[HTTP {self.status_code}] {self.message}"
Loading
Loading