Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 96 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Release

on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"

permissions:
contents: read

jobs:
build:
name: Test and build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.11"

# The tag must match __version__, so the published version is the one in the source.
- name: Check tag matches package version
run: |
VERSION=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/aura_python_sdk/_version.py)
if [ "v${VERSION}" != "${GITHUB_REF_NAME}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match __version__ ${VERSION}" >&2
exit 1
fi

# Gate: the release is only built if lint, types and tests pass.
- run: uv sync --all-extras
- run: uv run ruff format --check
- run: uv run ruff check
- run: uv run mypy
- run: uv run pytest -m "not integration"

- run: uv build
- name: Smoke-test the wheel in a clean environment
run: |
uv venv /tmp/smoke
uv pip install --python /tmp/smoke/bin/python dist/*.whl
/tmp/smoke/bin/python -c "import aura_python_sdk as aura; print(aura.__version__)"

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/aura-python-sdk/
permissions:
id-token: write # PyPI trusted publishing; no API token is stored in the repo
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1

github-release:
name: Create GitHub release
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

# Collect the lines between "## vX.Y.Z" and the next "## " heading in CHANGELOG.md.
- name: Extract release notes
run: |
awk -v ver="${GITHUB_REF_NAME}" '
/^## / && ($2 == ver) { found=1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d' > release_notes.md
if [ ! -s release_notes.md ]; then
echo "See CHANGELOG.md for details." > release_notes.md
fi
cat release_notes.md

- uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body_path: release_notes.md
files: dist/*
prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Changelog

All notable changes to this project are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The release workflow publishes
the `## vX.Y.Z` section that matches the pushed tag as the GitHub release notes.

## Unreleased

### Added

- `AsyncAuraClient` for asyncio. It has the same options and services as `AuraClient`, shares
their validation and parsing, and uses an `AsyncHttpTransport` (httpx by default).
- `AuraClient` for the Aura API v1, with the Go SDK's options as keyword arguments, `from_env()`,
and context-manager support.
- Services matching the Go SDK: `tenants`, `instances`, `snapshots`, `cmek`, `graph_analytics` and
`prometheus`.
- Full v1 spec coverage beyond the Go SDK: `instances.estimate_size`, `instances.upgrade`,
`cmek.get` / `create` / `delete`, list filters, and the `storage`, `vector_optimized` and
`graph_analytics_plugin` update fields.
- Frozen dataclass models and `StrEnum`s that tolerate values the SDK doesn't know yet.
- An exception class per error: `NotFoundError`, `RateLimitError` (with `retry_after`) and others.
- A pluggable `HttpTransport`, with an httpx implementation as the default.
- Only network failures are retried, and a non-idempotent request is never re-sent once it may
have reached the server.
- A stdlib Prometheus text-format parser whose output matches the Go SDK, and
`get_instance_health` with the Go SDK's thresholds.

### Fixed

- `Instance.connection_url` is now optional. The live API returns `null` for some instances,
although the spec marks the field as required, and that made `instances.get()` fail.

### Changed

- SDK errors now report their public name (for example `aura_python_sdk.NotFoundError`), and
their tracebacks stop at the public method you called instead of listing the SDK's internal
frames. Unexpected exceptions still show a full traceback.
- The live integration tests skip, instead of failing, when the credentials lack permission for
an endpoint (HTTP 403).
86 changes: 80 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,67 @@ parser accepts the spec's `{"errors": [...]}`, the middleware `{"error": "..."}`
- Logging goes to the stdlib `logging` module, at debug level for requests and info level for
mutations. Credentials, tokens and passwords are never logged.

### 2.7 Deliberate differences from Go (decided in phase 2)

- **Retry safety.** Go retries every network error for every method. Here, if the request may have
reached the server (read timeout, connection reset), only idempotent methods (GET, PUT, DELETE,
HEAD, OPTIONS) are retried. This stops a `POST /instances` from being sent twice and creating a
duplicate billable instance. Errors that happen before anything is sent (connect errors, pool
timeouts) are retried for every method. Transports report which case applies through
`AuraConnectionError.request_sent`.
- **One deadline per call.** `timeout` covers the token fetch, every attempt and every backoff,
matching Go's `context.WithTimeout` per method. A retry is skipped if its backoff would pass the
deadline.
- **`max_retries=0` is allowed** and means a single attempt. Go requires at least 1.
- **A 401 clears the cached token**, so the next call fetches a new one. The failed call is not
retried.
- **Token endpoint errors.** Any 4xx from `/oauth/token` raises `AuthenticationError`; 429 and 5xx
keep their usual types. A lower-case `bearer` token type is accepted.
- **Transport ownership.** `close()` closes only a transport the client created itself.

### 2.8 Decisions made in phase 5

- **Names**: instance and CMEK names must be 1–30 characters with no leading or trailing
whitespace, as the spec states. Go checks only length, and only on create.
- **CMEK IDs**: `get` and `delete` only require a non-empty key ID, which is then path-encoded.
The spec doesn't say whether these IDs are UUIDs.
- **`upgrade()`**: `memory` and `storage` must be given together or not at all, as the spec
requires. With neither, it sends `{}`.
- **`cmek.delete()`** returns `None`, since the API responds 204 with no body.
- **Coverage guard**: `test_every_spec_operation_has_a_client_method` fails if the spec gains an
operation that no SDK method covers.

### 2.9 Decisions made in phase 6

- **No `prometheus_client`.** It normalises counter names (`foo` becomes `foo_total`) and
converts timestamps to seconds, so its keys wouldn't match the Go SDK's. A roughly 150-line
stdlib parser gives exactly the same output as Go's `expfmt`, checked with a Go program on the
same input. The `[prometheus]` extra is gone, so httpx is the only runtime dependency.
- **Metrics URL guard.** The Aura bearer token is sent to the metrics URL, so it must be
`https://*.neo4j.io` unless `allow_insecure_base_url=True`. Go sends the token to any URL.
- **Missing metrics are `None`, not `0`.** `InstanceHealth` fields are `None` when the endpoint
didn't report a metric, and threshold checks skip them. The status logic and messages match Go.
- **`get_metric_value`** raises `MetricNotFoundError`, which is also a `LookupError`.

### 2.10 Decisions made in phase 8 (async)

- **Written once, run two ways.** Each service operation is a pure function that validates its
arguments and returns a `Call` (method, path, params, body, parser, log text). `Service._run`
sends it synchronously and `AsyncService._run` awaits it. The retry policy, token parsing,
header building and error mapping are shared the same way, and only the I/O loops are
duplicated.
- **Thin async classes.** `AsyncInstanceService` and the other async services repeat only the
signatures, and their docstrings point to the sync methods.
- **Parity is enforced.** `tests/unit/test_async_parity.py` runs every method on both clients
against the same responses and asserts identical requests and results. It also checks the
signatures match, and fails if a method has no case. Two deliberately broken methods were
caught.
- **Transports can't be mixed up.** `AuraClient` rejects a transport whose `send` is a coroutine,
and `AsyncAuraClient` requires one. mypy catches the same mistake statically.
- **`asyncio.Lock`** guards the token refresh, so concurrent tasks share one token fetch.
- **Test tooling:** async tests use anyio's pytest plugin, which is already installed with httpx.
No new dependency.

## 3. Package layout

```
Expand All @@ -207,7 +268,7 @@ src/aura_python_sdk/
_types.py # HttpRequest, HttpResponse, HttpTransport Protocol
_httpx.py # HttpxTransport, the only httpx import
metrics/
_parser.py # the only prometheus_client import (optional extra)
_parser.py # stdlib Prometheus text-format parser (matches Go expfmt)
tests/
unit/ # FakeTransport, no network
transport/ # HttpxTransport against httpx.MockTransport
Expand All @@ -220,13 +281,14 @@ examples/ # ports of go examples/v1/*
| Dependency | Purpose | Wrapped in |
|---|---|---|
| `httpx` | HTTP | `_internal/http/_httpx.py` |
| `prometheus_client` (optional extra `[prometheus]`) | Parse the Prometheus text format | `_internal/metrics/_parser.py` |

Dev tooling: `uv`, `ruff` (lint and format), `mypy --strict`, `pytest`, `pytest-cov`. No `respx`:
`httpx.MockTransport` plus our own fake transport are enough.

## 5. Phases

**Status:** all eight phases are done.

1. **Scaffold**: pyproject, uv, ruff, mypy, pytest config, CI workflow, and the import-boundary test.
2. **Core**: config/options, errors, `HttpTransport` + `HttpxTransport` (retries, size cap),
`TokenManager`, `RequestService`, and `AuraClient` with no services yet. Unit-tested to Go's
Expand All @@ -235,14 +297,14 @@ Dev tooling: `uv`, `ruff` (lint and format), `mypy --strict`, `pytest`, `pytest-
example payloads.
4. **Services at Go parity**: tenants, instances, snapshots, `cmek.list`, graph_analytics.
5. **Spec gap-fill**: instance sizing and upgrade, CMEK get/create/delete, list filters, extra PATCH fields.
6. **Prometheus**: the optional extra plus the health assessment.
6. **Prometheus**: a stdlib metrics parser plus the health assessment.
7. **Docs and release**: README, the ported examples, CHANGELOG, opt-in integration tests, PyPI publish workflow.
8. *(If chosen)* **Async**: `AsyncAuraClient` over an `AsyncHttpTransport`, reusing request
building and parsing. The layering keeps this additive.

## 6. Enforcing "wrap every import"

A unit test walks `src/` with `ast` and fails if `httpx` or `prometheus_client` is imported anywhere
A unit test walks `src/` with `ast` and fails if `httpx` (or any unregistered dependency) is imported anywhere
except its designated module. It also checks that no public symbol's annotations reference those
packages.

Expand All @@ -259,9 +321,11 @@ packages.
## 8. Spec and Go discrepancies to resolve during implementation

- **Query parameter name**: the spec names the list-filter parameter `tenantId`, but Go sends
`tenant_id` (CMEK list). Check against the live API.
`tenant_id` (CMEK list). *Resolved: follow the spec. `tenantId` is used for
every list filter (defined once in `services/cmek.py`).*
- **Overwrite response**: Go models it as `{"data": "<job id string>"}`, but the spec says
`Instance`. Parse tolerantly and confirm.
`Instance`. The Go tests only use mocks. *Resolved: follow the spec. `overwrite_from_instance`
and `overwrite_from_snapshot` return `Instance`.*
- **GDS `ttl` type**: the spec says `integer` in the session details but `string` in the create
request. Go uses string throughout.
- **GDS create response**: the spec has an odd `data: {type: object, items: ...}` shape. Treat it as
Expand All @@ -270,3 +334,13 @@ packages.
not its schema. Go sends them, so we keep them.
- **Instance status `stopped` / `available`**: present in Go but not in the spec enum. Keep them for
parity; tolerant parsing makes this harmless.
- **Snapshot ID format**: resolved. Snapshot IDs are UUIDs, and the spec's list example
(`snapshot_id: '2023-01-20T13:44:42Z'`) is wrong. We keep Go's UUID validation.
- **`connection_url` can be null**: the first live run showed that `GET /instances/{id}`
returns `connection_url: null` for some instances, although the spec marks it as required.
`Instance.connection_url` is now optional. Run the live tests again after spec updates, to
catch fields that are required in the spec but missing in practice.
- **Required fields on responses**: models follow the spec's `required` lists, with two
exceptions. Instance `storage` is optional because it isn't returned for Free instances. GDS
session `status` is optional because the spec's 202 example returns `null`. A missing required
field raises `AuraResponseError` and names the field.
Loading
Loading