diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b60c33..c9c7501 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: types: [opened, synchronize, reopened, ready_for_review, draft] paths: - 'src/codesphere/**' + - 'scripts/**' - '.github/workflows/ci.yml' - 'tests/**' - 'pyproject.toml' @@ -39,6 +40,15 @@ jobs: run: uv run ty check shell: bash + - name: Verify generated sync client is up to date + run: | + uv run python scripts/gen_sync.py + git diff --exit-code --stat src/codesphere/_sync || { + echo "::error::src/codesphere/_sync is stale. Run 'make sync-gen' and commit the result." + exit 1 + } + shell: bash + - name: Minimize uv cache run: uv cache prune --ci diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..44239b0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Build docs + run: | + uv sync --group docs + uv run mkdocs build --strict + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 66b724c..a41eddb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ __marimo__ .coverage coverage.xml test-results/ +# MkDocs build output +site/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d0143ae..3bd883f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,9 @@ repos: language: system types: [python] pass_filenames: false + - id: sync-gen + name: regenerate sync client + entry: uv run python scripts/gen_sync.py + language: system + files: ^(src/codesphere/_async/|src/codesphere/_sync/|scripts/gen_sync\.py|pyproject\.toml) + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 0defd8b..d835e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## Unreleased + +### Added + +- Synchronous client: `from codesphere.sync import CodesphereSDK` mirrors + the async API with identical signatures (generated from the async + source via unasyncd; parity is enforced in CI). Request/response + models are shared between both flavors via the new `codesphere.models` + package. +- Per-call timeout: every API method now accepts a keyword-only + `timeout=` (float or `httpx.Timeout`) that overrides the client-wide + timeout for that call only (applied per retry attempt). +- Hosted documentation site (mkdocs-material + mkdocstrings) with + quickstart, guides, full API reference, and `llms.txt`; deployed to + GitHub Pages on pushes to `main`. + +### Changed + +- Retries on transient failures are now enabled by default + (`max_retries=2`). Idempotent methods (`GET`, `HEAD`, `PUT`, `DELETE`) + are retried on `429`/`502`/`503`/`504` and connect/timeout errors. + Set `RetryConfig(max_retries=0)` to restore the previous opt-in + behavior. `POST` is still never retried unless explicitly added to + `retry_methods`. +- Debug/error logs no longer include request or response bodies (they can + contain secrets such as env-var values); only structural information is + logged. +- Package classifiers: `Development Status :: 5 - Production/Stable` and + `Typing :: Typed`. + +### Removed + +- The deprecated module path `codesphere.resources.workspace.envVars` + (use `codesphere.resources.workspace.env_vars`). + ## v1.0.0 (2026-02-21) ### Features @@ -7,10 +42,10 @@ - feat(git): add git resource (#45) - feat(landscape): add landscape resource (#41) - feat(model-export): provide model methods to export data (#40) -- feat(excetions): add base exceptions (#39) +- feat(exceptions): add base exceptions (#39) - feat(agent): add copilot instructions (#35) - feat(domain): add domains resource (#33) -- feat(workspace): impleöment base operations for workspaces (#32) +- feat(workspace): implement base operations for workspaces (#32) ### Refactors @@ -25,7 +60,7 @@ - refac(commit-hook): remove commitizen (#38) - test: fix ci - test(suite): set up initial testsuite (#36) -- chore(test): bla bla (#28) +- chore(test): test maintenance (#28) - chore(types): clean types annotations (#10) ## v0.4.0 (2025-07-22) @@ -44,7 +79,7 @@ ### Fix -- **dsd**: sdfsf +- internal CI fixes ## v0.2.2 (2025-07-21) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b88b1bd..cc0dde6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,6 +129,56 @@ versions. --- +## Gating an Endpoint Behind a Feature Flag + +Platform instances enable features via flags (`GET /ide-service/flags`, +three categories: `internal`, `preview`, `features`). If an SDK feature only +works when a flag is enabled, declare it on the operation — **always with an +explicit category**: + +```python +from ...core.operations import APIOperation +from ...feature_flags import FlagCategory, FlagRequirement + +_LIST_VPN_CONFIGS_OP = APIOperation( + method="GET", + endpoint_template="/vpn/configs", + response_model=ResourceList[VpnConfig], + required_flag=FlagRequirement("vpn", FlagCategory.INTERNAL), +) +``` + +Nothing else is needed: `execute_operation` checks the flag **before any +request** and raises `FeatureNotAvailableError` (flag missing on this +instance) or `FeatureNotEnabledError` (available but off). The flags +snapshot is fetched once per client and cached; `await sdk.flags.refresh()` +re-fetches. Instances without the flags endpoint fail closed. + +For code paths that don't go through an `APIOperation` (SSE streams, +shell-command features), check explicitly: + +```python +await self._require_flag(FlagRequirement("workspace-ssh", FlagCategory.PREVIEW)) +``` + +For async generators, perform this check in a plain `async def` factory +*before* returning the generator, so the call fails fast instead of at the +first iteration. + +**Testing a gated endpoint:** register a respx route for +`/ide-service/flags` alongside the endpoint route, and assert the endpoint +route was **not** called when the flag is disabled: + +```python +mock_api.get("/ide-service/flags").respond(200, json={"preview": {"available": ["x"], "enabled": []}}) +route = mock_api.get("/gated").respond(200, json={}) +with pytest.raises(FeatureNotEnabledError): + await resource.gated_call() +assert route.called is False +``` + +--- + ## Testing Guidelines We maintain two types of tests: **unit tests** and **integration tests**. When contributing, please ensure appropriate test coverage for your changes. diff --git a/Makefile b/Makefile index 1c41946..1cb3a5b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install lint format typecheck test test-integration test-unit bump release pypi tree version changelog +.PHONY: help install lint format typecheck test test-integration test-unit bump release pypi tree version changelog docs docs-build sync-gen sync-check .DEFAULT_GOAL := help @@ -61,7 +61,7 @@ bump: ## Bumps the version. Usage: make bump VERSION=0.5.0 exit 1; \ fi @echo ">>> Bumping version from $(CURRENT_VERSION) to $(VERSION)..." - @sed -i '' 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml + @sed -i.bak 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml && rm -f pyproject.toml.bak @echo ">>> Updating lockfile..." uv lock @echo "\033[0;32mVersion updated to $(VERSION) in pyproject.toml\033[0m" @@ -144,5 +144,19 @@ pypi: ## Builds and publishes to PyPI (usually called by CI) uv publish @echo "\n\033[0;32mPyPI release complete!\033[0m" +sync-gen: ## Regenerates the sync client (src/codesphere/_sync) from the async source + uv run python scripts/gen_sync.py + +sync-check: ## Verifies the generated sync client is up to date with the async source + uv run python scripts/gen_sync.py + @git diff --exit-code --stat src/codesphere/_sync \ + || (echo "\033[0;31mERROR: src/codesphere/_sync is stale. Run 'make sync-gen' and commit the result.\033[0m"; exit 1) + +docs: ## Serves the documentation locally with live reload + uv run --group docs mkdocs serve + +docs-build: ## Builds the documentation (strict: fails on broken references) + uv run --group docs mkdocs build --strict + tree: ## Shows filetree in terminal without uninteresting files tree -I "*.pyc|*.lock" diff --git a/README.md b/README.md index 22e946d..31c30dd 100644 --- a/README.md +++ b/README.md @@ -60,28 +60,47 @@ any environment variable). ### Retries -Retries are off by default. Opt in with a `RetryConfig`: +Transient failures are retried automatically (2 retries by default). +Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are +retried on `429`, `502`, `503`, and `504` responses as well as on +connect/timeout errors. The delay honors the server's `Retry-After` header +(seconds or HTTP-date) when present, otherwise exponential backoff with +jitter (`backoff_factor * 2**attempt`). + +Tune or disable the behavior with a `RetryConfig`: ```python from codesphere import CodesphereSDK, RetryConfig sdk = CodesphereSDK( token="your-api-token", - retry=RetryConfig(max_retries=3), + retry=RetryConfig(max_retries=0), # disable retries entirely ) ``` -Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are then -retried on `429`, `502`, `503`, and `504` responses as well as on -connect/timeout errors. The delay honors the server's `Retry-After` header -(seconds or HTTP-date) when present, otherwise exponential backoff with -jitter (`backoff_factor * 2**attempt`). `POST` is never retried unless you -add it to `retry_methods` explicitly: +`POST` is never retried unless you add it to `retry_methods` explicitly +(only do this if your endpoints tolerate duplicate submissions): ```python RetryConfig(max_retries=3, retry_methods=frozenset({"GET", "POST"})) ``` +### Feature flags + +Platform instances enable different features. Inspect them via `sdk.flags`: + +```python +snapshot = await sdk.flags.get() # fetched once, then cached +if await sdk.flags.is_enabled("workspace-ssh"): + ... + +await sdk.flags.refresh() # re-fetch after platform changes +``` + +SDK features that depend on a platform flag raise `FeatureNotEnabledError` +(or `FeatureNotAvailableError` if the instance doesn't have the feature at +all) *before* sending any request when the flag is off. + ## Quick Start ```python @@ -97,6 +116,24 @@ async def main(): asyncio.run(main()) ``` +### Synchronous client + +The same API is available without `async`/`await` — ideal for scripts and +tools that don't run an event loop: + +```python +from codesphere.sync import CodesphereSDK + +with CodesphereSDK() as sdk: + teams = sdk.teams.list() + for team in teams: + print(f"{team.name} (ID: {team.id})") +``` + +Both flavors share the same request/response models, configuration, and +error types; only the entities with API methods (`Workspace`, `Team`, …) +exist per flavor. + ## Usage ### Teams diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..628614e --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,3 @@ +# Changelog + +--8<-- "CHANGELOG.md" diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..27ce374 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,61 @@ +# Configuration + +Every option can be passed directly to the constructor: + +```python +from codesphere import CodesphereSDK + +sdk = CodesphereSDK(token="your-api-token") +# or, e.g. against a different deployment: +sdk = CodesphereSDK( + token="your-api-token", + base_url="https://my-instance.example.com/api", +) +``` + +Alternatively, configure via environment variables (or a `.env` file in your +project root). Explicit constructor arguments always win over the environment: + +| Variable | Description | Default | +|----------|-------------|---------| +| `CS_TOKEN` | API token (required) | – | +| `CS_BASE_URL` | API base URL | `https://codesphere.com/api` | +| `CS_CLIENT_TIMEOUT_CONNECT` | Connect timeout in seconds | `10.0` | +| `CS_CLIENT_TIMEOUT_READ` | Read timeout in seconds | `30.0` | + +If no token is provided at all, `CodesphereSDK()` raises an +`AuthenticationError` at construction (importing the package never requires +any environment variable). + +## Client lifecycle + +The client owns an HTTP connection pool. Use it as an async context manager +(recommended) or open/close it explicitly: + +```python +async with CodesphereSDK() as sdk: + ... + +# or +sdk = CodesphereSDK() +await sdk.open() +try: + ... +finally: + await sdk.close() +``` + +## Logging + +The SDK logs to the `codesphere` logger (a `NullHandler` is installed by +default). Enable debug output to see requests, responses, and retries: + +```python +import logging + +logging.getLogger("codesphere").setLevel(logging.DEBUG) +logging.basicConfig() +``` + +Request and response bodies are never logged — payloads such as env-var +values can contain secrets. diff --git a/docs/guides/feature-flags.md b/docs/guides/feature-flags.md new file mode 100644 index 0000000..db82555 --- /dev/null +++ b/docs/guides/feature-flags.md @@ -0,0 +1,37 @@ +# Feature flags + +Platform instances enable different features. Inspect them via `sdk.flags`: + +```python +snapshot = await sdk.flags.get() # fetched once, then cached +if await sdk.flags.is_enabled("workspace-ssh"): + ... + +await sdk.flags.refresh() # re-fetch after platform changes +``` + +SDK features that depend on a platform flag raise `FeatureNotEnabledError` +(or `FeatureNotAvailableError` if the instance doesn't have the feature at +all) *before* sending any request when the flag is off: + +```python +from codesphere import FeatureNotAvailableError, FeatureNotEnabledError + +try: + await workspace.landscape.deploy() +except FeatureNotEnabledError as e: + print(f"Enable the feature first: {e}") +except FeatureNotAvailableError as e: + print(f"This platform does not support the feature: {e}") +``` + +You can also assert a flag yourself: + +```python +await sdk.flags.require("msd") # raises unless enabled +``` + +Flags are cached per client connection; call `sdk.flags.refresh()` to pick +up platform changes without reconnecting. Legacy platforms without the +flags endpoint are treated as having no flags (the SDK caches an empty +snapshot instead of failing). diff --git a/docs/guides/retries.md b/docs/guides/retries.md new file mode 100644 index 0000000..d5c682f --- /dev/null +++ b/docs/guides/retries.md @@ -0,0 +1,56 @@ +# Retries & timeouts + +## Retries + +Transient failures are retried automatically (2 retries by default). +Requests using an idempotent method (`GET`, `HEAD`, `PUT`, `DELETE`) are +retried on `429`, `502`, `503`, and `504` responses as well as on +connect/timeout errors. The delay honors the server's `Retry-After` header +(seconds or HTTP-date) when present, otherwise exponential backoff with +jitter (`backoff_factor * 2**attempt`). + +Tune or disable the behavior with a `RetryConfig`: + +```python +from codesphere import CodesphereSDK, RetryConfig + +sdk = CodesphereSDK( + token="your-api-token", + retry=RetryConfig(max_retries=0), # disable retries entirely +) +``` + +`POST` is never retried unless you add it to `retry_methods` explicitly +(only do this if your endpoints tolerate duplicate submissions): + +```python +RetryConfig(max_retries=3, retry_methods=frozenset({"GET", "POST"})) +``` + +## Timeouts + +Client-wide timeouts come from the constructor or environment +(see [Configuration](configuration.md)). Every API method additionally +accepts a keyword-only `timeout=` to override them for a single call — +a float (seconds, applied to connect and read) or a full `httpx.Timeout`: + +```python +import httpx + +# quick status probe: don't wait longer than 3 seconds +status = await workspace.get_status(timeout=3.0) + +# long-running command: allow a slow read +result = await workspace.execute_command( + "./run-migrations.sh", + timeout=httpx.Timeout(10.0, read=600.0), +) +``` + +With retries enabled, a per-call timeout applies to each attempt +individually. + +!!! note + `workspace.wait_until_running(timeout=...)` and + `workspace.logs.stream(timeout=...)` are *deadlines* for polling and + streaming, not HTTP timeouts — they keep their own semantics. diff --git a/docs/guides/streaming-logs.md b/docs/guides/streaming-logs.md new file mode 100644 index 0000000..c926924 --- /dev/null +++ b/docs/guides/streaming-logs.md @@ -0,0 +1,48 @@ +# Streaming logs + +Workspace logs are delivered as Server-Sent Events. The `workspace.logs` +manager streams them as typed `LogEntry` objects. + +## Targets + +A log target selects what to stream: + +- `StageTarget(stage, step)` — a pipeline stage (`prepare`, `test`, `run`) +- `ServerTarget(step, server)` — one server of a Multi Server Deployment +- `ReplicaTarget(step, server, replica)` — a single replica + +## Stream entries as they arrive + +```python +from codesphere.resources.workspace import LogStage +from codesphere.resources.workspace.logs import StageTarget + +workspace = await sdk.workspaces.get(workspace_id=456) + +async for entry in workspace.logs.stream(StageTarget(LogStage.RUN, step=1)): + print(entry.message) +``` + +## Collect entries into a list + +```python +entries = await workspace.logs.collect( + StageTarget(LogStage.TEST, step=1), + max_entries=200, + timeout=30.0, +) +``` + +`timeout` here is a *stream deadline* in seconds — collection stops when it +elapses (or when `max_entries` is reached), it is not an HTTP timeout. + +## Manual stream control + +`open()` returns the stream as an async context manager for full control: + +```python +async with workspace.logs.open(StageTarget(LogStage.RUN, step=1)) as stream: + async for entry in stream: + if "ready" in entry.message: + break +``` diff --git a/docs/guides/sync-vs-async.md b/docs/guides/sync-vs-async.md new file mode 100644 index 0000000..3404806 --- /dev/null +++ b/docs/guides/sync-vs-async.md @@ -0,0 +1,48 @@ +# Sync vs Async + +The SDK ships two clients with identical APIs: + +| | Async | Sync | +|---|---|---| +| Import | `from codesphere import CodesphereSDK` | `from codesphere.sync import CodesphereSDK` | +| Lifecycle | `async with CodesphereSDK() as sdk:` | `with CodesphereSDK() as sdk:` | +| Calls | `await sdk.teams.list()` | `sdk.teams.list()` | +| Streaming | `async for entry in ...` | `for entry in ...` | + +Everything else — method names, parameters, configuration, retries, +feature-flag gating, exceptions — is the same. The sync client is +generated from the async source, and CI enforces that both surfaces +stay identical. + +## Which one should I use? + +- **Async** when your application already runs an event loop (FastAPI, + aiohttp, background workers) or makes many concurrent API calls. +- **Sync** for scripts, CLIs, notebooks, and tools where `asyncio.run` + would be ceremony. + +## Shared models + +Request and response payload classes live in `codesphere.models` and are +shared: a `WorkspaceCreate` built anywhere works with either client. + +```python +from codesphere.models import WorkspaceCreate + +payload = WorkspaceCreate(team_id=1, name="demo", plan_id=8) +``` + +## Per-flavor entities + +Entities with API methods — `Workspace`, `Team`, `Domain`, `LogStream` — +exist once per flavor (`workspace.delete()` is `async` in one, plain in +the other). Consequently `isinstance` checks do not match across flavors: +`codesphere.Workspace` and `codesphere.sync.Workspace` are different +classes. + +## Sync streaming caveat + +`workspace.logs.stream(..., timeout=...)` enforces its deadline between +entries and via the socket read timeout. A stream that trickles bytes +just below the read timeout can overrun the deadline by up to one +interval — the same best-effort behavior most sync SSE clients have. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2225491 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# Codesphere Python SDK + +The official Python client for the [Codesphere API](https://codesphere.com/api/swagger-ui/). + +- **Fully typed** — pydantic v2 models everywhere, ships `py.typed`; your editor is the documentation. +- **Async-first** — built on `httpx`, with async context-manager lifecycle. +- **Resilient by default** — automatic retries with backoff and `Retry-After` support. +- **Structured errors** — a complete exception hierarchy instead of raw HTTP errors. +- **Feature-flag aware** — operations that need a platform flag fail fast before sending anything. + +## Installation + +```bash +uv add codesphere +# or: pip install codesphere +``` + +## First call in three lines + +```python +async with CodesphereSDK() as sdk: # token from CS_TOKEN env var + teams = await sdk.teams.list() +``` + +Head to the [Quickstart](quickstart.md) for a runnable example, or browse the +[API Reference](reference/client.md). diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..059dde5 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,37 @@ +# Codesphere Python SDK + +> Official Python client for the Codesphere API. Async and sync clients +> with identical APIs (httpx), fully typed (pydantic v2, ships py.typed), +> automatic retries with Retry-After support, structured exception +> hierarchy, feature-flag-aware operation gating, and SSE log streaming. + +Install: `pip install codesphere` (Python >= 3.12). +Async entry point: `from codesphere import CodesphereSDK`; use as +`async with CodesphereSDK() as sdk:`. Sync entry point: +`from codesphere.sync import CodesphereSDK`; use as +`with CodesphereSDK() as sdk:` (same methods, no await). Token via +constructor or `CS_TOKEN` env var. Resources: `sdk.teams`, +`sdk.workspaces`, `sdk.metadata`, `sdk.flags`; sub-managers on entities: +`workspace.env_vars`, `workspace.git`, `workspace.landscape`, +`workspace.logs`, `team.domains`, `team.usage`. Shared payload models in +`codesphere.models` work with both flavors. Every API method accepts a +keyword-only `timeout=` override. Errors derive from +`codesphere.CodesphereError` (e.g. `NotFoundError`, `RateLimitError` with +`retry_after`). + +## Docs + +- [Quickstart](https://datata1.github.io/codesphere-python/quickstart/): install, token, first call +- [Configuration](https://datata1.github.io/codesphere-python/guides/configuration/): constructor args, env vars, lifecycle, logging +- [Retries & timeouts](https://datata1.github.io/codesphere-python/guides/retries/): default retry behavior, RetryConfig, per-call timeout +- [Feature flags](https://datata1.github.io/codesphere-python/guides/feature-flags/): sdk.flags, operation gating errors +- [Streaming logs](https://datata1.github.io/codesphere-python/guides/streaming-logs/): SSE log streaming, targets, deadlines +- [Sync vs Async](https://datata1.github.io/codesphere-python/guides/sync-vs-async/): choosing a flavor, shared models, caveats +- [API Reference](https://datata1.github.io/codesphere-python/reference/client/): full typed API surface +- [Changelog](https://datata1.github.io/codesphere-python/changelog/): release history + +## Related + +- [REST API (Swagger)](https://codesphere.com/api/swagger-ui/): the underlying HTTP API +- [Source (GitHub)](https://github.com/Datata1/codesphere-python): issues and source code +- [PyPI](https://pypi.org/project/codesphere/): releases diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..c8d9304 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,81 @@ +# Quickstart + +## 1. Install + +```bash +uv add codesphere +``` + +## 2. Provide a token + +Create an API token in Codesphere, then export it (or put it in a `.env` file): + +```bash +export CS_TOKEN="your-api-token" +``` + +## 3. Make your first call + +```python +import asyncio + +from codesphere import CodesphereSDK + + +async def main(): + async with CodesphereSDK() as sdk: + teams = await sdk.teams.list() + for team in teams: + print(f"{team.name} (ID: {team.id})") + + +asyncio.run(main()) +``` + +## Common operations + +### Workspaces + +```python +workspaces = await sdk.workspaces.list(team_id=123) +workspace = await sdk.workspaces.get(workspace_id=456) + +result = await workspace.execute_command("ls -la") +print(result.output) + +await workspace.env_vars.set([{"name": "API_KEY", "value": "secret"}]) +env_vars = await workspace.env_vars.get() +``` + +### Domains + +```python +team = await sdk.teams.get(team_id=123) +domains = await team.domains.list() +domain = await team.domains.create(name="api.example.com") +``` + +### Metadata + +```python +datacenters = await sdk.metadata.list_datacenters() +plans = await sdk.metadata.list_plans() +images = await sdk.metadata.list_images() +``` + +### Error handling + +```python +from codesphere import CodesphereSDK, NotFoundError, RateLimitError + +async with CodesphereSDK() as sdk: + try: + workspace = await sdk.workspaces.get(workspace_id=999999) + except NotFoundError: + print("Workspace does not exist") + except RateLimitError as e: + print(f"Rate limited, retry after {e.retry_after}s") +``` + +Continue with the [Configuration guide](guides/configuration.md) or the +[API Reference](reference/client.md). diff --git a/docs/reference/client.md b/docs/reference/client.md new file mode 100644 index 0000000..c9c1b80 --- /dev/null +++ b/docs/reference/client.md @@ -0,0 +1,3 @@ +# Client + +::: codesphere.CodesphereSDK diff --git a/docs/reference/config.md b/docs/reference/config.md new file mode 100644 index 0000000..799b7e8 --- /dev/null +++ b/docs/reference/config.md @@ -0,0 +1,25 @@ +# Configuration & errors + +## Retry configuration + +::: codesphere.RetryConfig + +## Exceptions + +::: codesphere.exceptions + options: + show_root_heading: false + members: + - CodesphereError + - APIError + - AuthenticationError + - AuthorizationError + - NotFoundError + - ValidationError + - ConflictError + - RateLimitError + - NetworkError + - TimeoutError + - FeatureFlagError + - FeatureNotAvailableError + - FeatureNotEnabledError diff --git a/docs/reference/flags.md b/docs/reference/flags.md new file mode 100644 index 0000000..1395df6 --- /dev/null +++ b/docs/reference/flags.md @@ -0,0 +1,5 @@ +# Feature flags + +::: codesphere.resources.flags.FlagsResource + +::: codesphere.FlagsSnapshot diff --git a/docs/reference/metadata.md b/docs/reference/metadata.md new file mode 100644 index 0000000..3014016 --- /dev/null +++ b/docs/reference/metadata.md @@ -0,0 +1,3 @@ +# Metadata + +::: codesphere.resources.metadata.MetadataResource diff --git a/docs/reference/sync.md b/docs/reference/sync.md new file mode 100644 index 0000000..86c6866 --- /dev/null +++ b/docs/reference/sync.md @@ -0,0 +1,10 @@ +# Sync client + +The synchronous flavor of the SDK. Identical API to the async client — +see [Sync vs Async](../guides/sync-vs-async.md). + +::: codesphere.sync.CodesphereSDK + options: + members: + - open + - close diff --git a/docs/reference/teams.md b/docs/reference/teams.md new file mode 100644 index 0000000..2592ace --- /dev/null +++ b/docs/reference/teams.md @@ -0,0 +1,17 @@ +# Teams & domains + +## Teams + +::: codesphere.resources.team.TeamsResource + +::: codesphere.Team + +## Domains + +::: codesphere.resources.team.domain.resources.TeamDomainManager + +::: codesphere.Domain + +## Usage history + +::: codesphere.resources.team.usage.resources.TeamUsageManager diff --git a/docs/reference/workspaces.md b/docs/reference/workspaces.md new file mode 100644 index 0000000..c410cd8 --- /dev/null +++ b/docs/reference/workspaces.md @@ -0,0 +1,23 @@ +# Workspaces + +::: codesphere.resources.workspace.WorkspacesResource + +::: codesphere.Workspace + +## Environment variables + +::: codesphere.resources.workspace.env_vars.resources.WorkspaceEnvVarManager + +## Git + +::: codesphere.resources.workspace.git.resources.WorkspaceGitManager + +## Landscape (Multi Server Deployments) + +::: codesphere.resources.workspace.landscape.resources.WorkspaceLandscapeManager + +## Logs + +::: codesphere.resources.workspace.logs.resources.WorkspaceLogManager + +::: codesphere.resources.workspace.LogStream diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..76d0dfa --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,66 @@ +site_name: Codesphere Python SDK +site_description: The official Python client for the Codesphere API. +site_url: https://datata1.github.io/codesphere-python/ +repo_url: https://github.com/Datata1/codesphere-python +repo_name: Datata1/codesphere-python + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - navigation.top + - content.code.copy + - search.suggest + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + show_source: false + members_order: source + separate_signature: true + show_signature_annotations: true + +markdown_extensions: + - admonition + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.snippets + - pymdownx.details + - tables + +nav: + - Home: index.md + - Quickstart: quickstart.md + - Guides: + - Configuration: guides/configuration.md + - Retries & timeouts: guides/retries.md + - Feature flags: guides/feature-flags.md + - Streaming logs: guides/streaming-logs.md + - Sync vs Async: guides/sync-vs-async.md + - API Reference: + - Client: reference/client.md + - Sync client: reference/sync.md + - Teams & domains: reference/teams.md + - Workspaces: reference/workspaces.md + - Metadata: reference/metadata.md + - Feature flags: reference/flags.md + - Configuration & errors: reference/config.md + - Changelog: changelog.md diff --git a/plans/refactor/01-typing-gate-and-tooling.md b/plans/refactor/01-typing-gate-and-tooling.md deleted file mode 100644 index 850e659..0000000 --- a/plans/refactor/01-typing-gate-and-tooling.md +++ /dev/null @@ -1,80 +0,0 @@ -# 01 — Add a blocking `ty` type-check gate (ratcheted) and clean up tooling config - -**Priority:** P1 -**Depends on:** — -**Unblocks:** 02–07 (the ratchet is how later tickets prove their typing work) - -## Problem - -The SDK ships `src/codesphere/py.typed` (so downstream type checkers trust our annotations) and -`.github/copilot-instructions.md` states "Strict type hints required" — but **no type checker -exists anywhere**: not in dev dependencies, not in `.pre-commit-config.yaml`, not in -`.github/workflows/ci.yml`. The claim is unenforced, and several annotations in `core/` are -actively wrong (see ticket 02). - -Surrounding tooling config has drifted: - -- `ruff.toml` selects only `["F", "E4", "E7", "E9"]` and ignores `E721`/`F841` (unused locals - pass lint). Its `exclude` list references `src/codesphere_sdk/...` paths that don't exist — - leftovers from a generated-client era. -- `pyproject.toml` `[tool.coverage.run]` has `source = ["api", "handler", "tasks"]` - (pyproject.toml:64) — none of those packages exist, so coverage numbers are unanchored. - `omit` also lists nonexistent `docs/*` / `scripts/**` roots. -- `pyproject.toml:13-14,22` declare `aiohttp`, `aiohttp-retry`, and `urllib3` as runtime - dependencies; `grep -r` over `src/` shows zero imports of any of them (the SDK is pure httpx). - They bloat installs and imply a retry feature that doesn't exist (see ticket 07). -- `requires-python = ">=3.12.9"` (pyproject.toml:11) pins an oddly specific patch release, - excluding 3.12.0–3.12.8 users for no identified reason. - -## Approach - -Toolchain choice: **`ty`** (Astral) rather than mypy/pyright, to keep type checking and linting -in the same toolchain family as ruff. ty is pre-1.0: **pin its version** in the dev group and -expect occasional rule renames on upgrades (note this in a comment next to the pin). - -1. **Add ty as a blocking gate with a ratchet.** - - `uv add --dev ty` (pinned, e.g. `ty==0.0.x`). - - Configure in `pyproject.toml` under `[tool.ty]`: `src.root = "src"`, error-level rules for - the strictness-relevant checks (unresolved attributes, invalid assignments, invalid - argument types, missing/implicit `Any` where ty supports it). - - **Ratchet:** exclude the paths that cannot pass until later tickets land, via - `[[tool.ty.overrides]]` (or `src.exclude` if the pinned ty version's override granularity - is insufficient): - - `src/codesphere/core/**` and `src/codesphere/resources/**` → removed by tickets 02/04 - - `src/codesphere/http_client.py`, `src/codesphere/client.py`, `src/codesphere/config.py` → removed by ticket 03 - Annotate each exclusion with the ticket number that deletes it. - - CI: add a `typecheck` job to `.github/workflows/ci.yml` running `uv run ty check` - (blocking). Add the same to `.pre-commit-config.yaml` and a `make typecheck` target. - -2. **Broaden ruff.** - - `select = ["F", "E", "W", "I", "UP", "B", "SIM", "RUF"]`; drop the `F841` and `E721` - ignores; delete the entire stale `src/codesphere_sdk/*` exclude block. - - Run `ruff check --fix` + `ruff format`; fix the residue by hand (expect mostly import - sorting and pyupgrade rewrites like `Optional[X]` → `X | None`). - -3. **Fix coverage config.** `[tool.coverage.run] source = ["src/codesphere"]`; prune the - nonexistent `omit` entries; change the CI pytest invocation from `--cov=.` to rely on the - config (`--cov`). - -4. **Prune dependencies.** Remove `aiohttp`, `aiohttp-retry`, `urllib3` from - `[project.dependencies]`. While there, grep-verify each remaining runtime dep is actually - imported (`python-dateutil` is suspect) and remove any that aren't. `uv lock` after. - -5. **Relax the Python floor** to `requires-python = ">=3.12"` (align `.python-version` and - `ruff.toml target-version = "py312"`, which already agree). - -## Breaking changes - -None. Dependency removals only shrink the install footprint; relaxing `requires-python` widens -compatibility. - -## Acceptance criteria - -- [ ] CI fails on any ty error outside the documented ratchet exclusions; `make typecheck` and - the pre-commit hook run the same command. -- [ ] Every ratchet exclusion carries a comment naming the ticket that removes it. -- [ ] `ruff check` passes with the broadened rule set; no `codesphere_sdk` references remain in - `ruff.toml`. -- [ ] Coverage output reports lines in `src/codesphere/**` (spot-check the CI coverage comment). -- [ ] Fresh `uv pip install .` pulls neither aiohttp nor urllib3; `uv.lock` regenerated. -- [ ] `requires-python = ">=3.12"`. diff --git a/plans/refactor/02-typed-operation-dispatch.md b/plans/refactor/02-typed-operation-dispatch.md deleted file mode 100644 index 8043d48..0000000 --- a/plans/refactor/02-typed-operation-dispatch.md +++ /dev/null @@ -1,215 +0,0 @@ -# 02 — Replace `__getattribute__` operation magic with an explicit, fully-typed `_execute` - -**Priority:** P1 — keystone ticket -**Depends on:** 01 (ty ratchet in place) -**Unblocks:** 04, 05, 06 - -## Problem - -The operation dispatch layer is invisible to type checkers and its declared types are wrong in -four independent ways: - -1. **The `AsyncCallable` alias lies about its signature.** - `src/codesphere/core/operations.py:10`: - ```python - AsyncCallable: TypeAlias = Callable[[], Awaitable[_T]] - ``` - declares **zero parameters**, but every call site passes kwargs — - `self.get_team_op(team_id=team_id)` (`resources/team/resources.py:28`), - `self.create_team_op(data=payload)` (`:33`), and the same in every other resource. Under any - type checker each operation call is a "too many arguments" error. - -2. **The wiring is runtime magic no checker can follow.** Resources declare ops as - `op: AsyncCallable[T] = Field(default=_OP, exclude=True)` on classes that are **not** - pydantic models (`ResourceBase` in `core/base.py:13` is a plain class), so `Field(...)` is - just a `FieldInfo` in a class attribute and `exclude=True` is meaningless. - `_APIOperationExecutor.__getattribute__` (`core/handler.py:18-31`) then sniffs **every** - attribute access for `FieldInfo`/`APIOperation` and swaps in - `partial(self._execute_operation, operation=op)`. The static type (`AsyncCallable`), the - assigned value (`FieldInfo`), and the runtime value (a partial) are three different things. - -3. **The executor spine is `Any` end to end.** `_execute_operation` (`handler.py:33`), - `execute` (`:54`), and `_parse_and_validate_response` (`:106`) all return `Any`; - `APIOperation.response_model: Type[ResponseT]` (`operations.py:18`) can't hold `type(None)` - under strict checking; **`input_model` (`operations.py:19`) is declared on every operation - but never read by the handler** — request payloads are never validated; and the - `get_origin(response_model) is list` branch (`handler.py:123-124`) is dead because real list - responses are `ResourceList[...]` RootModels (`get_origin` returns `ResourceList`, not - `list`). - -4. **Endpoint formatting scrapes hidden state and has a payload bug.** - `_prepare_request_args` (`handler.py:65-75`) pours `self.executor.__dict__` and a full - `model_dump()` into the format namespace (duplicating `format_args.update(self.kwargs)` at - lines 67 and 73), so which value fills `{workspace_id}` is not grep-able. And - `if json_data_obj := self.kwargs.get("data")` (`handler.py:78`) drops **falsy** payloads: an - empty-dict body is silently not sent. - -Related idiom smell fixed by the same rewrite: entity models mix pydantic and executor and use -a doubled annotation+assignment (`team/schemas.py:31-32`): -```python -delete: AsyncCallable[None] -delete = Field(default=APIOperation(...), exclude=True) -``` - -## Approach - -**Chosen design: explicit generic `_execute` (delete the attribute magic).** A descriptor-based -alternative (`APIOperation.__get__` returning a typed callable) was considered and rejected: the -per-op parameters live in a *string* (`endpoint_template="/teams/{team_id}"`), so a descriptor -can type the return value but never the parameters without hand-writing a per-op `Protocol` — -duplicating the parameter list while keeping the magic. Meanwhile every resource **already** -wraps each op in a public `async def` with the real typed signature; that wrapper is the typed -surface. Collapsing to one layer is mostly deletion, and half the codebase -(`Workspace.update/delete`, `WorkspaceLandscapeManager`, …) already calls -`self._execute_operation(_OP, ...)` directly. - -### New `core/operations.py` - -```python -from dataclasses import dataclass -from typing import Generic, TypeVar -from pydantic import BaseModel - -ResponseT = TypeVar("ResponseT") -EntryT = TypeVar("EntryT", bound=BaseModel) - -@dataclass(frozen=True, slots=True) -class APIOperation(Generic[ResponseT]): - method: str - endpoint_template: str - response_model: type[ResponseT] - input_model: type[BaseModel] | None = None # now enforced by the executor - -@dataclass(frozen=True, slots=True) -class StreamOperation(Generic[EntryT]): - endpoint_template: str - entry_model: type[EntryT] -``` - -Typing facts that make this strict-clean: pydantic parametrized generics -(`ResourceList[Team]`) are real runtime classes, so `response_model=ResourceList[Team]` infers -`APIOperation[ResourceList[Team]]`; `types.NoneType` is `type[None]`, so delete/void ops are -`APIOperation[None]` and `_execute` returns `None`. Dropping `BaseModel` as the op container -removes `arbitrary_types_allowed` and the `model_copy(update={"response_model": ...})` trick in -`team/domain/manager.py:20-53` (ticket 04 deletes those call sites; use `dataclasses.replace` -if one genuinely remains). Standardize the "no response" encoding on `NoneType` — retire the -`None`-vs-`type(None)` dual sentinel. - -### New `core/handler.py` — one free function - -```python -async def execute_operation( - client: APIHttpClient, - op: APIOperation[ResponseT], - *, - data: BaseModel | Mapping[str, Any] | None = None, - params: Mapping[str, Any] | None = None, - path_params: Mapping[str, Any], -) -> ResponseT: - # 1. endpoint = op.endpoint_template.format(**path_params) — explicit, no __dict__ scraping - # 2. if op.input_model is not None and data is not None: - # data = op.input_model.model_validate(data) # input_model finally enforced - # 3. payload = data.model_dump(by_alias=True, exclude_none=True) if isinstance(data, BaseModel) else data - # guard with `if data is not None` — fixes the falsy empty-dict bug - # 4. response = await client.request(op.method, endpoint, json=payload, params=params) - # 5. return _parse_response(response, op.response_model, client) -``` - -`_parse_response(response, model: type[ResponseT], client) -> ResponseT` handles: `NoneType` → -return `None`; otherwise `model.model_validate(response.json())` + inject the client into -bound models / `ResourceList` items (port `_inject_client_into_model`). The dead -`get_origin(...) is list` branch is not ported. Wrap the pydantic `ValidationError` in the -SDK's own response-validation error (or re-raise deliberately and document it) so users don't -have to catch pydantic internals. - -### New bases in `core/base.py` - -```python -class ResourceBase: - def __init__(self, http_client: APIHttpClient) -> None: - self._http_client = http_client - - async def _execute( - self, op: APIOperation[ResponseT], *, - data: BaseModel | Mapping[str, Any] | None = None, - params: Mapping[str, Any] | None = None, - **path_params: Any, - ) -> ResponseT: - return await execute_operation(self._http_client, op, data=data, params=params, path_params=path_params) - - -class BoundModel(CamelModel): - """API entity that carries the HTTP client it was fetched with (Team, Workspace, Domain, …).""" - _http_client: APIHttpClient | None = PrivateAttr(default=None) - - def _client(self) -> APIHttpClient: - # replaces validate_http_client(); raise the "detached model" RuntimeError (or a - # dedicated DetachedModelError) when None - ... - - async def _execute(self, op: APIOperation[ResponseT], *, data=None, params=None, **path_params: Any) -> ResponseT: - return await execute_operation(self._client(), op, data=data, params=params, path_params=path_params) -``` - -`_execute` is deliberately duplicated as a one-line delegation in each base instead of a shared -mixin — mixing a plain class into pydantic bases is what produced the current -`PrivateAttr`-on-plain-class confusion. `data`/`params` are reserved kwarg names (cannot be -path params); no current endpoint template uses either. - -**Deleted:** `_APIOperationExecutor`, `APIRequestHandler`, `AsyncCallable`, the -`__getattribute__` override. `core/__init__.py` re-exports become: `ResourceBase`, -`BoundModel`, `CamelModel`, `ResourceList`, `APIOperation`, `StreamOperation`. - -### A resource module after - -```python -# resources/team/resources.py -class TeamsResource(ResourceBase): - async def list(self) -> list[Team]: - return (await self._execute(LIST_TEAMS)).root - - async def get(self, team_id: int) -> Team: - return await self._execute(GET_TEAM, team_id=team_id) - - async def create(self, payload: TeamCreate) -> Team: - return await self._execute(CREATE_TEAM, data=payload) - -# resources/team/schemas.py — delete op moves to operations.py as DELETE_TEAM: APIOperation[None] -class Team(TeamBase, BoundModel): - async def delete(self) -> None: - await self._execute(DELETE_TEAM, team_id=self.id) -``` - -Migration is mechanical across the ~8 resource modules: delete the `Field`-wired op -attributes, keep every existing public method signature, and replace the body with one -`self._execute(...)` call. Templates that previously scraped `{id}`/`{workspace_id}` from -`self.__dict__` now receive them explicitly (`id=self.id`) — one extra kwarg, grep-able data -flow. - -**Document the contributor recipe in CONTRIBUTING.md** (this ticket): a new resource is -(1) pydantic schemas in `schemas.py` (`CamelModel`, or `BoundModel` if the entity makes calls), -(2) `APIOperation` constants in `operations.py`, (3) a `ResourceBase` subclass with one typed -public method per op. No Field wiring, no dunder knowledge. - -## Breaking changes - -- **User-facing API: none.** Public signatures already lived on the wrapper methods and are - unchanged; `sdk.teams.get(...)`, `team.delete()`, `workspace.env_vars...` behave identically. -- `codesphere.core` internals change/disappear. This ticket adds a line to the README/CHANGELOG - declaring that only top-level `codesphere` exports (and the documented - `codesphere.resources.*` re-exports) are public API. -- Behavior fix: payloads are now validated against `input_model` (previously silently - unvalidated) and empty-dict bodies are now sent (previously dropped). Both are corrections; - call them out in the CHANGELOG. - -## Acceptance criteria - -- [ ] `grep -rn "AsyncCallable\|__getattribute__\|APIRequestHandler\|_APIOperationExecutor" src/` is empty. -- [ ] `grep -rn "Field(default=" src/codesphere/resources src/codesphere/core` finds no - operation wiring. -- [ ] `src/codesphere/core/**` and `src/codesphere/resources/**` removed from the ty ratchet; - `uv run ty check` passes. -- [ ] New unit test: a payload violating `input_model` raises a validation error before any - HTTP request is made. -- [ ] New regression test: an operation called with `data={}` sends `{}` as the JSON body. -- [ ] Full unit + integration suites pass; CONTRIBUTING.md contains the new-resource recipe. diff --git a/plans/refactor/03-client-config-lifecycle.md b/plans/refactor/03-client-config-lifecycle.md deleted file mode 100644 index fdd6cdc..0000000 --- a/plans/refactor/03-client-config-lifecycle.md +++ /dev/null @@ -1,92 +0,0 @@ -# 03 — Constructor-injectable config, lazy settings, single close path - -**Priority:** P1 -**Depends on:** 01; mostly parallel to 02 (one noted overlap) - -## Problem - -1. **`import codesphere` crashes without `CS_TOKEN`.** `src/codesphere/config.py:20` runs - `settings = Settings()` at module import and `token: SecretStr` (`config.py:13`) is - required, so a bare import raises a raw pydantic `ValidationError`. The purpose-built - `AuthenticationError` in `exceptions.py` (whose message even says to set `CS_TOKEN`) is - **never raised anywhere** — dead code. - -2. **No per-client configuration.** `CodesphereSDK.__init__` (`client.py:12-16`) takes no - arguments and `APIHttpClient.__init__` reads the token from the global `settings` - (`http_client.py:16`). Two clients with different tokens in one process are impossible; - pointing at a staging URL requires env mutation before import. - -3. **`CS_BASE_URL` is dead.** `http_client.py:15-17`: - ```python - def __init__(self, base_url: str = "https://codesphere.com/api"): - self._base_url = base_url or str(settings.base_url) - ``` - The parameter default is a non-empty string, so `settings.base_url` is only consulted if a - caller passes `""` — and `client.py:13` calls `APIHttpClient()` with no args. The env var - and the duplicated literal can silently disagree. - -4. Assorted lifecycle/typing debris: - - `http_client.py:29-30` `setattr`s dynamic `get/post/...` verb methods — untyped, - invisible to IDEs, and unused (the handler always calls `.request`). - - Duplicate payload serialization: the handler dumps `BaseModel` payloads AND - `http_client.request` re-dumps (`http_client.py:62-63`). - - `client.py` has two divergent close paths: `close()` (`:21-22`) discards exception info - while `__aexit__` (`:28-29`) bypasses `close()` entirely. - - `config.py:14` `base_url: HttpUrl = "https://codesphere.com/api"` — a `str` default on an - `HttpUrl` field (runtime-coerced, statically wrong). - -## Approach - -1. **Make settings lazy.** Delete module-level `settings = Settings()`. Make - `token: SecretStr | None = None`. `Settings()` is constructed inside the client path only - (a small `Settings.load()` or plain construction in `CodesphereSDK.__init__`). Fix the - `base_url` default to a proper `HttpUrl` value so the field type-checks. - -2. **Constructor injection with explicit resolution order.** - ```python - class CodesphereSDK: - def __init__( - self, - token: str | SecretStr | None = None, - base_url: str | None = None, - timeout: httpx.Timeout | None = None, - http_client: APIHttpClient | None = None, # escape hatch for tests/advanced use - ) -> None: ... - ``` - Resolution: explicit argument → `CS_*` env via `Settings()` → built-in default. A missing - token raises `AuthenticationError` **at construction** (finally using the existing - exception and its message). - -3. **Make `APIHttpClient` a dumb transport.** - `def __init__(self, *, token: SecretStr, base_url: str, timeout: httpx.Timeout)` — no - `settings` access inside; the SDK resolves config, the transport receives it. Delete the - `setattr` verb loop. Remove the `BaseModel` re-dump from `request` so serialization is - owned solely by `execute_operation` (from ticket 02 — if 03 lands first, keep the re-dump - until 02 merges, then delete it there). - -4. **One close path.** `__aexit__` delegates to a single `close(exc_type=None, ...)`; - `CodesphereSDK.close()` and `__aexit__` both route through it. - -5. Update README/examples for `CodesphereSDK(token=...)` (env-only usage keeps working - unchanged). - -## Breaking changes - -- `import codesphere` no longer raises when `CS_TOKEN` is unset — the failure moves to - `CodesphereSDK()` construction and changes type from pydantic `ValidationError` to - `codesphere.AuthenticationError`. Anyone catching the pydantic error at import time (nobody - should be) is affected. CHANGELOG entry: behavior fix. -- `APIHttpClient.__init__` signature changes (internal; ticket 02 declares `core`/transport - internals private). - -## Acceptance criteria - -- [ ] `env -i python -c "import codesphere"` succeeds (no env vars needed to import). -- [ ] `CodesphereSDK()` without a token (arg or env) raises `AuthenticationError`. -- [ ] `CodesphereSDK(token="x", base_url="http://localhost:8000")` sends requests to the - custom URL (respx test); `CS_BASE_URL` env var is honored when no arg is given (test). -- [ ] Two SDK instances with different tokens send different `Authorization` headers (test). -- [ ] No dynamic `setattr` verb methods; exactly one payload-serialization site; exactly one - close path (`grep` + tests). -- [ ] `config.py`, `http_client.py`, `client.py` removed from the ty ratchet; `uv run ty check` - passes. diff --git a/plans/refactor/04-resource-layer-normalization.md b/plans/refactor/04-resource-layer-normalization.md deleted file mode 100644 index 0aef2a9..0000000 --- a/plans/refactor/04-resource-layer-normalization.md +++ /dev/null @@ -1,90 +0,0 @@ -# 04 — One module layout, one execution idiom, shared scoped-resource bases - -**Priority:** P2 -**Depends on:** 02 - -## Problem - -The resource packages never settled on a single pattern, so a contributor has no canonical -template to copy: - -- **File naming chaos.** The manager class lives in `manager.py` for `team/domain` and - `team/usage`, but in `models.py` for `workspace/envVars`, `workspace/git`, - `workspace/landscape`, `workspace/logs`. `git` uses `schema.py` (singular) while everything - else uses `schemas.py`. The `envVars/` directory is camelCase in a snake_case tree. - `workspace/landscape/resources.py` is a zero-byte file; `resources/__init__.py` is empty. -- **Two contradictory execution idioms** (pre-ticket-02): Field-wired ops vs direct - `self._execute_operation(_OP, ...)` calls. Ticket 02 standardizes the mechanism; this ticket - sweeps the remaining structural drift. -- **Copy-pasted scoped-manager boilerplate.** Six managers repeat the same - `__init__(self, http_client, workspace_id/team_id)` (e.g. `envVars/models.py`, - `git/models.py`, `landscape/models.py`, `logs/models.py`, `domain/manager.py`, - `usage/manager.py`). -- **Domain ops defined twice.** `team/domain/operations.py` sets `response_model=DomainBase`, - then `team/domain/manager.py:20-53` overrides every single op via - `model_copy(update={"response_model": Domain})`. `Domain.verify_status` is annotated - `AsyncCallable[None]` while its op returns `DomainVerificationStatus`. -- **Convention breaks & duplication.** `EnvVar` (`envVars/schemas.py`) uses plain `BaseModel` - instead of `CamelModel` (loses aliasing and the `to_dict/to_json/to_yaml` helpers). - `PipelineStatusList` (`landscape/schemas.py:41`) reimplements exactly what - `core.base.ResourceList` provides. The `min(max(1, x), 100)` clamp appears 4× in - `team/usage/manager.py:72,91,128,151`. `UsageSummaryResponse.refresh` and - `UsageEventsResponse.refresh` are near-identical. `utils.dict_to_model_list` has zero callers - and a `key_field: str = None` typing bug. - -## Approach - -1. **Canonical per-resource layout** (document in CONTRIBUTING.md alongside ticket 02's - recipe): each resource package contains exactly `operations.py` (op constants), - `schemas.py` (pydantic models), `resources.py` (`ResourceBase` subclasses), `__init__.py` - (re-exports). Rename `manager.py`/`models.py`/`schema.py` accordingly; delete the - zero-byte `landscape/resources.py` placeholder content merge. - -2. **Rename `envVars/` → `env_vars/`**, keeping the public import surface stable: the - re-exports in `workspace/__init__.py` (`from .env_vars import EnvVar, ...`) keep the paths - users actually import (`codesphere.resources.workspace.envVars` deep imports appear in - examples — keep a thin `envVars.py` shim module emitting `DeprecationWarning`, or update - the examples and CHANGELOG if a clean break is preferred; decide at implementation time, - shim recommended since the SDK is published). - -3. **Scoped bases in `core/base.py`:** - ```python - class TeamScopedResource(ResourceBase): - def __init__(self, http_client: APIHttpClient, team_id: int) -> None: ... - - class WorkspaceScopedResource(ResourceBase): - def __init__(self, http_client: APIHttpClient, workspace_id: int) -> None: ... - ``` - All six scoped managers subclass these; the copy-pasted `__init__`s are deleted. - -4. **Domain ops defined once** with their final `response_model=Domain` / - `ResourceList[Domain]` directly in `operations.py` (ticket 02's frozen dataclass makes - `model_copy` impossible anyway). Fix `verify_status` to return - `DomainVerificationStatus`, matching its op. - -5. **Convention/duplication sweep:** - - `EnvVar(CamelModel)`. - - Delete `PipelineStatusList`; use `ResourceList[PipelineStatus]` (re-export the old name - as an alias for compatibility). - - One clamp: `PageSize = Annotated[int, Field(ge=1, le=100)]` on the request models, or a - single `_clamp_page_size()` helper in `team/usage`. - - Hoist the shared `refresh()` onto `PaginatedResponse`; while there, make - `limit`/`offset` non-Optional with defaults (removes the repeated `self.limit or 25` - re-defaulting). - - Delete `utils.dict_to_model_list` (dead code); if `utils.py` becomes empty, delete it. - -## Breaking changes - -- Internal module paths move (`...domain.manager` → `...domain.resources`, etc.). Public - re-exports from `codesphere` and the package `__init__.py`s stay stable; the `envVars` deep - path gets a deprecation shim (recommended) per step 2. - -## Acceptance criteria - -- [ ] `find src -name "manager.py" -o -name "models.py" -o -name "schema.py"` returns nothing; - no zero-byte modules; no camelCase directories (modulo the optional shim module). -- [ ] `grep -rn "model_copy(update" src/codesphere/resources` is empty. -- [ ] At most one page-size clamp implementation; one `refresh()` implementation. -- [ ] `EnvVar` round-trips camelCase aliases like every other schema (test). -- [ ] Examples still run (`examples/sdk_demo.py` imports resolve). -- [ ] Touched paths removed from the ty ratchet; `uv run ty check` passes. diff --git a/plans/refactor/05-logs-landscape-hardening.md b/plans/refactor/05-logs-landscape-hardening.md deleted file mode 100644 index 6cdbbea..0000000 --- a/plans/refactor/05-logs-landscape-hardening.md +++ /dev/null @@ -1,84 +0,0 @@ -# 05 — Deduplicate the logs stream/collect trio; make profile writes injection-safe - -**Priority:** P2 -**Depends on:** 02 - -## Problem - -1. **The stream/collect API is the same code six times.** `workspace/logs/models.py` defines - `stream` / `stream_server` / `stream_replica` (lines ~205-237) and `collect` / - `collect_server` / `collect_replica` (~238-293). Each trio differs only in which - `StreamOperation` it selects and which id kwarg it passes; each `collect_*` is an identical - `async with ... as stream: async for ...` accumulation wrapper around its `stream_*`. Any - fix or feature (filters, timeouts) must be applied six times. - -2. **`save_profile` builds a shell heredoc from user content.** - `workspace/landscape/models.py:87`: - ```python - f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n" - ``` - executed remotely via `execute_command`. The profile *name* is regex-validated, but the - YAML *body* is interpolated raw: content containing a line `PROFILE_EOF` truncates the file - and executes the remainder as shell; CRLF/backslash edge cases corrupt content. - `delete_profile` (`models.py:95`) does `rm -f {filename}` — safe only because of the name - regex, worth an assertion. This is also a design smell (remote file management expressed as - string-built shell), but the injection fix is the actionable part. - -3. Minor: `LogStream` hardcodes `httpx.Timeout(5.0, read=None)` and SSE terminator event names - (`"end", "close", "done", "complete"`), and reaches into the private - `self._http_client._get_client()`. - -## Approach - -1. **One parametrized stream path.** Introduce frozen target dataclasses, each knowing its - `StreamOperation` and path params: - ```python - @dataclass(frozen=True, slots=True) - class StageTarget: stage: LogStage; step: int = 0 - @dataclass(frozen=True, slots=True) - class ServerTarget: step: int; server: str - @dataclass(frozen=True, slots=True) - class ReplicaTarget: step: int; replica: str - - LogTarget = StageTarget | ServerTarget | ReplicaTarget - - class WorkspaceLogsResource(WorkspaceScopedResource): - def stream(self, target: LogTarget, ...) -> LogStream: ... - async def collect(self, target: LogTarget, ...) -> list[LogEntry]: # implemented on stream() - ``` - Keep the six existing method names as one-line deprecated shims delegating to - `stream(...)`/`collect(...)` (emit `DeprecationWarning`), since the SDK is published. - -2. **Base64 transport for profile content.** Replace the heredoc with content-independent - plumbing: - ```python - f"printf '%s' '{b64}' | base64 -d > '{filename}'" - ``` - where `b64 = base64.b64encode(body.encode()).decode()` (alphabet is `[A-Za-z0-9+/=]`, inert - in single quotes) and `filename` remains regex-validated (`ci..yml`). Add an - adversarial round-trip unit test: content containing `PROFILE_EOF`, single/double quotes, - `$(reboot)`, backticks, CRLF, and unicode must be written byte-identically (assert on the - command string in unit tests; optionally cover in integration). - -3. While in the file: take the SSE read timeout from the client's configured timeouts instead - of the hardcoded `Timeout(5.0, read=None)`; name the terminator event set as a module-level - constant; expose whatever accessor `LogStream` needs on `APIHttpClient` instead of calling - `_get_client()` from outside. - -4. Tighten `LogEntry` typing: `kind` becomes a `str`-based `Enum` (`"I"`/`"E"`, with a - fallback for unknown values) instead of `Optional[str]` on an `extra="allow"` model. - -## Breaking changes - -None — old method names remain as deprecated shims; profile files are byte-identical after the -transport change. - -## Acceptance criteria - -- [ ] Stream/collect logic exists once; the six legacy names are 1-line shims with - `DeprecationWarning` (tested via `pytest.warns`). -- [ ] Adversarial-content profile test passes (including a literal `PROFILE_EOF` line and - `$(...)`). -- [ ] No heredoc construction remains: `grep -rn "PROFILE_EOF" src/` is empty. -- [ ] `workspace/logs/**` and `workspace/landscape/**` removed from the ty ratchet; - `uv run ty check` passes. diff --git a/plans/refactor/06-test-suite-http-boundary.md b/plans/refactor/06-test-suite-http-boundary.md deleted file mode 100644 index fadd42a..0000000 --- a/plans/refactor/06-test-suite-http-boundary.md +++ /dev/null @@ -1,58 +0,0 @@ -# 06 — Test at the HTTP boundary (respx); cover the untested core modules - -**Priority:** P2 -**Depends on:** 02, 03 - -## Problem - -- Unit tests mock at the dispatch boundary — e.g. - `landscape_manager._execute_operation = AsyncMock()` - (`tests/resources/workspace/landscape/test_pipeline.py:94`) — so real request construction, - URL/path formatting, auth header injection, error mapping, and response deserialization are - never exercised by unit tests. Only the live-credential integration suite (gated to the - upstream repo's secrets) touches that path. -- **Zero dedicated unit tests** exist for `exceptions.py` (10 exported error types, including - the `raise_for_status` status→exception mapping and `Retry-After` parsing), `config.py`, - `http_client.py`, and `utils.py`. - -## Approach - -1. Add `respx` to the dev group. Shared fixture (e.g. in `tests/conftest.py`): - `mock_api` — a `respx.mock` router bound to the SDK's base URL, plus an `sdk` fixture - yielding an opened `CodesphereSDK(token="test-token", base_url="https://test.local/api")` - (uses ticket 03's constructor injection). - -2. **Exception matrix** (`tests/test_exceptions.py`): for each mapped status - (400, 401, 403, 404, 409, 422, 429, 5xx) assert the raised class, `message`, `status_code`, - parsed `errors`, and `RateLimitError.retry_after` from the `Retry-After` header; plus - non-JSON error bodies and the httpx timeout/connect-error → `TimeoutError`/`NetworkError` - mapping in `http_client.request`. - -3. **Executor behavior** (`tests/core/test_execute_operation.py`), asserting on the *real* - outgoing request via respx: endpoint template formatting from explicit path params - (missing param → clear error), `input_model` validation rejecting bad payloads before any - request, `data={}` sending an empty JSON object (falsy-body regression from ticket 02), - `APIOperation[None]` returning `None` without parsing, `ResourceList` items receiving the - injected client (calling a method on a returned entity hits the mock). - -4. **Config resolution** (`tests/test_config.py`): arg > env > default precedence for token - and base_url; import without env vars; `AuthenticationError` on missing token; two SDKs → - two Authorization headers (from ticket 03's criteria). - -5. **Migration policy:** new tests are respx-only; existing `AsyncMock(_execute...)` tests are - converted opportunistically whenever their module is touched by tickets 02/04/05 — no - big-bang rewrite. Add a short note to CONTRIBUTING.md: unit tests for request/response - behavior mock the transport (respx), not SDK internals. - -## Breaking changes - -None (tests and dev dependencies only). - -## Acceptance criteria - -- [ ] Line coverage > 90% for `exceptions.py`, `config.py`, `http_client.py`, and the executor - module (measured with ticket 01's fixed coverage config). -- [ ] No **new** test mocks `_execute`/`_execute_operation` or SDK-internal methods for - request/response behavior. -- [ ] The exception matrix covers every exception class exported from `codesphere.__init__`. -- [ ] Suite runs offline (no `CS_*` env needed) and passes in CI. diff --git a/plans/refactor/07-retry-support.md b/plans/refactor/07-retry-support.md deleted file mode 100644 index 80da84e..0000000 --- a/plans/refactor/07-retry-support.md +++ /dev/null @@ -1,53 +0,0 @@ -# 07 — Opt-in retry with backoff, honoring `Retry-After` - -**Priority:** P3 -**Depends on:** 03 (constructor injection), 06 (respx harness for tests) - -## Problem - -The SDK parses `Retry-After` into `RateLimitError.retry_after` (`exceptions.py`), and until -ticket 01 it even shipped `aiohttp-retry` as a dependency — but **no retry logic exists -anywhere**. Nothing consumes `retry_after`; a 429 or a transient 503 always surfaces -immediately to the caller. The feature is implied but absent. - -## Approach - -1. **Config object** (in `config.py` or a new `retry.py`): - ```python - @dataclass(frozen=True, slots=True) - class RetryConfig: - max_retries: int = 0 # 0 = disabled (default: zero behavior change) - backoff_factor: float = 0.5 # sleep = backoff_factor * 2**attempt, jittered - retry_statuses: frozenset[int] = frozenset({429, 502, 503, 504}) - retry_methods: frozenset[str] = frozenset({"GET", "HEAD", "PUT", "DELETE"}) # idempotent only - ``` - -2. **Wire-through:** `CodesphereSDK(..., retry: RetryConfig | None = None)` → passed to - `APIHttpClient`. - -3. **Implementation:** a loop in `APIHttpClient.request` around the send + `raise_for_status`: - - retry on a response whose status is in `retry_statuses` **and** whose method is in - `retry_methods` (POST retried only if the caller opts in via `retry_methods`), and on - `httpx.TimeoutException`/`httpx.ConnectError`; - - sleep = `Retry-After` header value when present (seconds or HTTP-date), else exponential - backoff with jitter, via `asyncio.sleep`; - - after `max_retries` attempts, raise the same exception the non-retry path would - (`RateLimitError`, `NetworkError`, …) so error handling is unchanged; - - `log.debug` each retry with attempt count and delay. - - Keep it a plain loop — do not reintroduce a retry dependency; httpx has no native retry and - the logic is ~30 lines. - -## Breaking changes - -None. Default `max_retries=0` means behavior is identical unless a user opts in. - -## Acceptance criteria - -- [ ] respx test: 429 with `Retry-After: 1` then 200 → succeeds, exactly 2 requests, waited - ≥1s (patch/measure sleep). -- [ ] respx test: retries exhausted → the original `RateLimitError` is raised with - `retry_after` populated. -- [ ] respx test: non-idempotent POST is **not** retried by default. -- [ ] Default construction performs zero retries (single request on failure). -- [ ] README documents `RetryConfig` with an example. diff --git a/plans/refactor/README.md b/plans/refactor/README.md deleted file mode 100644 index 39b645d..0000000 --- a/plans/refactor/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Refactor Tickets - -Outcome of a full codebase review (2026-07-10) focused on **strong typing** and **easy extensibility**. - -The SDK's layered design (declarative `APIOperation` constants + thin resource facades + pydantic -response validation) is a solid, extensible spine. The findings concentrate on two gaps: - -1. **The typing story is aspirational, not enforced.** The package ships `py.typed` and the - contribution docs demand strict type hints, but no type checker runs anywhere — and the core - dispatch mechanism (`__getattribute__` interception of `Field`-wrapped operations) could not - pass one as written. -2. **The declared contracts aren't what the runtime does.** `input_model` is never validated, - `CS_BASE_URL` is dead, `import codesphere` crashes without `CS_TOKEN`, and empty request - bodies are silently dropped. - -## Tickets - -| # | Ticket | Priority | Depends on | -|---|--------|----------|------------| -| 01 | [Typing gate (ty) & tooling cleanup](01-typing-gate-and-tooling.md) | P1 | — | -| 02 | [Typed operation dispatch](02-typed-operation-dispatch.md) | P1 (keystone) | 01 | -| 03 | [Client config & lifecycle](03-client-config-lifecycle.md) | P1 | 01 (partially parallel to 02) | -| 04 | [Resource layer normalization](04-resource-layer-normalization.md) | P2 | 02 | -| 05 | [Logs & landscape hardening](05-logs-landscape-hardening.md) | P2 | 02 | -| 06 | [Test suite at the HTTP boundary](06-test-suite-http-boundary.md) | P2 | 02, 03 | -| 07 | [Opt-in retry support](07-retry-support.md) | P3 | 03, 06 | - -## Sequencing rationale - -- **01 lands first** and turns the `ty` gate on immediately as a *ratchet*: known-broken paths - (`core/`, `resources/`, `http_client.py`, `client.py`) are excluded at first, so the gate is - blocking for everything else (and all new code) from day one without waiting on the dispatch - rewrite. -- **02 is the keystone** — every later ticket builds on the explicit `_execute` dispatch it - introduces. Each ticket's acceptance criteria include removing its own paths from the ty - ratchet, so type-checked coverage only ever grows. -- **03** can start in parallel with 02 (different files), with one small ordering note on the - duplicate-serialization removal (see ticket). -- **Public API stability:** across all tickets, only symbols exported from the top-level - `codesphere` package (plus documented deep imports like `codesphere.resources.workspace.*` - re-exports) are treated as public. `codesphere.core` internals may change freely; ticket 02 - formalizes this. diff --git a/pyproject.toml b/pyproject.toml index f62912d..a016081 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,14 @@ dependencies = [ ] classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", ] [build-system] @@ -92,9 +95,36 @@ exclude = [ "examples/", ] +[tool.unasyncd] +# The sync client is generated from the async source of truth. +# Regenerate with `make sync-gen`; CI verifies freshness via +# `unasyncd --check`. Never edit src/codesphere/_sync by hand. +files = { "src/codesphere/_async" = "src/codesphere/_sync" } +add_editors_note = true +transform_docstrings = true +cache = false +remove_unused_imports = false +ruff_fix = false +ruff_format = false + +[tool.unasyncd.add_replacements] +"httpx.AsyncClient" = "httpx.Client" +"contextlib.AbstractAsyncContextManager" = "contextlib.AbstractContextManager" +"aiter_lines" = "iter_lines" +"aread" = "read" +"codesphere._async" = "codesphere._sync" + +[tool.unasyncd.per_file_add_replacements] +"src/codesphere/_async/_compat.py" = { "asyncio" = "threading" } + [dependency-groups] dev = [ "respx>=0.23.1", + "unasyncd>=0.10.1", +] +docs = [ + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.27", ] [project.urls] diff --git a/ruff.toml b/ruff.toml index bda7c30..9d39371 100644 --- a/ruff.toml +++ b/ruff.toml @@ -25,6 +25,17 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [lint.per-file-ignores] # marimo notebook: a bare trailing expression is how a cell renders its widget "examples/sdk_demo.py" = ["B018"] +# Compatibility shims re-export the implementation from the _async tree. +"src/codesphere/client.py" = ["F401", "F403", "E501"] +"src/codesphere/http_client.py" = ["F401", "F403", "E501"] +"src/codesphere/core/base.py" = ["F401", "F403", "E501"] +"src/codesphere/core/handler.py" = ["F401", "F403", "E501"] +"src/codesphere/resources/**" = ["F401", "F403", "E501"] +# The generated sync tree mirrors the async source. Style noise from the +# transformation (unused imports, re-exports without __all__) is expected; +# correctness is covered by ty and the sync test suite. +"src/codesphere/_sync/**" = ["F401", "F403", "E501", "W293", "I001", "UP028", "SIM"] +"src/codesphere/sync/__init__.py" = ["F401", "E501"] [format] # Like Black, use double quotes for strings. diff --git a/scripts/gen_sync.py b/scripts/gen_sync.py new file mode 100644 index 0000000..c40d574 --- /dev/null +++ b/scripts/gen_sync.py @@ -0,0 +1,63 @@ +"""Generate the sync client tree (src/codesphere/_sync) from the async source. + +Runs unasyncd (configured in pyproject.toml), restores ``__all__`` blocks +that unasyncd empties in re-export-only modules (class names are identical +in both trees, so the async block is copied verbatim), then normalizes the +result with the project's ruff config. + +Usage: uv run python scripts/gen_sync.py +CI freshness check: run this script, then `git diff --exit-code src/codesphere/_sync`. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ASYNC_DIR = ROOT / "src/codesphere/_async" +SYNC_DIR = ROOT / "src/codesphere/_sync" + +_ALL_RE = re.compile(r"^__all__ = \[[^\]]*\]", re.MULTILINE | re.DOTALL) + + +def run(*cmd: str, fatal: bool = True) -> None: + result = subprocess.run(cmd, cwd=ROOT) + if fatal and result.returncode != 0: + sys.exit(result.returncode) + + +def restore_all_blocks() -> int: + restored = 0 + for src in ASYNC_DIR.rglob("*.py"): + source_all = _ALL_RE.search(src.read_text()) + if source_all is None: + continue + target = SYNC_DIR / src.relative_to(ASYNC_DIR) + if not target.exists(): + continue + text = target.read_text() + target_all = _ALL_RE.search(text) + if target_all is None or target_all.group(0) == source_all.group(0): + continue + target.write_text( + text[: target_all.start()] + source_all.group(0) + text[target_all.end() :] + ) + restored += 1 + return restored + + +def main() -> None: + run("uv", "run", "unasyncd") + restored = restore_all_blocks() + print(f"Restored __all__ in {restored} generated files") + # Remaining findings are silenced via per-file-ignores in ruff.toml; + # fix what is fixable but do not fail generation on the rest. + run("uv", "run", "ruff", "check", "--fix", "--quiet", str(SYNC_DIR), fatal=False) + run("uv", "run", "ruff", "format", "--quiet", str(SYNC_DIR)) + + +if __name__ == "__main__": + main() diff --git a/src/codesphere/__init__.py b/src/codesphere/__init__.py index c62a026..5b3aa2a 100644 --- a/src/codesphere/__init__.py +++ b/src/codesphere/__init__.py @@ -1,13 +1,11 @@ """ Codesphere SDK - Python client for the Codesphere API. -This package provides a high-level asynchronous client for interacting -with the `Codesphere Public API `_. +This package provides a high-level client for interacting with the +`Codesphere Public API `_, +in an async and a sync flavor with identical APIs. -Main Entrypoint: - `from codesphere import CodesphereSDK` - -Basic Usage: +Async (default): >>> import asyncio >>> from codesphere import CodesphereSDK >>> @@ -17,11 +15,19 @@ >>> print(teams) >>> >>> asyncio.run(main()) + +Sync: + >>> from codesphere.sync import CodesphereSDK + >>> + >>> with CodesphereSDK() as sdk: + ... teams = sdk.teams.list() """ import logging +from ._sync.client import CodesphereSDK as SyncCodesphereSDK from .client import CodesphereSDK +from .client import CodesphereSDK as AsyncCodesphereSDK from .config import RetryConfig from .exceptions import ( APIError, @@ -29,12 +35,16 @@ AuthorizationError, CodesphereError, ConflictError, + FeatureFlagError, + FeatureNotAvailableError, + FeatureNotEnabledError, NetworkError, NotFoundError, RateLimitError, TimeoutError, ValidationError, ) +from .resources.flags import CategoryFlags, FlagCategory, FlagsSnapshot from .resources.metadata import Characteristic, Datacenter, Image, WsPlan from .resources.team import ( CustomDomainConfig, @@ -58,10 +68,11 @@ __all__ = [ "APIError", + "AsyncCodesphereSDK", "AuthenticationError", "AuthorizationError", + "CategoryFlags", "Characteristic", - # Exceptions "CodesphereError", "CodesphereSDK", "ConflictError", @@ -72,12 +83,17 @@ "DomainRouting", "DomainVerificationStatus", "EnvVar", + "FeatureFlagError", + "FeatureNotAvailableError", + "FeatureNotEnabledError", + "FlagCategory", + "FlagsSnapshot", "Image", "NetworkError", "NotFoundError", "RateLimitError", "RetryConfig", - # Resources + "SyncCodesphereSDK", "Team", "TeamBase", "TeamCreate", diff --git a/src/codesphere/_async/__init__.py b/src/codesphere/_async/__init__.py new file mode 100644 index 0000000..e2680bd --- /dev/null +++ b/src/codesphere/_async/__init__.py @@ -0,0 +1 @@ +"""Async client implementation (source of truth for the sync client).""" diff --git a/src/codesphere/_async/_compat.py b/src/codesphere/_async/_compat.py new file mode 100644 index 0000000..de11694 --- /dev/null +++ b/src/codesphere/_async/_compat.py @@ -0,0 +1,13 @@ +"""Async primitives whose sync counterpart is produced by name replacement. + +unasyncd maps most constructs automatically (asyncio.sleep -> time.sleep, +async def/await, __aenter__ -> __enter__, ...). Anything it cannot map is +aliased here; the generated sync twin replaces ``asyncio`` with +``threading`` (see ``per_file_add_replacements`` in pyproject.toml). +""" + +import asyncio + +Lock = asyncio.Lock + +__all__ = ["Lock"] diff --git a/src/codesphere/_async/client.py b/src/codesphere/_async/client.py new file mode 100644 index 0000000..ae7a2df --- /dev/null +++ b/src/codesphere/_async/client.py @@ -0,0 +1,97 @@ +from types import TracebackType + +import httpx +from pydantic import SecretStr + +from codesphere.config import RetryConfig, Settings +from codesphere.exceptions import AuthenticationError + +from .http_client import APIHttpClient +from .resources.flags import FlagsResource +from .resources.metadata import MetadataResource +from .resources.team import TeamsResource +from .resources.workspace import WorkspacesResource + + +class CodesphereSDK: + """Entry point to the Codesphere API. + + Configuration is resolved per argument: explicit constructor argument, + then the corresponding ``CS_*`` environment variable (``CS_TOKEN``, + ``CS_BASE_URL``, ``CS_CLIENT_TIMEOUT_CONNECT``/``_READ``; a local + ``.env`` file is honored), then the built-in default. A missing token + raises :class:`~codesphere.AuthenticationError` at construction. + """ + + teams: TeamsResource + workspaces: WorkspacesResource + metadata: MetadataResource + flags: FlagsResource + + def __init__( + self, + token: str | SecretStr | None = None, + base_url: str | None = None, + timeout: httpx.Timeout | None = None, + retry: RetryConfig | None = None, + http_client: APIHttpClient | None = None, + ): + if http_client is not None: + self._http_client = http_client + else: + settings = Settings() + + resolved_token = self._resolve_token(token, settings) + resolved_base_url = ( + base_url if base_url is not None else str(settings.base_url) + ) + resolved_timeout = ( + timeout + if timeout is not None + else httpx.Timeout( + settings.client_timeout_connect, + read=settings.client_timeout_read, + ) + ) + self._http_client = APIHttpClient( + token=resolved_token, + base_url=resolved_base_url, + timeout=resolved_timeout, + retry=retry, + ) + + self.teams = TeamsResource(self._http_client) + self.workspaces = WorkspacesResource(self._http_client) + self.metadata = MetadataResource(self._http_client) + self.flags = FlagsResource(self._http_client) + + @staticmethod + def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr: + if token is not None: + return token if isinstance(token, SecretStr) else SecretStr(token) + if settings.token is not None: + return settings.token + raise AuthenticationError() + + async def open(self) -> None: + await self._http_client.open() + + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: + await self._http_client.close(exc_type, exc_val, exc_tb) + + async def __aenter__(self) -> "CodesphereSDK": + await self.open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close(exc_type, exc_val, exc_tb) diff --git a/src/codesphere/_async/core/__init__.py b/src/codesphere/_async/core/__init__.py new file mode 100644 index 0000000..db86660 --- /dev/null +++ b/src/codesphere/_async/core/__init__.py @@ -0,0 +1,22 @@ +from codesphere.core.models import CamelModel, ResourceList +from codesphere.core.operations import APIOperation, StreamOperation + +from .base import ( + BoundModel, + ResourceBase, + TeamScopedResource, + WorkspaceScopedResource, +) +from .handler import execute_operation + +__all__ = [ + "APIOperation", + "BoundModel", + "CamelModel", + "ResourceBase", + "ResourceList", + "StreamOperation", + "TeamScopedResource", + "WorkspaceScopedResource", + "execute_operation", +] diff --git a/src/codesphere/_async/core/base.py b/src/codesphere/_async/core/base.py new file mode 100644 index 0000000..36f3dd3 --- /dev/null +++ b/src/codesphere/_async/core/base.py @@ -0,0 +1,100 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +import httpx +from pydantic import PrivateAttr + +from codesphere.core.models import CamelModel +from codesphere.core.models import ResourceList as ResourceList +from codesphere.core.operations import APIOperation +from codesphere.feature_flags import FlagRequirement + +from ..http_client import APIHttpClient +from .handler import RequestData, execute_operation + +ResponseT = TypeVar("ResponseT") + + +class ResourceBase: + """Base for resource and manager classes bound to an HTTP client.""" + + def __init__(self, http_client: APIHttpClient): + self._http_client = http_client + + async def _execute( + self, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, + **path_params: Any, + ) -> ResponseT: + return await execute_operation( + self._http_client, + op, + data=data, + params=params, + path_params=path_params, + timeout=timeout, + ) + + async def _require_flag(self, requirement: FlagRequirement) -> None: + """Fail fast unless the platform flag is enabled (non-operation paths).""" + await self._http_client.flags_store.require(requirement) + + +class TeamScopedResource(ResourceBase): + """Base for managers operating within one team.""" + + def __init__(self, http_client: APIHttpClient, team_id: int): + super().__init__(http_client) + self.team_id = team_id + + +class WorkspaceScopedResource(ResourceBase): + """Base for managers operating within one workspace.""" + + def __init__(self, http_client: APIHttpClient, workspace_id: int): + super().__init__(http_client) + self.workspace_id = workspace_id + + +class BoundModel(CamelModel): + """API entity that carries the HTTP client it was fetched with. + + Instances returned by the SDK get their client attached automatically, + which lets entity methods (e.g. ``workspace.delete()``) make further + API calls. + """ + + _http_client: APIHttpClient | None = PrivateAttr(default=None) + + def _client(self) -> APIHttpClient: + if self._http_client is None or not hasattr(self._http_client, "request"): + raise RuntimeError( + "Cannot access resource on a detached model. HTTP client missing." + ) + return self._http_client + + async def _execute( + self, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, + **path_params: Any, + ) -> ResponseT: + return await execute_operation( + self._client(), + op, + data=data, + params=params, + path_params=path_params, + timeout=timeout, + ) + + async def _require_flag(self, requirement: FlagRequirement) -> None: + """Fail fast unless the platform flag is enabled (non-operation paths).""" + await self._client().flags_store.require(requirement) diff --git a/src/codesphere/_async/core/handler.py b/src/codesphere/_async/core/handler.py new file mode 100644 index 0000000..5620efa --- /dev/null +++ b/src/codesphere/_async/core/handler.py @@ -0,0 +1,123 @@ +import logging +from collections.abc import Mapping, Sequence +from types import NoneType +from typing import Any, TypeVar, cast + +import httpx +from pydantic import BaseModel, RootModel, ValidationError + +from codesphere.core.operations import APIOperation + +from ..http_client import APIHttpClient + +log = logging.getLogger(__name__) + +ResponseT = TypeVar("ResponseT") + +type RequestData = BaseModel | Mapping[str, Any] | Sequence[Any] + + +async def execute_operation( + client: APIHttpClient, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + path_params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, +) -> ResponseT: + """Execute a declarative :class:`APIOperation` against the API. + + Path parameters are passed explicitly and formatted into + ``op.endpoint_template``. When ``op.input_model`` is set, ``data`` is + validated against it before the request is sent. A non-``None`` ``data`` + is always sent as the JSON body, even when empty. When the operation + declares ``required_flag``, the flag is checked first — nothing is + sent if the connected instance does not have it enabled. A ``timeout`` + overrides the client-wide timeout for this call only (applied per + retry attempt). + """ + if op.required_flag is not None: + await client.flags_store.require(op.required_flag) + + endpoint = _format_endpoint(op.endpoint_template, path_params or {}) + + request_kwargs: dict[str, Any] = {} + if params is not None: + request_kwargs["params"] = params + if data is not None: + request_kwargs["json"] = _serialize_payload(data, op.input_model) + if timeout is not None: + request_kwargs["timeout"] = timeout + + response = await client.request( + method=op.method, endpoint=endpoint, **request_kwargs + ) + return _parse_response(response, op.response_model, client, endpoint) + + +def _format_endpoint(template: str, path_params: Mapping[str, Any]) -> str: + try: + return template.format(**path_params) + except KeyError as e: + raise ValueError( + f"Missing path parameter {e.args[0]!r} for endpoint template {template!r}" + ) from e + + +def _serialize_payload(data: RequestData, input_model: type[BaseModel] | None) -> Any: + if input_model is not None: + model = ( + data if isinstance(data, input_model) else input_model.model_validate(data) + ) + return model.model_dump(exclude_none=True) + if isinstance(data, BaseModel): + return data.model_dump(exclude_none=True) + return data + + +def _parse_response( + response: httpx.Response, + response_model: type[ResponseT], + client: APIHttpClient | None, + endpoint_for_logging: str, +) -> ResponseT: + if response_model is NoneType: + return cast(ResponseT, None) + + try: + json_response = response.json() + log.debug(f"Validating JSON response for endpoint: {endpoint_for_logging}") + except httpx.ResponseNotRead: + log.warning(f"No JSON response body for {endpoint_for_logging}") + return cast(ResponseT, None) + + model_cls = cast(type[BaseModel], response_model) + try: + instance = model_cls.model_validate(json_response) + except ValidationError as e: + # Do not log the response body or the ValidationError itself: both + # may contain secret values (e.g. env vars). Log locations only. + locations = sorted({".".join(map(str, err["loc"])) for err in e.errors()}) + log.error( + f"Pydantic validation failed for {endpoint_for_logging} " + f"({e.error_count()} errors at: {', '.join(locations) or ''})" + ) + raise + + _bind_client(instance, client) + log.debug("Successfully validated response for %s.", endpoint_for_logging) + return cast(ResponseT, instance) + + +def _bind_client(instance: BaseModel, client: APIHttpClient | None) -> None: + """Attach the HTTP client to returned models so they can make calls.""" + from .base import BoundModel + + if isinstance(instance, BoundModel): + instance._http_client = client + + if isinstance(instance, RootModel) and isinstance(instance.root, list): + for item in instance.root: + if isinstance(item, BoundModel): + item._http_client = client diff --git a/src/codesphere/_async/flags_store.py b/src/codesphere/_async/flags_store.py new file mode 100644 index 0000000..1b0ad1b --- /dev/null +++ b/src/codesphere/_async/flags_store.py @@ -0,0 +1,74 @@ +"""Per-client feature-flags cache (async implementation).""" + +from typing import TYPE_CHECKING + +from codesphere.exceptions import ( + FeatureNotAvailableError, + FeatureNotEnabledError, + NotFoundError, +) +from codesphere.feature_flags import ( + FLAGS_ENDPOINT, + FlagRequirement, + FlagsSnapshot, +) + +from ._compat import Lock + +if TYPE_CHECKING: + from .http_client import APIHttpClient + + +class FlagsStore: + """Per-client cache of the platform's feature flags. + + Fetches ``GET /ide-service/flags`` lazily on first need (under a lock, + so concurrent gated calls trigger one request), then caches for the + client's lifetime. A 404 — the instance predates feature flags — is + cached as an empty snapshot: reads report nothing available and gated + calls fail closed. Transient errors propagate uncached. + """ + + def __init__(self, client: "APIHttpClient") -> None: + self._client = client + self._snapshot: FlagsSnapshot | None = None + self._legacy_platform = False + self._lock = Lock() + + async def get(self, *, refresh: bool = False) -> FlagsSnapshot: + async with self._lock: + if self._snapshot is None or refresh: + self._snapshot = await self._fetch() + return self._snapshot + + async def _fetch(self) -> FlagsSnapshot: + try: + response = await self._client.request("GET", FLAGS_ENDPOINT) + except NotFoundError: + self._legacy_platform = True + return FlagsSnapshot.empty() + self._legacy_platform = False + return FlagsSnapshot.model_validate(response.json()) + + async def require(self, requirement: FlagRequirement) -> None: + """Raise unless the required flag is enabled on this instance.""" + snapshot = await self.get() + flag, category = requirement.flag, requirement.category + category_str = str(category) if category is not None else None + + if not snapshot.is_available(flag, category): + raise FeatureNotAvailableError( + flag=flag, + category=category_str, + legacy_platform=self._legacy_platform, + ) + if not snapshot.is_enabled(flag, category): + raise FeatureNotEnabledError(flag=flag, category=category_str) + + def cached(self) -> FlagsSnapshot | None: + """The current snapshot, or None if nothing has been fetched yet.""" + return self._snapshot + + def invalidate(self) -> None: + """Drop the cached snapshot; the next need re-fetches.""" + self._snapshot = None diff --git a/src/codesphere/_async/http_client.py b/src/codesphere/_async/http_client.py new file mode 100644 index 0000000..554118b --- /dev/null +++ b/src/codesphere/_async/http_client.py @@ -0,0 +1,197 @@ +import asyncio +import logging +import random +from contextlib import AbstractAsyncContextManager +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime +from types import TracebackType +from typing import Any, cast + +import httpx +from pydantic import SecretStr + +from codesphere.config import RetryConfig +from codesphere.exceptions import NetworkError, TimeoutError, raise_for_status + +from .flags_store import FlagsStore + +log = logging.getLogger(__name__) + + +def _parse_retry_after(value: str | None) -> float | None: + """Parse a Retry-After header: delta-seconds or an HTTP-date.""" + if not value: + return None + if value.isdigit(): + return float(value) + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + return max(0.0, (retry_at - datetime.now(UTC)).total_seconds()) + + +class APIHttpClient: + """Thin transport around httpx. + + Receives its full configuration from the caller (the SDK resolves + arguments, environment variables, and defaults) and owns nothing but + the connection lifecycle, error mapping, and opt-in retries. + """ + + def __init__( + self, + *, + token: SecretStr, + base_url: str, + timeout: httpx.Timeout, + retry: RetryConfig | None = None, + ): + self._token = token.get_secret_value() + self._base_url = base_url + self._client: httpx.AsyncClient | None = None + self._retry = retry if retry is not None else RetryConfig() + # Per-connection feature-flags cache; the transport never reads it + # itself (see feature_flags.FlagsStore). + self.flags_store = FlagsStore(self) + + self._timeout_config = timeout + self._client_config: dict[str, Any] = { + "base_url": self._base_url, + "headers": {"Authorization": f"Bearer {self._token}"}, + "timeout": self._timeout_config, + } + + def _get_client(self) -> httpx.AsyncClient: + if not self._client: + raise RuntimeError( + "Client is not open. Use the client as a context manager " + "or call open() before making requests." + ) + return self._client + + @property + def timeout(self) -> httpx.Timeout: + """The configured request timeouts.""" + return self._timeout_config + + @property + def stream_timeout(self) -> httpx.Timeout: + """Timeouts for long-lived streams: configured connect, unbounded read.""" + # httpx.Timeout attributes are untyped upstream; connect is float | None + connect = cast("float | None", self._timeout_config.connect) + return httpx.Timeout(connect, read=None) + + def stream( + self, method: str, endpoint: str, **kwargs: Any + ) -> AbstractAsyncContextManager[httpx.Response]: + """Open a streaming request (e.g. SSE) on the underlying client.""" + return self._get_client().stream(method, endpoint, **kwargs) + + async def open(self) -> None: + if not self._client: + self._client = httpx.AsyncClient(**self._client_config) + await self._client.__aenter__() + + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: + if self._client: + await self._client.__aexit__(exc_type, exc_val, exc_tb) + self._client = None + + async def __aenter__(self) -> "APIHttpClient": + await self.open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close(exc_type, exc_val, exc_tb) + + async def request( + self, method: str, endpoint: str, **kwargs: Any + ) -> httpx.Response: + client = self._get_client() + + log.debug(f"Request: {method} {endpoint}") + # Never log bodies or headers: request payloads (e.g. env vars) + # can contain secrets. + if kwargs: + summary = ", ".join( + f"params={value!r}" + if key == "params" + else f"{key}=<{type(value).__name__} omitted>" + for key, value in kwargs.items() + ) + log.debug(f"Request options: {summary}") + + retryable_method = method.upper() in self._retry.retry_methods + attempts = self._retry.max_retries + 1 if retryable_method else 1 + + for attempt in range(attempts): + last_attempt = attempt == attempts - 1 + try: + response = await client.request(method, endpoint, **kwargs) + except httpx.TimeoutException as e: + if not last_attempt: + await self._sleep_before_retry(attempt, None, method, endpoint) + continue + log.error(f"Request timeout for {method} {endpoint}: {e}") + raise TimeoutError(f"Request to {endpoint} timed out.") from e + except httpx.ConnectError as e: + if not last_attempt: + await self._sleep_before_retry(attempt, None, method, endpoint) + continue + log.error(f"Connection error for {method} {endpoint}: {e}") + raise NetworkError( + f"Failed to connect to the API: {e}", + original_error=e, + ) from e + except httpx.RequestError as e: + log.error(f"Network error for {method} {endpoint}: {e}") + raise NetworkError( + f"A network error occurred: {e}", + original_error=e, + ) from e + + log.debug( + f"Response: {response.status_code} {response.reason_phrase} " + f"for {method} {endpoint}" + ) + + if response.status_code in self._retry.retry_statuses and not last_attempt: + await self._sleep_before_retry(attempt, response, method, endpoint) + continue + + raise_for_status(response) + return response + + raise AssertionError("unreachable: retry loop must return or raise") + + async def _sleep_before_retry( + self, + attempt: int, + response: httpx.Response | None, + method: str, + endpoint: str, + ) -> None: + delay = None + if response is not None: + delay = _parse_retry_after(response.headers.get("Retry-After")) + if delay is None: + delay = self._retry.backoff_factor * (2**attempt) + # Jitter to avoid thundering herds; not security-relevant. + delay += random.uniform(0, self._retry.backoff_factor / 2) # nosec B311 + + log.debug( + f"Retrying {method} {endpoint} in {delay:.2f}s " + f"(attempt {attempt + 1}/{self._retry.max_retries})" + ) + await asyncio.sleep(delay) diff --git a/src/codesphere/_async/resources/__init__.py b/src/codesphere/_async/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codesphere/_async/resources/flags/__init__.py b/src/codesphere/_async/resources/flags/__init__.py new file mode 100644 index 0000000..da904cb --- /dev/null +++ b/src/codesphere/_async/resources/flags/__init__.py @@ -0,0 +1,17 @@ +"""Feature flags of the connected Codesphere instance (``sdk.flags``). + +This package deviates from the canonical resource layout in one way: +there is no ``operations.py``. The fetch lives in +:class:`codesphere.feature_flags.FlagsStore` so that ``sdk.flags`` and +operation gating (``APIOperation.required_flag``) share a single cache. +""" + +from .resources import FlagsResource +from .schemas import CategoryFlags, FlagCategory, FlagsSnapshot + +__all__ = [ + "CategoryFlags", + "FlagCategory", + "FlagsResource", + "FlagsSnapshot", +] diff --git a/src/codesphere/_async/resources/flags/resources.py b/src/codesphere/_async/resources/flags/resources.py new file mode 100644 index 0000000..d123d52 --- /dev/null +++ b/src/codesphere/_async/resources/flags/resources.py @@ -0,0 +1,38 @@ +from codesphere.feature_flags import FlagCategory, FlagRequirement, FlagsSnapshot + +from ...core import ResourceBase + + +class FlagsResource(ResourceBase): + """Read the feature flags of the connected Codesphere instance. + + The snapshot is fetched lazily and cached for the client's lifetime; + :meth:`refresh` re-fetches. The same cache backs operation gating, so + a refresh here also affects gated SDK features. + """ + + async def get(self, *, refresh: bool = False) -> FlagsSnapshot: + """The instance's flags snapshot (cached after the first call).""" + return await self._http_client.flags_store.get(refresh=refresh) + + async def refresh(self) -> FlagsSnapshot: + """Re-fetch the flags from the platform.""" + return await self.get(refresh=True) + + async def is_available( + self, flag: str, category: FlagCategory | None = None + ) -> bool: + """Whether ``flag`` exists on this instance (any or one category).""" + snapshot = await self.get() + return snapshot.is_available(flag, category) + + async def is_enabled(self, flag: str, category: FlagCategory | None = None) -> bool: + """Whether ``flag`` is enabled on this instance (any or one category).""" + snapshot = await self.get() + return snapshot.is_enabled(flag, category) + + async def require(self, flag: str, category: FlagCategory | None = None) -> None: + """Raise :class:`~codesphere.FeatureFlagError` unless ``flag`` is enabled.""" + await self._http_client.flags_store.require( + FlagRequirement(flag=flag, category=category) + ) diff --git a/src/codesphere/_async/resources/flags/schemas.py b/src/codesphere/_async/resources/flags/schemas.py new file mode 100644 index 0000000..d3b96ce --- /dev/null +++ b/src/codesphere/_async/resources/flags/schemas.py @@ -0,0 +1,9 @@ +"""Flag schemas — re-exported from the engine module. + +The models live in :mod:`codesphere.feature_flags` (top level) to keep the +import graph cycle-free; see that module's docstring. +""" + +from codesphere.feature_flags import CategoryFlags, FlagCategory, FlagsSnapshot + +__all__ = ["CategoryFlags", "FlagCategory", "FlagsSnapshot"] diff --git a/src/codesphere/_async/resources/metadata/__init__.py b/src/codesphere/_async/resources/metadata/__init__.py new file mode 100644 index 0000000..178d70d --- /dev/null +++ b/src/codesphere/_async/resources/metadata/__init__.py @@ -0,0 +1,6 @@ +"""Metadata Resource & Models""" + +from .resources import MetadataResource +from .schemas import Characteristic, Datacenter, Image, WsPlan + +__all__ = ["Characteristic", "Datacenter", "Image", "MetadataResource", "WsPlan"] diff --git a/src/codesphere/_async/resources/metadata/operations.py b/src/codesphere/_async/resources/metadata/operations.py new file mode 100644 index 0000000..0c2cf1d --- /dev/null +++ b/src/codesphere/_async/resources/metadata/operations.py @@ -0,0 +1,22 @@ +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import Datacenter, Image, WsPlan + +_LIST_DC_OP = APIOperation( + method="GET", + endpoint_template="/metadata/datacenters", + response_model=ResourceList[Datacenter], +) + +_LIST_PLANS_OP = APIOperation( + method="GET", + endpoint_template="/metadata/workspace-plans", + response_model=ResourceList[WsPlan], +) + +_LIST_IMAGES_OP = APIOperation( + method="GET", + endpoint_template="/metadata/workspace-base-images", + response_model=ResourceList[Image], +) diff --git a/src/codesphere/_async/resources/metadata/resources.py b/src/codesphere/_async/resources/metadata/resources.py new file mode 100644 index 0000000..5641132 --- /dev/null +++ b/src/codesphere/_async/resources/metadata/resources.py @@ -0,0 +1,25 @@ +import httpx + +from ...core import ResourceBase +from .operations import _LIST_DC_OP, _LIST_IMAGES_OP, _LIST_PLANS_OP +from .schemas import Datacenter, Image, WsPlan + + +class MetadataResource(ResourceBase): + async def list_datacenters( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[Datacenter]: + result = await self._execute(_LIST_DC_OP, timeout=timeout) + return result.root + + async def list_plans( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[WsPlan]: + result = await self._execute(_LIST_PLANS_OP, timeout=timeout) + return result.root + + async def list_images( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[Image]: + result = await self._execute(_LIST_IMAGES_OP, timeout=timeout) + return result.root diff --git a/src/codesphere/_async/resources/metadata/schemas.py b/src/codesphere/_async/resources/metadata/schemas.py new file mode 100644 index 0000000..6def418 --- /dev/null +++ b/src/codesphere/_async/resources/metadata/schemas.py @@ -0,0 +1,3 @@ +from codesphere.models.metadata import Characteristic, Datacenter, Image, WsPlan + +__all__ = ["Characteristic", "Datacenter", "Image", "WsPlan"] diff --git a/src/codesphere/_async/resources/team/__init__.py b/src/codesphere/_async/resources/team/__init__.py new file mode 100644 index 0000000..5734d43 --- /dev/null +++ b/src/codesphere/_async/resources/team/__init__.py @@ -0,0 +1,39 @@ +from .domain import ( + CustomDomainConfig, + Domain, + DomainBase, + DomainRouting, + DomainVerificationStatus, + TeamDomainManager, +) +from .resources import TeamsResource +from .schemas import Team, TeamBase, TeamCreate +from .usage import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + PaginatedResponse, + ServiceAction, + TeamUsageManager, + UsageEventsResponse, + UsageSummaryResponse, +) + +__all__ = [ + "CustomDomainConfig", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "Team", + "TeamBase", + "TeamCreate", + "TeamDomainManager", + "TeamUsageManager", + "TeamsResource", + "UsageEventsResponse", + "UsageSummaryResponse", +] diff --git a/src/codesphere/_async/resources/team/domain/__init__.py b/src/codesphere/_async/resources/team/domain/__init__.py new file mode 100644 index 0000000..fb53171 --- /dev/null +++ b/src/codesphere/_async/resources/team/domain/__init__.py @@ -0,0 +1,19 @@ +from .resources import TeamDomainManager +from .schemas import ( + CustomDomainConfig, + Domain, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) + +__all__ = [ + "CustomDomainConfig", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "RoutingMap", + "TeamDomainManager", +] diff --git a/src/codesphere/_async/resources/team/domain/operations.py b/src/codesphere/_async/resources/team/domain/operations.py new file mode 100644 index 0000000..7ca8275 --- /dev/null +++ b/src/codesphere/_async/resources/team/domain/operations.py @@ -0,0 +1,49 @@ +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import CustomDomainConfig, Domain, DomainVerificationStatus + +_LIST_OP = APIOperation( + method="GET", + endpoint_template="/domains/team/{team_id}", + response_model=ResourceList[Domain], +) + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=Domain, +) + +_CREATE_OP = APIOperation( + method="POST", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=Domain, +) + +_UPDATE_OP = APIOperation( + method="PATCH", + endpoint_template="/domains/team/{team_id}/domain/{name}", + input_model=CustomDomainConfig, + response_model=Domain, +) + +_UPDATE_WS_OP = APIOperation( + method="PUT", + endpoint_template="/domains/team/{team_id}/domain/{name}/workspace-connections", + response_model=Domain, +) + +_VERIFY_OP = APIOperation( + method="POST", + endpoint_template="/domains/team/{team_id}/domain/{name}/verify", + response_model=DomainVerificationStatus, +) + +_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=NoneType, +) diff --git a/src/codesphere/_async/resources/team/domain/resources.py b/src/codesphere/_async/resources/team/domain/resources.py new file mode 100644 index 0000000..784c668 --- /dev/null +++ b/src/codesphere/_async/resources/team/domain/resources.py @@ -0,0 +1,56 @@ +import httpx + +from ....core.base import TeamScopedResource +from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP +from .schemas import CustomDomainConfig, Domain, DomainRouting, RoutingMap + + +class TeamDomainManager(TeamScopedResource): + async def list( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[Domain]: + result = await self._execute(_LIST_OP, team_id=self.team_id, timeout=timeout) + return result.root + + async def get( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> Domain: + return await self._execute( + _GET_OP, team_id=self.team_id, name=name, timeout=timeout + ) + + async def create( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> Domain: + return await self._execute( + _CREATE_OP, team_id=self.team_id, name=name, timeout=timeout + ) + + async def update( + self, + name: str, + config: CustomDomainConfig, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + return await self._execute( + _UPDATE_OP, team_id=self.team_id, name=name, data=config, timeout=timeout + ) + + async def update_workspace_connections( + self, + name: str, + connections: DomainRouting | RoutingMap, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + payload = ( + connections.root if isinstance(connections, DomainRouting) else connections + ) + return await self._execute( + _UPDATE_WS_OP, + team_id=self.team_id, + name=name, + data=payload, + timeout=timeout, + ) diff --git a/src/codesphere/_async/resources/team/domain/schemas.py b/src/codesphere/_async/resources/team/domain/schemas.py new file mode 100644 index 0000000..c35cef0 --- /dev/null +++ b/src/codesphere/_async/resources/team/domain/schemas.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import httpx + +from codesphere.models.domain import ( + CertificateRequestStatus, + CustomDomainConfig, + DNSEntries, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) +from codesphere.utils import update_model_fields + +from ....core.base import BoundModel + +__all__ = [ + "CertificateRequestStatus", + "CustomDomainConfig", + "DNSEntries", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "RoutingMap", +] + + +class Domain(DomainBase, BoundModel): + async def update( + self, + data: CustomDomainConfig, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + from .operations import _UPDATE_OP + + response = await self._execute( + _UPDATE_OP, team_id=self.team_id, name=self.name, data=data, timeout=timeout + ) + update_model_fields(target=self, source=response) + return response + + async def update_workspace_connections( + self, + connections: DomainRouting | RoutingMap, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + from .operations import _UPDATE_WS_OP + + payload = ( + connections.root if isinstance(connections, DomainRouting) else connections + ) + response = await self._execute( + _UPDATE_WS_OP, + team_id=self.team_id, + name=self.name, + data=payload, + timeout=timeout, + ) + update_model_fields(target=self, source=response) + return response + + async def verify_status( + self, *, timeout: httpx.Timeout | float | None = None + ) -> DomainVerificationStatus: + from .operations import _VERIFY_OP + + response = await self._execute( + _VERIFY_OP, team_id=self.team_id, name=self.name, timeout=timeout + ) + update_model_fields(target=self.domain_verification_status, source=response) + return response + + async def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_OP + + await self._execute( + _DELETE_OP, team_id=self.team_id, name=self.name, timeout=timeout + ) diff --git a/src/codesphere/_async/resources/team/operations.py b/src/codesphere/_async/resources/team/operations.py new file mode 100644 index 0000000..1adec20 --- /dev/null +++ b/src/codesphere/_async/resources/team/operations.py @@ -0,0 +1,31 @@ +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import Team, TeamCreate + +_LIST_TEAMS_OP = APIOperation( + method="GET", + endpoint_template="/teams", + response_model=ResourceList[Team], +) + +_GET_TEAM_OP = APIOperation( + method="GET", + endpoint_template="/teams/{team_id}", + response_model=Team, +) + +_CREATE_TEAM_OP = APIOperation( + method="POST", + endpoint_template="/teams", + input_model=TeamCreate, + response_model=Team, +) + +_DELETE_TEAM_OP = APIOperation( + method="DELETE", + endpoint_template="/teams/{team_id}", + response_model=NoneType, +) diff --git a/src/codesphere/_async/resources/team/resources.py b/src/codesphere/_async/resources/team/resources.py new file mode 100644 index 0000000..bb2eb41 --- /dev/null +++ b/src/codesphere/_async/resources/team/resources.py @@ -0,0 +1,25 @@ +import httpx + +from ...core import ResourceBase +from .operations import ( + _CREATE_TEAM_OP, + _GET_TEAM_OP, + _LIST_TEAMS_OP, +) +from .schemas import Team, TeamCreate + + +class TeamsResource(ResourceBase): + async def list(self, *, timeout: httpx.Timeout | float | None = None) -> list[Team]: + result = await self._execute(_LIST_TEAMS_OP, timeout=timeout) + return result.root + + async def get( + self, team_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> Team: + return await self._execute(_GET_TEAM_OP, team_id=team_id, timeout=timeout) + + async def create( + self, payload: TeamCreate, *, timeout: httpx.Timeout | float | None = None + ) -> Team: + return await self._execute(_CREATE_TEAM_OP, data=payload, timeout=timeout) diff --git a/src/codesphere/_async/resources/team/schemas.py b/src/codesphere/_async/resources/team/schemas.py new file mode 100644 index 0000000..e5e142c --- /dev/null +++ b/src/codesphere/_async/resources/team/schemas.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from functools import cached_property + +import httpx + +from codesphere.models.team import TeamBase +from codesphere.models.team import TeamCreate as TeamCreate + +from ...core.base import BoundModel +from .domain.resources import TeamDomainManager +from .usage.resources import TeamUsageManager + + +class Team(TeamBase, BoundModel): + async def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_TEAM_OP + + await self._execute(_DELETE_TEAM_OP, team_id=self.id, timeout=timeout) + + @cached_property + def domains(self) -> TeamDomainManager: + return TeamDomainManager(self._client(), team_id=self.id) + + @cached_property + def usage(self) -> TeamUsageManager: + return TeamUsageManager(self._client(), team_id=self.id) diff --git a/src/codesphere/_async/resources/team/usage/__init__.py b/src/codesphere/_async/resources/team/usage/__init__.py new file mode 100644 index 0000000..d53a802 --- /dev/null +++ b/src/codesphere/_async/resources/team/usage/__init__.py @@ -0,0 +1,21 @@ +"""Team usage history resources.""" + +from .resources import TeamUsageManager +from .schemas import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + PaginatedResponse, + ServiceAction, + UsageEventsResponse, + UsageSummaryResponse, +) + +__all__ = [ + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "TeamUsageManager", + "UsageEventsResponse", + "UsageSummaryResponse", +] diff --git a/src/codesphere/_async/resources/team/usage/operations.py b/src/codesphere/_async/resources/team/usage/operations.py new file mode 100644 index 0000000..8e57b40 --- /dev/null +++ b/src/codesphere/_async/resources/team/usage/operations.py @@ -0,0 +1,15 @@ +from codesphere.core.operations import APIOperation + +from .schemas import UsageEventsResponse, UsageSummaryResponse + +_GET_LANDSCAPE_SUMMARY_OP = APIOperation( + method="GET", + endpoint_template="/usage/teams/{team_id}/resources/landscape-service/summary", + response_model=UsageSummaryResponse, +) + +_GET_LANDSCAPE_EVENTS_OP = APIOperation( + method="GET", + endpoint_template="/usage/teams/{team_id}/resources/landscape-service/{resource_id}/events", + response_model=UsageEventsResponse, +) diff --git a/src/codesphere/_async/resources/team/usage/resources.py b/src/codesphere/_async/resources/team/usage/resources.py new file mode 100644 index 0000000..0457a95 --- /dev/null +++ b/src/codesphere/_async/resources/team/usage/resources.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import datetime +from functools import partial + +import httpx + +from ....core.base import TeamScopedResource +from .operations import _GET_LANDSCAPE_EVENTS_OP, _GET_LANDSCAPE_SUMMARY_OP +from .schemas import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + UsageEventsResponse, + UsageSummaryResponse, +) + + +def _clamp_page_size(value: int) -> int: + """The usage API accepts page sizes between 1 and 100.""" + return min(max(1, value), 100) + + +class TeamUsageManager(TeamScopedResource): + """Manager for team usage history operations. + + Provides access to landscape service usage summaries and events with + support for pagination and automatic iteration through all results. + + Example: + ```python + summary = await team.usage.get_landscape_summary( + begin_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 31), + limit=50 + ) + print(f"Total items: {summary.total_items}") + print(f"Page {summary.current_page} of {summary.total_pages}") + + for item in summary.items: + print(f"{item.resource_name}: {item.usage_seconds}s") + + await summary.refresh() + + async for item in team.usage.iter_all_landscape_summary( + begin_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 31) + ): + print(f"{item.resource_name}: {item.usage_seconds}s") + ``` + """ + + async def get_landscape_summary( + self, + begin_date: datetime | str, + end_date: datetime | str, + limit: int = 25, + offset: int = 0, + *, + timeout: httpx.Timeout | float | None = None, + ) -> UsageSummaryResponse: + params = { + "beginDate": begin_date.isoformat() + if isinstance(begin_date, datetime) + else begin_date, + "endDate": end_date.isoformat() + if isinstance(end_date, datetime) + else end_date, + "limit": _clamp_page_size(limit), + "offset": max(0, offset), + } + result = await self._execute( + _GET_LANDSCAPE_SUMMARY_OP, + team_id=self.team_id, + params=params, + timeout=timeout, + ) + + result._refresh_op = partial( + self._execute, _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id + ) + result._team_id = self.team_id + + return result + + async def iter_all_landscape_summary( + self, + begin_date: datetime | str, + end_date: datetime | str, + page_size: int = 100, + *, + timeout: httpx.Timeout | float | None = None, + ) -> AsyncIterator[LandscapeServiceSummary]: + offset = 0 + page_size = _clamp_page_size(page_size) + + while True: + response = await self.get_landscape_summary( + begin_date=begin_date, + end_date=end_date, + limit=page_size, + offset=offset, + timeout=timeout, + ) + + for item in response.items: + yield item + + if not response.has_next_page: + break + + offset += page_size + + async def get_landscape_events( + self, + resource_id: str, + begin_date: datetime | str, + end_date: datetime | str, + limit: int = 25, + offset: int = 0, + *, + timeout: httpx.Timeout | float | None = None, + ) -> UsageEventsResponse: + params = { + "beginDate": begin_date.isoformat() + if isinstance(begin_date, datetime) + else begin_date, + "endDate": end_date.isoformat() + if isinstance(end_date, datetime) + else end_date, + "limit": _clamp_page_size(limit), + "offset": max(0, offset), + } + result = await self._execute( + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, + params=params, + timeout=timeout, + ) + + result._refresh_op = partial( + self._execute, + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, + ) + result._team_id = self.team_id + result._resource_id = resource_id + + return result + + async def iter_all_landscape_events( + self, + resource_id: str, + begin_date: datetime | str, + end_date: datetime | str, + page_size: int = 100, + *, + timeout: httpx.Timeout | float | None = None, + ) -> AsyncIterator[LandscapeServiceEvent]: + offset = 0 + page_size = _clamp_page_size(page_size) + + while True: + response = await self.get_landscape_events( + resource_id=resource_id, + begin_date=begin_date, + end_date=end_date, + limit=page_size, + offset=offset, + timeout=timeout, + ) + + for item in response.items: + yield item + + if not response.has_next_page: + break + + offset += page_size diff --git a/src/codesphere/_async/resources/team/usage/schemas.py b/src/codesphere/_async/resources/team/usage/schemas.py new file mode 100644 index 0000000..03ea32d --- /dev/null +++ b/src/codesphere/_async/resources/team/usage/schemas.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any, Generic, Self, TypeVar + +import httpx +from pydantic import Field + +from codesphere.models.usage import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + ServiceAction, +) + +from ....core.base import CamelModel + +__all__ = [ + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "UsageEventsResponse", + "UsageSummaryResponse", +] + +ItemT = TypeVar("ItemT") + + +class PaginatedResponse(CamelModel, Generic[ItemT]): + total_items: int + limit: int = 25 + offset: int = 0 + begin_date: datetime + end_date: datetime + + # Loosely typed so the sync flavor (no Awaitable) shares the shape. + _refresh_op: Callable[..., Any] | None = None + + @property + def has_next_page(self) -> bool: + return (self.offset + self.limit) < self.total_items + + @property + def has_prev_page(self) -> bool: + return self.offset > 0 + + @property + def current_page(self) -> int: + return (self.offset // self.limit) + 1 + + @property + def total_pages(self) -> int: + if self.total_items == 0: + return 1 + return (self.total_items + self.limit - 1) // self.limit + + async def refresh(self, *, timeout: httpx.Timeout | float | None = None) -> Self: + """Re-fetch this page with the same query parameters, in place.""" + if self._refresh_op is None: + raise RuntimeError( + "Refresh operation not available. Use manager methods instead." + ) + result = await self._refresh_op( + params={ + "beginDate": self.begin_date.isoformat(), + "endDate": self.end_date.isoformat(), + "limit": self.limit, + "offset": self.offset, + }, + timeout=timeout, + ) + for field_name in result.model_fields_set: + setattr(self, field_name, getattr(result, field_name)) + return self + + +class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): + summary: list[LandscapeServiceSummary] = Field(default_factory=list) + _team_id: int | None = None + + @property + def items(self) -> list[LandscapeServiceSummary]: + return self.summary + + +class UsageEventsResponse(PaginatedResponse[LandscapeServiceEvent]): + events: list[LandscapeServiceEvent] = Field(default_factory=list) + + _team_id: int | None = None + _resource_id: str | None = None + + @property + def items(self) -> list[LandscapeServiceEvent]: + return self.events diff --git a/src/codesphere/_async/resources/workspace/__init__.py b/src/codesphere/_async/resources/workspace/__init__.py new file mode 100644 index 0000000..f8bebba --- /dev/null +++ b/src/codesphere/_async/resources/workspace/__init__.py @@ -0,0 +1,28 @@ +from .git import GitHead, WorkspaceGitManager +from .logs import LogEntry, LogProblem, LogStage, LogStream, WorkspaceLogManager +from .resources import WorkspacesResource +from .schemas import ( + CommandInput, + CommandOutput, + Workspace, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, +) + +__all__ = [ + "CommandInput", + "CommandOutput", + "GitHead", + "LogEntry", + "LogProblem", + "LogStage", + "LogStream", + "Workspace", + "WorkspaceCreate", + "WorkspaceGitManager", + "WorkspaceLogManager", + "WorkspaceStatus", + "WorkspaceUpdate", + "WorkspacesResource", +] diff --git a/src/codesphere/_async/resources/workspace/env_vars/__init__.py b/src/codesphere/_async/resources/workspace/env_vars/__init__.py new file mode 100644 index 0000000..8e2327d --- /dev/null +++ b/src/codesphere/_async/resources/workspace/env_vars/__init__.py @@ -0,0 +1,4 @@ +from .resources import WorkspaceEnvVarManager +from .schemas import EnvVar + +__all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/_async/resources/workspace/env_vars/operations.py b/src/codesphere/_async/resources/workspace/env_vars/operations.py new file mode 100644 index 0000000..102a1a9 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/env_vars/operations.py @@ -0,0 +1,24 @@ +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import EnvVar + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/env-vars", + response_model=ResourceList[EnvVar], +) + +_BULK_SET_OP = APIOperation( + method="PUT", + endpoint_template="/workspaces/{id}/env-vars", + response_model=NoneType, +) + +_BULK_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}/env-vars", + response_model=NoneType, +) diff --git a/src/codesphere/_async/resources/workspace/env_vars/resources.py b/src/codesphere/_async/resources/workspace/env_vars/resources.py new file mode 100644 index 0000000..4a949c9 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/env_vars/resources.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import httpx + +from ....core.base import ResourceList, WorkspaceScopedResource +from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP +from .schemas import EnvVar + +log = logging.getLogger(__name__) + + +class WorkspaceEnvVarManager(WorkspaceScopedResource): + async def get( + self, *, timeout: httpx.Timeout | float | None = None + ) -> ResourceList[EnvVar]: + return await self._execute(_GET_OP, id=self.workspace_id, timeout=timeout) + + async def set( + self, + env_vars: ResourceList[EnvVar] | Sequence[EnvVar | dict[str, str]], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + payload = ResourceList[EnvVar].model_validate(env_vars) + await self._execute( + _BULK_SET_OP, id=self.workspace_id, data=payload, timeout=timeout + ) + + async def delete( + self, + items: Sequence[str | EnvVar | dict[str, str]] | ResourceList[EnvVar], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if not items: + return + payload: list[str] = [] + + for item in items: + if isinstance(item, str): + payload.append(item) + elif isinstance(item, EnvVar): + payload.append(item.name) + elif isinstance(item, dict) and "name" in item: + payload.append(item["name"]) + + if payload: + await self._execute( + _BULK_DELETE_OP, id=self.workspace_id, data=payload, timeout=timeout + ) diff --git a/src/codesphere/_async/resources/workspace/env_vars/schemas.py b/src/codesphere/_async/resources/workspace/env_vars/schemas.py new file mode 100644 index 0000000..f3b84d8 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/env_vars/schemas.py @@ -0,0 +1,3 @@ +from codesphere.models.env_vars import EnvVar + +__all__ = ["EnvVar"] diff --git a/src/codesphere/_async/resources/workspace/git/__init__.py b/src/codesphere/_async/resources/workspace/git/__init__.py new file mode 100644 index 0000000..e91a0c2 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/git/__init__.py @@ -0,0 +1,4 @@ +from .resources import WorkspaceGitManager +from .schemas import GitHead + +__all__ = ["GitHead", "WorkspaceGitManager"] diff --git a/src/codesphere/_async/resources/workspace/git/operations.py b/src/codesphere/_async/resources/workspace/git/operations.py new file mode 100644 index 0000000..3c74539 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/git/operations.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from types import NoneType + +from codesphere.core.operations import APIOperation + +from .schemas import GitHead + +_GET_HEAD_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/git/head", + response_model=GitHead, +) + +_PULL_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull", + response_model=NoneType, +) + +_PULL_WITH_REMOTE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}", + response_model=NoneType, +) + +_PULL_WITH_REMOTE_AND_BRANCH_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}/{branch}", + response_model=NoneType, +) diff --git a/src/codesphere/_async/resources/workspace/git/resources.py b/src/codesphere/_async/resources/workspace/git/resources.py new file mode 100644 index 0000000..4822bc8 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/git/resources.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging + +import httpx + +from ....core.base import WorkspaceScopedResource +from .operations import ( + _GET_HEAD_OP, + _PULL_OP, + _PULL_WITH_REMOTE_AND_BRANCH_OP, + _PULL_WITH_REMOTE_OP, +) +from .schemas import GitHead + +log = logging.getLogger(__name__) + + +class WorkspaceGitManager(WorkspaceScopedResource): + """Manager for git operations on a workspace.""" + + async def get_head( + self, *, timeout: httpx.Timeout | float | None = None + ) -> GitHead: + return await self._execute(_GET_HEAD_OP, id=self.workspace_id, timeout=timeout) + + async def pull( + self, + remote: str | None = None, + branch: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if remote is not None and branch is not None: + await self._execute( + _PULL_WITH_REMOTE_AND_BRANCH_OP, + id=self.workspace_id, + remote=remote, + branch=branch, + timeout=timeout, + ) + elif remote is not None: + await self._execute( + _PULL_WITH_REMOTE_OP, + id=self.workspace_id, + remote=remote, + timeout=timeout, + ) + else: + await self._execute(_PULL_OP, id=self.workspace_id, timeout=timeout) diff --git a/src/codesphere/_async/resources/workspace/git/schemas.py b/src/codesphere/_async/resources/workspace/git/schemas.py new file mode 100644 index 0000000..561fe8d --- /dev/null +++ b/src/codesphere/_async/resources/workspace/git/schemas.py @@ -0,0 +1,3 @@ +from codesphere.models.git import GitHead + +__all__ = ["GitHead"] diff --git a/src/codesphere/_async/resources/workspace/landscape/__init__.py b/src/codesphere/_async/resources/workspace/landscape/__init__.py new file mode 100644 index 0000000..cb77be2 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/landscape/__init__.py @@ -0,0 +1,41 @@ +from .resources import WorkspaceLandscapeManager +from .schemas import ( + ManagedServiceBuilder, + ManagedServiceConfig, + NetworkConfig, + PathConfig, + PipelineStage, + PipelineState, + PipelineStatus, + PipelineStatusList, + PortConfig, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceConfig, + StageConfig, + Step, + StepStatus, +) + +__all__ = [ + "ManagedServiceBuilder", + "ManagedServiceConfig", + "NetworkConfig", + "PathConfig", + "PipelineStage", + "PipelineState", + "PipelineStatus", + "PipelineStatusList", + "PortConfig", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "ReactiveServiceBuilder", + "ReactiveServiceConfig", + "StageConfig", + "Step", + "StepStatus", + "WorkspaceLandscapeManager", +] diff --git a/src/codesphere/_async/resources/workspace/landscape/operations.py b/src/codesphere/_async/resources/workspace/landscape/operations.py new file mode 100644 index 0000000..7716f66 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/landscape/operations.py @@ -0,0 +1,54 @@ +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import PipelineStatus + +_DEPLOY_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy", + response_model=NoneType, +) + +_DEPLOY_WITH_PROFILE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy/{profile}", + response_model=NoneType, +) + +_TEARDOWN_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}/landscape/teardown", + response_model=NoneType, +) + +_SCALE_OP = APIOperation( + method="PATCH", + endpoint_template="/workspaces/{id}/landscape/scale", + response_model=NoneType, +) + +_START_PIPELINE_STAGE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/start", + response_model=NoneType, +) + +_START_PIPELINE_STAGE_WITH_PROFILE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/start/{profile}", + response_model=NoneType, +) + +_STOP_PIPELINE_STAGE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/stop", + response_model=NoneType, +) + +_GET_PIPELINE_STATUS_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/pipeline/{stage}", + response_model=ResourceList[PipelineStatus], +) diff --git a/src/codesphere/_async/resources/workspace/landscape/resources.py b/src/codesphere/_async/resources/workspace/landscape/resources.py new file mode 100644 index 0000000..e98e7b4 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/landscape/resources.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import asyncio +import base64 +import logging +import re +from typing import TYPE_CHECKING + +import httpx + +from ....core.base import ResourceList, WorkspaceScopedResource +from .operations import ( + _DEPLOY_OP, + _DEPLOY_WITH_PROFILE_OP, + _GET_PIPELINE_STATUS_OP, + _SCALE_OP, + _START_PIPELINE_STAGE_OP, + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + _STOP_PIPELINE_STAGE_OP, + _TEARDOWN_OP, +) +from .schemas import ( + PipelineStage, + PipelineState, + PipelineStatusList, + Profile, + ProfileConfig, +) + +if TYPE_CHECKING: + from ..schemas import CommandOutput + +log = logging.getLogger(__name__) + +# Regex pattern to match ci..yml files +_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") +# Pattern for valid profile names +_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_profile_name(name: str) -> None: + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + +def _profile_filename(name: str) -> str: + _validate_profile_name(name) + return f"ci.{name}.yml" + + +class WorkspaceLandscapeManager(WorkspaceScopedResource): + async def _run_command( + self, command: str, *, timeout: httpx.Timeout | float | None = None + ) -> CommandOutput: + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + return await self._execute( + _EXECUTE_COMMAND_OP, + id=self.workspace_id, + data=CommandInput(command=command), + timeout=timeout, + ) + + async def list_profiles( + self, *, timeout: httpx.Timeout | float | None = None + ) -> ResourceList[Profile]: + result = await self._run_command( + "ls -1 *.yml 2>/dev/null || true", timeout=timeout + ) + + profiles: list[Profile] = [] + if result.output: + for line in result.output.strip().split("\n"): + if match := _PROFILE_FILE_PATTERN.match(line.strip()): + profiles.append(Profile(name=match.group(1))) + + return ResourceList[Profile](root=profiles) + + async def save_profile( + self, + name: str, + config: ProfileConfig | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + filename = _profile_filename(name) + + yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config + + body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" + # Transport the content base64-encoded: the base64 alphabet is inert + # inside single quotes, so arbitrary YAML cannot break out of the + # shell command (the filename is regex-validated above). + encoded = base64.b64encode(body.encode("utf-8")).decode("ascii") + await self._run_command( + f"printf '%s' '{encoded}' | base64 -d > '{filename}'", timeout=timeout + ) + + async def get_profile( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> str: + result = await self._run_command( + f"cat '{_profile_filename(name)}'", timeout=timeout + ) + return result.output + + async def delete_profile( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> None: + await self._run_command(f"rm -f '{_profile_filename(name)}'", timeout=timeout) + + async def deploy( + self, + profile: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if profile is not None: + _validate_profile_name(profile) + await self._execute( + _DEPLOY_WITH_PROFILE_OP, + id=self.workspace_id, + profile=profile, + timeout=timeout, + ) + else: + await self._execute(_DEPLOY_OP, id=self.workspace_id, timeout=timeout) + + async def teardown(self, *, timeout: httpx.Timeout | float | None = None) -> None: + await self._execute(_TEARDOWN_OP, id=self.workspace_id, timeout=timeout) + + async def scale( + self, + services: dict[str, int], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + await self._execute( + _SCALE_OP, id=self.workspace_id, data=services, timeout=timeout + ) + + async def start_stage( + self, + stage: PipelineStage | str, + profile: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + if profile is not None: + _validate_profile_name(profile) + await self._execute( + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + id=self.workspace_id, + stage=stage, + profile=profile, + timeout=timeout, + ) + else: + await self._execute( + _START_PIPELINE_STAGE_OP, + id=self.workspace_id, + stage=stage, + timeout=timeout, + ) + + async def stop_stage( + self, + stage: PipelineStage | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + await self._execute( + _STOP_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage, timeout=timeout + ) + + async def get_stage_status( + self, + stage: PipelineStage | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> PipelineStatusList: + if isinstance(stage, PipelineStage): + stage = stage.value + + return await self._execute( + _GET_PIPELINE_STATUS_OP, + id=self.workspace_id, + stage=stage, + timeout=timeout, + ) + + async def wait_for_stage( + self, + stage: PipelineStage | str, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + server: str | None = None, + ) -> PipelineStatusList: + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + stage_name = stage.value if isinstance(stage, PipelineStage) else stage + elapsed = 0.0 + + while elapsed < timeout: + status_list = await self.get_stage_status(stage) + + relevant_statuses = [] + for s in status_list: + if server is not None: + if s.server == server: + relevant_statuses.append(s) + else: + if s.steps or s.state != PipelineState.WAITING: + relevant_statuses.append(s) + + if not relevant_statuses: + log.debug( + "Pipeline stage '%s': no servers with steps yet, waiting...", + stage_name, + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + continue + + all_completed = all( + s.state + in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED) + for s in relevant_statuses + ) + + if all_completed: + log.debug("Pipeline stage '%s' completed.", stage_name) + return PipelineStatusList(root=relevant_statuses) + + states = [f"{s.server}={s.state.value}" for s in relevant_statuses] + log.debug( + "Pipeline stage '%s' status: %s (elapsed: %.1fs)", + stage_name, + ", ".join(states), + elapsed, + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds." + ) diff --git a/src/codesphere/_async/resources/workspace/landscape/schemas.py b/src/codesphere/_async/resources/workspace/landscape/schemas.py new file mode 100644 index 0000000..5ffc098 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/landscape/schemas.py @@ -0,0 +1,53 @@ +from codesphere.models.landscape import ( + ManagedServiceBuilder, + ManagedServiceBuilderContext, + ManagedServiceConfig, + NetworkConfig, + PathBuilder, + PathConfig, + PipelineStage, + PipelineState, + PipelineStatus, + PipelineStatusList, + PortBuilder, + PortConfig, + PrepareStageBuilder, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceBuilderContext, + ReactiveServiceConfig, + StageConfig, + Step, + StepBuilder, + StepStatus, + TestStageBuilder, +) + +__all__ = [ + "ManagedServiceBuilder", + "ManagedServiceBuilderContext", + "ManagedServiceConfig", + "NetworkConfig", + "PathBuilder", + "PathConfig", + "PipelineStage", + "PipelineState", + "PipelineStatus", + "PipelineStatusList", + "PortBuilder", + "PortConfig", + "PrepareStageBuilder", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "ReactiveServiceBuilder", + "ReactiveServiceBuilderContext", + "ReactiveServiceConfig", + "StageConfig", + "Step", + "StepBuilder", + "StepStatus", + "TestStageBuilder", +] diff --git a/src/codesphere/_async/resources/workspace/logs/__init__.py b/src/codesphere/_async/resources/workspace/logs/__init__.py new file mode 100644 index 0000000..176ecfd --- /dev/null +++ b/src/codesphere/_async/resources/workspace/logs/__init__.py @@ -0,0 +1,22 @@ +from .resources import ( + LogStream, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, + WorkspaceLogManager, +) +from .schemas import LogEntry, LogKind, LogProblem, LogStage + +__all__ = [ + "LogEntry", + "LogKind", + "LogProblem", + "LogStage", + "LogStream", + "LogTarget", + "ReplicaTarget", + "ServerTarget", + "StageTarget", + "WorkspaceLogManager", +] diff --git a/src/codesphere/_async/resources/workspace/logs/operations.py b/src/codesphere/_async/resources/workspace/logs/operations.py new file mode 100644 index 0000000..9622f55 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/logs/operations.py @@ -0,0 +1,18 @@ +from codesphere.core.operations import StreamOperation + +from .schemas import LogEntry + +_STREAM_STAGE_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/{stage}/{step}", + entry_model=LogEntry, +) + +_STREAM_SERVER_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/run/{step}/server/{server}", + entry_model=LogEntry, +) + +_STREAM_REPLICA_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/run/{step}/replica/{replica}", + entry_model=LogEntry, +) diff --git a/src/codesphere/_async/resources/workspace/logs/resources.py b/src/codesphere/_async/resources/workspace/logs/resources.py new file mode 100644 index 0000000..83e41fb --- /dev/null +++ b/src/codesphere/_async/resources/workspace/logs/resources.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import json +import logging +import time +import warnings +from collections.abc import AsyncIterator +from typing import Any, cast + +import httpx + +from codesphere.core.operations import StreamOperation +from codesphere.exceptions import APIError, ValidationError, raise_for_status + +from ....core.base import WorkspaceScopedResource +from ....http_client import APIHttpClient +from .operations import ( + _STREAM_REPLICA_LOGS_OP, + _STREAM_SERVER_LOGS_OP, + _STREAM_STAGE_LOGS_OP, +) +from .schemas import ( + LogEntry, + LogProblem, + LogStage, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, +) + +log = logging.getLogger(__name__) + +# SSE event names that terminate a stream. +_STREAM_END_EVENTS = frozenset({"end", "close", "done", "complete"}) + + +def _resolve_target( + target: LogTarget, +) -> tuple[StreamOperation[LogEntry], dict[str, Any]]: + match target: + case StageTarget(stage=stage, step=step): + stage_value = stage.value if isinstance(stage, LogStage) else stage + return _STREAM_STAGE_LOGS_OP, {"stage": stage_value, "step": step} + case ServerTarget(step=step, server=server): + return _STREAM_SERVER_LOGS_OP, {"step": step, "server": server} + case ReplicaTarget(step=step, replica=replica): + return _STREAM_REPLICA_LOGS_OP, {"step": step, "replica": replica} + raise TypeError(f"Unsupported log target: {target!r}") + + +def _deprecated(old: str, new: str) -> None: + warnings.warn( + f"'{old}' is deprecated; use '{new}' instead.", + DeprecationWarning, + stacklevel=3, + ) + + +class LogStream: + """Async context manager for streaming logs via SSE.""" + + def __init__( + self, + http_client: APIHttpClient, + endpoint: str, + entry_model: type[LogEntry], + timeout: float | None = None, + ): + self._http_client = http_client + self._endpoint = endpoint + self._entry_model = entry_model + self._timeout = timeout + self._response: httpx.Response | None = None + self._stream_context: Any = None + + async def __aenter__(self) -> LogStream: + headers = {"Accept": "text/event-stream"} + # A user timeout bounds the read timeout too, so a silent stream + # wakes up instead of blocking past the deadline. + stream_timeout = self._http_client.stream_timeout + if self._timeout is not None: + connect = cast("float | None", stream_timeout.connect) + stream_timeout = httpx.Timeout(connect, read=self._timeout) + self._stream_context = self._http_client.stream( + "GET", + self._endpoint, + headers=headers, + timeout=stream_timeout, + ) + self._response = await self._stream_context.__aenter__() + + if not self._response.is_success: + await self._response.aread() + raise_for_status(self._response) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + if self._stream_context: + await self._stream_context.__aexit__(exc_type, exc_val, exc_tb) + + def __aiter__(self) -> AsyncIterator[LogEntry]: + return self._iterate() + + async def _iterate(self) -> AsyncIterator[LogEntry]: + if self._response is None: + raise RuntimeError("LogStream must be used as a context manager") + + if self._timeout is None: + async for entry in self._parse_sse_stream(): + yield entry + return + + # Total-duration deadline, checked between entries; blocked reads + # are bounded by the read timeout set in __aenter__. Best effort: + # a stream trickling bytes can overrun the deadline by up to one + # read-timeout interval. + deadline = time.monotonic() + self._timeout + try: + async for entry in self._parse_sse_stream(): + if time.monotonic() >= deadline: + raise TimeoutError( + f"Log stream exceeded timeout of {self._timeout}s" + ) + yield entry + except httpx.ReadTimeout as e: + raise TimeoutError( + f"Log stream exceeded timeout of {self._timeout}s" + ) from e + + async def _parse_sse_stream(self) -> AsyncIterator[LogEntry]: + if self._response is None: + raise RuntimeError("LogStream must be used as async context manager") + + event_type: str | None = None + data_buffer: list[str] = [] + + async for line in self._response.aiter_lines(): + line = line.strip() + + if not line: + if event_type and data_buffer: + data_str = "\n".join(data_buffer) + + if event_type in _STREAM_END_EVENTS: + return + + if event_type == "problem": + self._handle_problem(data_str) + elif event_type == "data": + for entry in self._parse_data(data_str): + yield entry + + elif event_type in _STREAM_END_EVENTS: + return + + event_type = None + data_buffer = [] + continue + + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_buffer.append(line[5:].strip()) + elif not line.startswith(":") and event_type: + data_buffer.append(line) + + def _parse_data(self, data_str: str) -> list[LogEntry]: + entries = [] + try: + json_data = json.loads(data_str) + if isinstance(json_data, list): + for item in json_data: + entries.append(self._entry_model.model_validate(item)) + else: + entries.append(self._entry_model.model_validate(json_data)) + except json.JSONDecodeError as e: + log.warning(f"Failed to parse log entry JSON: {e}") + except Exception as e: + log.warning(f"Failed to validate log entry: {e}") + return entries + + def _handle_problem(self, data_str: str) -> None: + try: + problem_data = json.loads(data_str) + problem = LogProblem.model_validate(problem_data) + + if problem.status == 400: + raise ValidationError( + message=problem.reason, + errors=[{"detail": problem.detail}] if problem.detail else None, + ) + else: + raise APIError( + message=problem.reason, + status_code=problem.status, + response_body=problem_data, + ) + except json.JSONDecodeError as e: + raise APIError(message=f"Invalid problem event: {data_str}") from e + + +class WorkspaceLogManager(WorkspaceScopedResource): + """Manager for streaming workspace logs via SSE. + + The unified API takes a :data:`LogTarget`: + + ```python + async for entry in workspace.logs.stream(StageTarget(LogStage.RUN, step=1)): + ... + entries = await workspace.logs.collect(ServerTarget(step=1, server="web")) + ``` + """ + + def _build_endpoint(self, operation: StreamOperation, **kwargs: Any) -> str: + return operation.endpoint_template.format(id=self.workspace_id, **kwargs) + + def open(self, target: LogTarget, timeout: float | None = None) -> LogStream: + """Open a log stream for ``target`` as an async context manager.""" + op, params = _resolve_target(target) + endpoint = self._build_endpoint(op, **params) + return LogStream(self._http_client, endpoint, op.entry_model, timeout=timeout) + + async def stream( + self, + target: LogTarget | LogStage | str, + step: int | None = None, + timeout: float | None = 30.0, + ) -> AsyncIterator[LogEntry]: + """Stream log entries for ``target`` as they arrive.""" + resolved = self._coerce_target(target, step, "stream") + async with self.open(resolved, timeout) as stream: + async for entry in stream: + yield entry + + async def collect( + self, + target: LogTarget | LogStage | str, + step: int | None = None, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Collect log entries for ``target`` into a list.""" + resolved = self._coerce_target(target, step, "collect") + entries: list[LogEntry] = [] + try: + async with self.open(resolved, timeout) as stream: + async for entry in stream: + entries.append(entry) + if max_entries and len(entries) >= max_entries: + break + except TimeoutError: + pass + return entries + + @staticmethod + def _coerce_target( + target: LogTarget | LogStage | str, step: int | None, method: str + ) -> LogTarget: + if isinstance(target, StageTarget | ServerTarget | ReplicaTarget): + return target + _deprecated(f"{method}(stage, step)", f"{method}(StageTarget(stage, step))") + return StageTarget(stage=target, step=step if step is not None else 0) + + # ------------------------------------------------------------------ + # Deprecated aliases (kept for backwards compatibility) + # ------------------------------------------------------------------ + + def open_stream( + self, stage: LogStage | str, step: int, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`StageTarget`.""" + _deprecated("open_stream", "open(StageTarget(...))") + return self.open(StageTarget(stage=stage, step=step), timeout=timeout) + + def open_server_stream( + self, step: int, server: str, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`ServerTarget`.""" + _deprecated("open_server_stream", "open(ServerTarget(...))") + return self.open(ServerTarget(step=step, server=server), timeout=timeout) + + def open_replica_stream( + self, step: int, replica: str, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`ReplicaTarget`.""" + _deprecated("open_replica_stream", "open(ReplicaTarget(...))") + return self.open(ReplicaTarget(step=step, replica=replica), timeout=timeout) + + async def stream_server( + self, step: int, server: str, timeout: float | None = None + ) -> AsyncIterator[LogEntry]: + """Deprecated: use :meth:`stream` with a :class:`ServerTarget`.""" + _deprecated("stream_server", "stream(ServerTarget(...))") + async for entry in self.stream( + ServerTarget(step=step, server=server), timeout=timeout + ): + yield entry + + async def stream_replica( + self, step: int, replica: str, timeout: float | None = None + ) -> AsyncIterator[LogEntry]: + """Deprecated: use :meth:`stream` with a :class:`ReplicaTarget`.""" + _deprecated("stream_replica", "stream(ReplicaTarget(...))") + async for entry in self.stream( + ReplicaTarget(step=step, replica=replica), timeout=timeout + ): + yield entry + + async def collect_server( + self, + step: int, + server: str, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Deprecated: use :meth:`collect` with a :class:`ServerTarget`.""" + _deprecated("collect_server", "collect(ServerTarget(...))") + return await self.collect( + ServerTarget(step=step, server=server), + max_entries=max_entries, + timeout=timeout, + ) + + async def collect_replica( + self, + step: int, + replica: str, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Deprecated: use :meth:`collect` with a :class:`ReplicaTarget`.""" + _deprecated("collect_replica", "collect(ReplicaTarget(...))") + return await self.collect( + ReplicaTarget(step=step, replica=replica), + max_entries=max_entries, + timeout=timeout, + ) diff --git a/src/codesphere/_async/resources/workspace/logs/schemas.py b/src/codesphere/_async/resources/workspace/logs/schemas.py new file mode 100644 index 0000000..a736cef --- /dev/null +++ b/src/codesphere/_async/resources/workspace/logs/schemas.py @@ -0,0 +1,21 @@ +from codesphere.models.logs import ( + LogEntry, + LogKind, + LogProblem, + LogStage, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, +) + +__all__ = [ + "LogEntry", + "LogKind", + "LogProblem", + "LogStage", + "LogTarget", + "ReplicaTarget", + "ServerTarget", + "StageTarget", +] diff --git a/src/codesphere/_async/resources/workspace/operations.py b/src/codesphere/_async/resources/workspace/operations.py new file mode 100644 index 0000000..bba075a --- /dev/null +++ b/src/codesphere/_async/resources/workspace/operations.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import ( + CommandInput, + CommandOutput, + Workspace, + WorkspaceCreate, + WorkspaceStatus, +) + +_LIST_BY_TEAM_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/team/{team_id}", + response_model=ResourceList[Workspace], +) + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{workspace_id}", + response_model=Workspace, +) + +_CREATE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces", + input_model=WorkspaceCreate, + response_model=Workspace, +) + +_UPDATE_OP = APIOperation( + method="PATCH", + endpoint_template="/workspaces/{id}", + response_model=NoneType, +) + +_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}", + response_model=NoneType, +) + +_GET_STATUS_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/status", + response_model=WorkspaceStatus, +) + +_EXECUTE_COMMAND_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/execute", + input_model=CommandInput, + response_model=CommandOutput, +) diff --git a/src/codesphere/_async/resources/workspace/resources.py b/src/codesphere/_async/resources/workspace/resources.py new file mode 100644 index 0000000..154279b --- /dev/null +++ b/src/codesphere/_async/resources/workspace/resources.py @@ -0,0 +1,33 @@ +import httpx + +from codesphere.exceptions import ValidationError + +from ...core import ResourceBase +from .operations import ( + _CREATE_OP, + _GET_OP, + _LIST_BY_TEAM_OP, +) +from .schemas import Workspace, WorkspaceCreate + + +class WorkspacesResource(ResourceBase): + async def list( + self, team_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> list[Workspace]: + if team_id <= 0: + raise ValidationError("team_id must be a positive integer") + result = await self._execute(_LIST_BY_TEAM_OP, team_id=team_id, timeout=timeout) + return result.root + + async def get( + self, workspace_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> Workspace: + if workspace_id <= 0: + raise ValidationError("workspace_id must be a positive integer") + return await self._execute(_GET_OP, workspace_id=workspace_id, timeout=timeout) + + async def create( + self, payload: WorkspaceCreate, *, timeout: httpx.Timeout | float | None = None + ) -> Workspace: + return await self._execute(_CREATE_OP, data=payload, timeout=timeout) diff --git a/src/codesphere/_async/resources/workspace/schemas.py b/src/codesphere/_async/resources/workspace/schemas.py new file mode 100644 index 0000000..27e8f15 --- /dev/null +++ b/src/codesphere/_async/resources/workspace/schemas.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +import logging +from functools import cached_property + +import httpx + +from codesphere.models.workspace import ( + CommandInput, + CommandOutput, + WorkspaceBase, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, +) +from codesphere.utils import update_model_fields + +from ...core.base import BoundModel +from .env_vars import EnvVar, WorkspaceEnvVarManager +from .git import WorkspaceGitManager +from .landscape import WorkspaceLandscapeManager +from .logs import WorkspaceLogManager + +log = logging.getLogger(__name__) + +__all__ = [ + "CommandInput", + "CommandOutput", + "EnvVar", + "Workspace", + "WorkspaceBase", + "WorkspaceCreate", + "WorkspaceStatus", + "WorkspaceUpdate", +] + + +class Workspace(WorkspaceBase, BoundModel): + async def update( + self, + data: WorkspaceUpdate, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + from .operations import _UPDATE_OP + + await self._execute(_UPDATE_OP, id=self.id, data=data, timeout=timeout) + update_model_fields(target=self, source=data) + + async def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_OP + + await self._execute(_DELETE_OP, id=self.id, timeout=timeout) + + async def get_status( + self, *, timeout: httpx.Timeout | float | None = None + ) -> WorkspaceStatus: + from .operations import _GET_STATUS_OP + + return await self._execute(_GET_STATUS_OP, id=self.id, timeout=timeout) + + async def wait_until_running( + self, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + ) -> None: + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + elapsed = 0.0 + while elapsed < timeout: + status = await self.get_status() + if status.is_running: + log.debug("Workspace %s is now running.", self.id) + return + log.debug( + "Workspace %s not running yet, waiting %ss... (elapsed: %.1fs)", + self.id, + poll_interval, + elapsed, + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Workspace {self.id} did not reach running state within {timeout} seconds." + ) + + async def execute_command( + self, + command: str, + env: dict[str, str] | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> CommandOutput: + from .operations import _EXECUTE_COMMAND_OP + + command_data = CommandInput(command=command, env=env) + return await self._execute( + _EXECUTE_COMMAND_OP, id=self.id, data=command_data, timeout=timeout + ) + + @cached_property + def env_vars(self) -> WorkspaceEnvVarManager: + return WorkspaceEnvVarManager(self._client(), workspace_id=self.id) + + @cached_property + def landscape(self) -> WorkspaceLandscapeManager: + """Manager for landscape operations (Multi Server Deployments).""" + return WorkspaceLandscapeManager(self._client(), workspace_id=self.id) + + @cached_property + def git(self) -> WorkspaceGitManager: + """Manager for git operations (head, pull).""" + return WorkspaceGitManager(self._client(), workspace_id=self.id) + + @cached_property + def logs(self) -> WorkspaceLogManager: + """Manager for streaming workspace logs. + + Provides methods to stream or collect logs from pipeline stages + (prepare, test, run) and Multi Server Deployment servers/replicas. + + Example: + ```python + # Stream logs as they arrive + async for entry in workspace.logs.stream(stage="prepare", step=1): + print(entry.message) + + # Collect all logs at once + entries = await workspace.logs.collect(stage="test", step=1) + ``` + """ + return WorkspaceLogManager(self._client(), workspace_id=self.id) diff --git a/src/codesphere/_sync/__init__.py b/src/codesphere/_sync/__init__.py new file mode 100644 index 0000000..7401298 --- /dev/null +++ b/src/codesphere/_sync/__init__.py @@ -0,0 +1,3 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/__init__.py +"""Async client implementation (source of truth for the sync client).""" diff --git a/src/codesphere/_sync/_compat.py b/src/codesphere/_sync/_compat.py new file mode 100644 index 0000000..cf568ea --- /dev/null +++ b/src/codesphere/_sync/_compat.py @@ -0,0 +1,16 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/_compat.py +"""Async primitives whose sync counterpart is produced by name replacement. + +unasyncd maps most constructs automatically (time.sleep -> time.sleep, +def/await, __enter__ -> __enter__, ...). Anything it cannot map is +aliased here; the generated sync twin replaces ``threading`` with +``threading`` (see ``per_file_add_replacements`` in pyproject.toml). +""" + +import asyncio +import threading + +Lock = threading.Lock + +__all__ = ["Lock"] diff --git a/src/codesphere/_sync/client.py b/src/codesphere/_sync/client.py new file mode 100644 index 0000000..7306e5b --- /dev/null +++ b/src/codesphere/_sync/client.py @@ -0,0 +1,99 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/client.py +from types import TracebackType + +import httpx +from pydantic import SecretStr + +from codesphere.config import RetryConfig, Settings +from codesphere.exceptions import AuthenticationError + +from .http_client import APIHttpClient +from .resources.flags import FlagsResource +from .resources.metadata import MetadataResource +from .resources.team import TeamsResource +from .resources.workspace import WorkspacesResource + + +class CodesphereSDK: + """Entry point to the Codesphere API. + + Configuration is resolved per argument: explicit constructor argument, + then the corresponding ``CS_*`` environment variable (``CS_TOKEN``, + ``CS_BASE_URL``, ``CS_CLIENT_TIMEOUT_CONNECT``/``_READ``; a local + ``.env`` file is honored), then the built-in default. A missing token + raises :class:`~codesphere.AuthenticationError` at construction. + """ + + teams: TeamsResource + workspaces: WorkspacesResource + metadata: MetadataResource + flags: FlagsResource + + def __init__( + self, + token: str | SecretStr | None = None, + base_url: str | None = None, + timeout: httpx.Timeout | None = None, + retry: RetryConfig | None = None, + http_client: APIHttpClient | None = None, + ): + if http_client is not None: + self._http_client = http_client + else: + settings = Settings() + + resolved_token = self._resolve_token(token, settings) + resolved_base_url = ( + base_url if base_url is not None else str(settings.base_url) + ) + resolved_timeout = ( + timeout + if timeout is not None + else httpx.Timeout( + settings.client_timeout_connect, + read=settings.client_timeout_read, + ) + ) + self._http_client = APIHttpClient( + token=resolved_token, + base_url=resolved_base_url, + timeout=resolved_timeout, + retry=retry, + ) + + self.teams = TeamsResource(self._http_client) + self.workspaces = WorkspacesResource(self._http_client) + self.metadata = MetadataResource(self._http_client) + self.flags = FlagsResource(self._http_client) + + @staticmethod + def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr: + if token is not None: + return token if isinstance(token, SecretStr) else SecretStr(token) + if settings.token is not None: + return settings.token + raise AuthenticationError() + + def open(self) -> None: + self._http_client.open() + + def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: + self._http_client.close(exc_type, exc_val, exc_tb) + + def __enter__(self) -> "CodesphereSDK": + self.open() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close(exc_type, exc_val, exc_tb) diff --git a/src/codesphere/_sync/core/__init__.py b/src/codesphere/_sync/core/__init__.py new file mode 100644 index 0000000..a1c4bb3 --- /dev/null +++ b/src/codesphere/_sync/core/__init__.py @@ -0,0 +1,24 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/core/__init__.py +from codesphere.core.models import CamelModel, ResourceList +from codesphere.core.operations import APIOperation, StreamOperation + +from .base import ( + BoundModel, + ResourceBase, + TeamScopedResource, + WorkspaceScopedResource, +) +from .handler import execute_operation + +__all__ = [ + "APIOperation", + "BoundModel", + "CamelModel", + "ResourceBase", + "ResourceList", + "StreamOperation", + "TeamScopedResource", + "WorkspaceScopedResource", + "execute_operation", +] diff --git a/src/codesphere/_sync/core/base.py b/src/codesphere/_sync/core/base.py new file mode 100644 index 0000000..95e02b2 --- /dev/null +++ b/src/codesphere/_sync/core/base.py @@ -0,0 +1,102 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/core/base.py +from collections.abc import Mapping +from typing import Any, TypeVar + +import httpx +from pydantic import PrivateAttr + +from codesphere.core.models import CamelModel +from codesphere.core.models import ResourceList as ResourceList +from codesphere.core.operations import APIOperation +from codesphere.feature_flags import FlagRequirement + +from ..http_client import APIHttpClient +from .handler import RequestData, execute_operation + +ResponseT = TypeVar("ResponseT") + + +class ResourceBase: + """Base for resource and manager classes bound to an HTTP client.""" + + def __init__(self, http_client: APIHttpClient): + self._http_client = http_client + + def _execute( + self, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, + **path_params: Any, + ) -> ResponseT: + return execute_operation( + self._http_client, + op, + data=data, + params=params, + path_params=path_params, + timeout=timeout, + ) + + def _require_flag(self, requirement: FlagRequirement) -> None: + """Fail fast unless the platform flag is enabled (non-operation paths).""" + self._http_client.flags_store.require(requirement) + + +class TeamScopedResource(ResourceBase): + """Base for managers operating within one team.""" + + def __init__(self, http_client: APIHttpClient, team_id: int): + super().__init__(http_client) + self.team_id = team_id + + +class WorkspaceScopedResource(ResourceBase): + """Base for managers operating within one workspace.""" + + def __init__(self, http_client: APIHttpClient, workspace_id: int): + super().__init__(http_client) + self.workspace_id = workspace_id + + +class BoundModel(CamelModel): + """API entity that carries the HTTP client it was fetched with. + + Instances returned by the SDK get their client attached automatically, + which lets entity methods (e.g. ``workspace.delete()``) make further + API calls. + """ + + _http_client: APIHttpClient | None = PrivateAttr(default=None) + + def _client(self) -> APIHttpClient: + if self._http_client is None or not hasattr(self._http_client, "request"): + raise RuntimeError( + "Cannot access resource on a detached model. HTTP client missing." + ) + return self._http_client + + def _execute( + self, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, + **path_params: Any, + ) -> ResponseT: + return execute_operation( + self._client(), + op, + data=data, + params=params, + path_params=path_params, + timeout=timeout, + ) + + def _require_flag(self, requirement: FlagRequirement) -> None: + """Fail fast unless the platform flag is enabled (non-operation paths).""" + self._client().flags_store.require(requirement) diff --git a/src/codesphere/_sync/core/handler.py b/src/codesphere/_sync/core/handler.py new file mode 100644 index 0000000..c6ae6b8 --- /dev/null +++ b/src/codesphere/_sync/core/handler.py @@ -0,0 +1,123 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/core/handler.py +import logging +from collections.abc import Mapping, Sequence +from types import NoneType +from typing import Any, TypeVar, cast + +import httpx +from pydantic import BaseModel, RootModel, ValidationError + +from codesphere.core.operations import APIOperation + +from ..http_client import APIHttpClient + +log = logging.getLogger(__name__) + +ResponseT = TypeVar("ResponseT") + +type RequestData = BaseModel | Mapping[str, Any] | Sequence[Any] + + +def execute_operation( + client: APIHttpClient, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + path_params: Mapping[str, Any] | None = None, + timeout: httpx.Timeout | float | None = None, +) -> ResponseT: + """Execute a declarative :class:`APIOperation` against the API. + + Path parameters are passed explicitly and formatted into + ``op.endpoint_template``. When ``op.input_model`` is set, ``data`` is + validated against it before the request is sent. A non-``None`` ``data`` + is always sent as the JSON body, even when empty. When the operation + declares ``required_flag``, the flag is checked first — nothing is + sent if the connected instance does not have it enabled. A ``timeout`` + overrides the client-wide timeout for this call only (applied per + retry attempt). + """ + if op.required_flag is not None: + client.flags_store.require(op.required_flag) + + endpoint = _format_endpoint(op.endpoint_template, path_params or {}) + + request_kwargs: dict[str, Any] = {} + if params is not None: + request_kwargs["params"] = params + if data is not None: + request_kwargs["json"] = _serialize_payload(data, op.input_model) + if timeout is not None: + request_kwargs["timeout"] = timeout + + response = client.request(method=op.method, endpoint=endpoint, **request_kwargs) + return _parse_response(response, op.response_model, client, endpoint) + + +def _format_endpoint(template: str, path_params: Mapping[str, Any]) -> str: + try: + return template.format(**path_params) + except KeyError as e: + raise ValueError( + f"Missing path parameter {e.args[0]!r} for endpoint template {template!r}" + ) from e + + +def _serialize_payload(data: RequestData, input_model: type[BaseModel] | None) -> Any: + if input_model is not None: + model = ( + data if isinstance(data, input_model) else input_model.model_validate(data) + ) + return model.model_dump(exclude_none=True) + if isinstance(data, BaseModel): + return data.model_dump(exclude_none=True) + return data + + +def _parse_response( + response: httpx.Response, + response_model: type[ResponseT], + client: APIHttpClient | None, + endpoint_for_logging: str, +) -> ResponseT: + if response_model is NoneType: + return cast(ResponseT, None) + + try: + json_response = response.json() + log.debug(f"Validating JSON response for endpoint: {endpoint_for_logging}") + except httpx.ResponseNotRead: + log.warning(f"No JSON response body for {endpoint_for_logging}") + return cast(ResponseT, None) + + model_cls = cast(type[BaseModel], response_model) + try: + instance = model_cls.model_validate(json_response) + except ValidationError as e: + # Do not log the response body or the ValidationError itself: both + # may contain secret values (e.g. env vars). Log locations only. + locations = sorted({".".join(map(str, err["loc"])) for err in e.errors()}) + log.error( + f"Pydantic validation failed for {endpoint_for_logging} " + f"({e.error_count()} errors at: {', '.join(locations) or ''})" + ) + raise + + _bind_client(instance, client) + log.debug("Successfully validated response for %s.", endpoint_for_logging) + return cast(ResponseT, instance) + + +def _bind_client(instance: BaseModel, client: APIHttpClient | None) -> None: + """Attach the HTTP client to returned models so they can make calls.""" + from .base import BoundModel + + if isinstance(instance, BoundModel): + instance._http_client = client + + if isinstance(instance, RootModel) and isinstance(instance.root, list): + for item in instance.root: + if isinstance(item, BoundModel): + item._http_client = client diff --git a/src/codesphere/_sync/flags_store.py b/src/codesphere/_sync/flags_store.py new file mode 100644 index 0000000..9073730 --- /dev/null +++ b/src/codesphere/_sync/flags_store.py @@ -0,0 +1,76 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/flags_store.py +"""Per-client feature-flags cache (async implementation).""" + +from typing import TYPE_CHECKING + +from codesphere.exceptions import ( + FeatureNotAvailableError, + FeatureNotEnabledError, + NotFoundError, +) +from codesphere.feature_flags import ( + FLAGS_ENDPOINT, + FlagRequirement, + FlagsSnapshot, +) + +from ._compat import Lock + +if TYPE_CHECKING: + from .http_client import APIHttpClient + + +class FlagsStore: + """Per-client cache of the platform's feature flags. + + Fetches ``GET /ide-service/flags`` lazily on first need (under a lock, + so concurrent gated calls trigger one request), then caches for the + client's lifetime. A 404 — the instance predates feature flags — is + cached as an empty snapshot: reads report nothing available and gated + calls fail closed. Transient errors propagate uncached. + """ + + def __init__(self, client: "APIHttpClient") -> None: + self._client = client + self._snapshot: FlagsSnapshot | None = None + self._legacy_platform = False + self._lock = Lock() + + def get(self, *, refresh: bool = False) -> FlagsSnapshot: + with self._lock: + if self._snapshot is None or refresh: + self._snapshot = self._fetch() + return self._snapshot + + def _fetch(self) -> FlagsSnapshot: + try: + response = self._client.request("GET", FLAGS_ENDPOINT) + except NotFoundError: + self._legacy_platform = True + return FlagsSnapshot.empty() + self._legacy_platform = False + return FlagsSnapshot.model_validate(response.json()) + + def require(self, requirement: FlagRequirement) -> None: + """Raise unless the required flag is enabled on this instance.""" + snapshot = self.get() + flag, category = requirement.flag, requirement.category + category_str = str(category) if category is not None else None + + if not snapshot.is_available(flag, category): + raise FeatureNotAvailableError( + flag=flag, + category=category_str, + legacy_platform=self._legacy_platform, + ) + if not snapshot.is_enabled(flag, category): + raise FeatureNotEnabledError(flag=flag, category=category_str) + + def cached(self) -> FlagsSnapshot | None: + """The current snapshot, or None if nothing has been fetched yet.""" + return self._snapshot + + def invalidate(self) -> None: + """Drop the cached snapshot; the next need re-fetches.""" + self._snapshot = None diff --git a/src/codesphere/_sync/http_client.py b/src/codesphere/_sync/http_client.py new file mode 100644 index 0000000..46edbb9 --- /dev/null +++ b/src/codesphere/_sync/http_client.py @@ -0,0 +1,198 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/http_client.py +import asyncio +import logging +import random +from contextlib import AbstractAsyncContextManager, AbstractContextManager +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime +from types import TracebackType +from typing import Any, cast + +import httpx +from pydantic import SecretStr + +from codesphere.config import RetryConfig +from codesphere.exceptions import NetworkError, TimeoutError, raise_for_status + +from .flags_store import FlagsStore +import time + +log = logging.getLogger(__name__) + + +def _parse_retry_after(value: str | None) -> float | None: + """Parse a Retry-After header: delta-seconds or an HTTP-date.""" + if not value: + return None + if value.isdigit(): + return float(value) + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + return max(0.0, (retry_at - datetime.now(UTC)).total_seconds()) + + +class APIHttpClient: + """Thin transport around httpx. + + Receives its full configuration from the caller (the SDK resolves + arguments, environment variables, and defaults) and owns nothing but + the connection lifecycle, error mapping, and opt-in retries. + """ + + def __init__( + self, + *, + token: SecretStr, + base_url: str, + timeout: httpx.Timeout, + retry: RetryConfig | None = None, + ): + self._token = token.get_secret_value() + self._base_url = base_url + self._client: httpx.Client | None = None + self._retry = retry if retry is not None else RetryConfig() + # Per-connection feature-flags cache; the transport never reads it + # itself (see feature_flags.FlagsStore). + self.flags_store = FlagsStore(self) + + self._timeout_config = timeout + self._client_config: dict[str, Any] = { + "base_url": self._base_url, + "headers": {"Authorization": f"Bearer {self._token}"}, + "timeout": self._timeout_config, + } + + def _get_client(self) -> httpx.Client: + if not self._client: + raise RuntimeError( + "Client is not open. Use the client as a context manager " + "or call open() before making requests." + ) + return self._client + + @property + def timeout(self) -> httpx.Timeout: + """The configured request timeouts.""" + return self._timeout_config + + @property + def stream_timeout(self) -> httpx.Timeout: + """Timeouts for long-lived streams: configured connect, unbounded read.""" + # httpx.Timeout attributes are untyped upstream; connect is float | None + connect = cast("float | None", self._timeout_config.connect) + return httpx.Timeout(connect, read=None) + + def stream( + self, method: str, endpoint: str, **kwargs: Any + ) -> AbstractContextManager[httpx.Response]: + """Open a streaming request (e.g. SSE) on the underlying client.""" + return self._get_client().stream(method, endpoint, **kwargs) + + def open(self) -> None: + if not self._client: + self._client = httpx.Client(**self._client_config) + self._client.__enter__() + + def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: + if self._client: + self._client.__exit__(exc_type, exc_val, exc_tb) + self._client = None + + def __enter__(self) -> "APIHttpClient": + self.open() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close(exc_type, exc_val, exc_tb) + + def request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response: + client = self._get_client() + + log.debug(f"Request: {method} {endpoint}") + # Never log bodies or headers: request payloads (e.g. env vars) + # can contain secrets. + if kwargs: + summary = ", ".join( + f"params={value!r}" + if key == "params" + else f"{key}=<{type(value).__name__} omitted>" + for key, value in kwargs.items() + ) + log.debug(f"Request options: {summary}") + + retryable_method = method.upper() in self._retry.retry_methods + attempts = self._retry.max_retries + 1 if retryable_method else 1 + + for attempt in range(attempts): + last_attempt = attempt == attempts - 1 + try: + response = client.request(method, endpoint, **kwargs) + except httpx.TimeoutException as e: + if not last_attempt: + self._sleep_before_retry(attempt, None, method, endpoint) + continue + log.error(f"Request timeout for {method} {endpoint}: {e}") + raise TimeoutError(f"Request to {endpoint} timed out.") from e + except httpx.ConnectError as e: + if not last_attempt: + self._sleep_before_retry(attempt, None, method, endpoint) + continue + log.error(f"Connection error for {method} {endpoint}: {e}") + raise NetworkError( + f"Failed to connect to the API: {e}", + original_error=e, + ) from e + except httpx.RequestError as e: + log.error(f"Network error for {method} {endpoint}: {e}") + raise NetworkError( + f"A network error occurred: {e}", + original_error=e, + ) from e + + log.debug( + f"Response: {response.status_code} {response.reason_phrase} " + f"for {method} {endpoint}" + ) + + if response.status_code in self._retry.retry_statuses and not last_attempt: + self._sleep_before_retry(attempt, response, method, endpoint) + continue + + raise_for_status(response) + return response + + raise AssertionError("unreachable: retry loop must return or raise") + + def _sleep_before_retry( + self, + attempt: int, + response: httpx.Response | None, + method: str, + endpoint: str, + ) -> None: + delay = None + if response is not None: + delay = _parse_retry_after(response.headers.get("Retry-After")) + if delay is None: + delay = self._retry.backoff_factor * (2**attempt) + # Jitter to avoid thundering herds; not security-relevant. + delay += random.uniform(0, self._retry.backoff_factor / 2) # nosec B311 + + log.debug( + f"Retrying {method} {endpoint} in {delay:.2f}s " + f"(attempt {attempt + 1}/{self._retry.max_retries})" + ) + time.sleep(delay) diff --git a/src/codesphere/_sync/resources/__init__.py b/src/codesphere/_sync/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codesphere/_sync/resources/flags/__init__.py b/src/codesphere/_sync/resources/flags/__init__.py new file mode 100644 index 0000000..319f62f --- /dev/null +++ b/src/codesphere/_sync/resources/flags/__init__.py @@ -0,0 +1,19 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/flags/__init__.py +"""Feature flags of the connected Codesphere instance (``sdk.flags``). + +This package deviates from the canonical resource layout in one way: +there is no ``operations.py``. The fetch lives in +:class:`codesphere.feature_flags.FlagsStore` so that ``sdk.flags`` and +operation gating (``APIOperation.required_flag``) share a single cache. +""" + +from .resources import FlagsResource +from .schemas import CategoryFlags, FlagCategory, FlagsSnapshot + +__all__ = [ + "CategoryFlags", + "FlagCategory", + "FlagsResource", + "FlagsSnapshot", +] diff --git a/src/codesphere/_sync/resources/flags/resources.py b/src/codesphere/_sync/resources/flags/resources.py new file mode 100644 index 0000000..11042e6 --- /dev/null +++ b/src/codesphere/_sync/resources/flags/resources.py @@ -0,0 +1,38 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/flags/resources.py +from codesphere.feature_flags import FlagCategory, FlagRequirement, FlagsSnapshot + +from ...core import ResourceBase + + +class FlagsResource(ResourceBase): + """Read the feature flags of the connected Codesphere instance. + + The snapshot is fetched lazily and cached for the client's lifetime; + :meth:`refresh` re-fetches. The same cache backs operation gating, so + a refresh here also affects gated SDK features. + """ + + def get(self, *, refresh: bool = False) -> FlagsSnapshot: + """The instance's flags snapshot (cached after the first call).""" + return self._http_client.flags_store.get(refresh=refresh) + + def refresh(self) -> FlagsSnapshot: + """Re-fetch the flags from the platform.""" + return self.get(refresh=True) + + def is_available(self, flag: str, category: FlagCategory | None = None) -> bool: + """Whether ``flag`` exists on this instance (any or one category).""" + snapshot = self.get() + return snapshot.is_available(flag, category) + + def is_enabled(self, flag: str, category: FlagCategory | None = None) -> bool: + """Whether ``flag`` is enabled on this instance (any or one category).""" + snapshot = self.get() + return snapshot.is_enabled(flag, category) + + def require(self, flag: str, category: FlagCategory | None = None) -> None: + """Raise :class:`~codesphere.FeatureFlagError` unless ``flag`` is enabled.""" + self._http_client.flags_store.require( + FlagRequirement(flag=flag, category=category) + ) diff --git a/src/codesphere/_sync/resources/flags/schemas.py b/src/codesphere/_sync/resources/flags/schemas.py new file mode 100644 index 0000000..0e8564b --- /dev/null +++ b/src/codesphere/_sync/resources/flags/schemas.py @@ -0,0 +1,11 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/flags/schemas.py +"""Flag schemas — re-exported from the engine module. + +The models live in :mod:`codesphere.feature_flags` (top level) to keep the +import graph cycle-free; see that module's docstring. +""" + +from codesphere.feature_flags import CategoryFlags, FlagCategory, FlagsSnapshot + +__all__ = ["CategoryFlags", "FlagCategory", "FlagsSnapshot"] diff --git a/src/codesphere/_sync/resources/metadata/__init__.py b/src/codesphere/_sync/resources/metadata/__init__.py new file mode 100644 index 0000000..e5be3fb --- /dev/null +++ b/src/codesphere/_sync/resources/metadata/__init__.py @@ -0,0 +1,8 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/metadata/__init__.py +"""Metadata Resource & Models""" + +from .resources import MetadataResource +from .schemas import Characteristic, Datacenter, Image, WsPlan + +__all__ = ["Characteristic", "Datacenter", "Image", "MetadataResource", "WsPlan"] diff --git a/src/codesphere/_sync/resources/metadata/operations.py b/src/codesphere/_sync/resources/metadata/operations.py new file mode 100644 index 0000000..131f869 --- /dev/null +++ b/src/codesphere/_sync/resources/metadata/operations.py @@ -0,0 +1,24 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/metadata/operations.py +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import Datacenter, Image, WsPlan + +_LIST_DC_OP = APIOperation( + method="GET", + endpoint_template="/metadata/datacenters", + response_model=ResourceList[Datacenter], +) + +_LIST_PLANS_OP = APIOperation( + method="GET", + endpoint_template="/metadata/workspace-plans", + response_model=ResourceList[WsPlan], +) + +_LIST_IMAGES_OP = APIOperation( + method="GET", + endpoint_template="/metadata/workspace-base-images", + response_model=ResourceList[Image], +) diff --git a/src/codesphere/_sync/resources/metadata/resources.py b/src/codesphere/_sync/resources/metadata/resources.py new file mode 100644 index 0000000..2d42f5d --- /dev/null +++ b/src/codesphere/_sync/resources/metadata/resources.py @@ -0,0 +1,27 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/metadata/resources.py +import httpx + +from ...core import ResourceBase +from .operations import _LIST_DC_OP, _LIST_IMAGES_OP, _LIST_PLANS_OP +from .schemas import Datacenter, Image, WsPlan + + +class MetadataResource(ResourceBase): + def list_datacenters( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[Datacenter]: + result = self._execute(_LIST_DC_OP, timeout=timeout) + return result.root + + def list_plans( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[WsPlan]: + result = self._execute(_LIST_PLANS_OP, timeout=timeout) + return result.root + + def list_images( + self, *, timeout: httpx.Timeout | float | None = None + ) -> list[Image]: + result = self._execute(_LIST_IMAGES_OP, timeout=timeout) + return result.root diff --git a/src/codesphere/_sync/resources/metadata/schemas.py b/src/codesphere/_sync/resources/metadata/schemas.py new file mode 100644 index 0000000..16623b4 --- /dev/null +++ b/src/codesphere/_sync/resources/metadata/schemas.py @@ -0,0 +1,5 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/metadata/schemas.py +from codesphere.models.metadata import Characteristic, Datacenter, Image, WsPlan + +__all__ = ["Characteristic", "Datacenter", "Image", "WsPlan"] diff --git a/src/codesphere/_sync/resources/team/__init__.py b/src/codesphere/_sync/resources/team/__init__.py new file mode 100644 index 0000000..971377b --- /dev/null +++ b/src/codesphere/_sync/resources/team/__init__.py @@ -0,0 +1,41 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/__init__.py +from .domain import ( + CustomDomainConfig, + Domain, + DomainBase, + DomainRouting, + DomainVerificationStatus, + TeamDomainManager, +) +from .resources import TeamsResource +from .schemas import Team, TeamBase, TeamCreate +from .usage import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + PaginatedResponse, + ServiceAction, + TeamUsageManager, + UsageEventsResponse, + UsageSummaryResponse, +) + +__all__ = [ + "CustomDomainConfig", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "Team", + "TeamBase", + "TeamCreate", + "TeamDomainManager", + "TeamUsageManager", + "TeamsResource", + "UsageEventsResponse", + "UsageSummaryResponse", +] diff --git a/src/codesphere/_sync/resources/team/domain/__init__.py b/src/codesphere/_sync/resources/team/domain/__init__.py new file mode 100644 index 0000000..0fa82f6 --- /dev/null +++ b/src/codesphere/_sync/resources/team/domain/__init__.py @@ -0,0 +1,21 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/domain/__init__.py +from .resources import TeamDomainManager +from .schemas import ( + CustomDomainConfig, + Domain, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) + +__all__ = [ + "CustomDomainConfig", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "RoutingMap", + "TeamDomainManager", +] diff --git a/src/codesphere/_sync/resources/team/domain/operations.py b/src/codesphere/_sync/resources/team/domain/operations.py new file mode 100644 index 0000000..dbfe53a --- /dev/null +++ b/src/codesphere/_sync/resources/team/domain/operations.py @@ -0,0 +1,51 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/domain/operations.py +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import CustomDomainConfig, Domain, DomainVerificationStatus + +_LIST_OP = APIOperation( + method="GET", + endpoint_template="/domains/team/{team_id}", + response_model=ResourceList[Domain], +) + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=Domain, +) + +_CREATE_OP = APIOperation( + method="POST", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=Domain, +) + +_UPDATE_OP = APIOperation( + method="PATCH", + endpoint_template="/domains/team/{team_id}/domain/{name}", + input_model=CustomDomainConfig, + response_model=Domain, +) + +_UPDATE_WS_OP = APIOperation( + method="PUT", + endpoint_template="/domains/team/{team_id}/domain/{name}/workspace-connections", + response_model=Domain, +) + +_VERIFY_OP = APIOperation( + method="POST", + endpoint_template="/domains/team/{team_id}/domain/{name}/verify", + response_model=DomainVerificationStatus, +) + +_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/domains/team/{team_id}/domain/{name}", + response_model=NoneType, +) diff --git a/src/codesphere/_sync/resources/team/domain/resources.py b/src/codesphere/_sync/resources/team/domain/resources.py new file mode 100644 index 0000000..87004a1 --- /dev/null +++ b/src/codesphere/_sync/resources/team/domain/resources.py @@ -0,0 +1,52 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/domain/resources.py +import httpx + +from ....core.base import TeamScopedResource +from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP +from .schemas import CustomDomainConfig, Domain, DomainRouting, RoutingMap + + +class TeamDomainManager(TeamScopedResource): + def list(self, *, timeout: httpx.Timeout | float | None = None) -> list[Domain]: + result = self._execute(_LIST_OP, team_id=self.team_id, timeout=timeout) + return result.root + + def get(self, name: str, *, timeout: httpx.Timeout | float | None = None) -> Domain: + return self._execute(_GET_OP, team_id=self.team_id, name=name, timeout=timeout) + + def create( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> Domain: + return self._execute( + _CREATE_OP, team_id=self.team_id, name=name, timeout=timeout + ) + + def update( + self, + name: str, + config: CustomDomainConfig, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + return self._execute( + _UPDATE_OP, team_id=self.team_id, name=name, data=config, timeout=timeout + ) + + def update_workspace_connections( + self, + name: str, + connections: DomainRouting | RoutingMap, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + payload = ( + connections.root if isinstance(connections, DomainRouting) else connections + ) + return self._execute( + _UPDATE_WS_OP, + team_id=self.team_id, + name=name, + data=payload, + timeout=timeout, + ) diff --git a/src/codesphere/_sync/resources/team/domain/schemas.py b/src/codesphere/_sync/resources/team/domain/schemas.py new file mode 100644 index 0000000..163d960 --- /dev/null +++ b/src/codesphere/_sync/resources/team/domain/schemas.py @@ -0,0 +1,82 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/domain/schemas.py +from __future__ import annotations + +import httpx + +from codesphere.models.domain import ( + CertificateRequestStatus, + CustomDomainConfig, + DNSEntries, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) +from codesphere.utils import update_model_fields + +from ....core.base import BoundModel + +__all__ = [ + "CertificateRequestStatus", + "CustomDomainConfig", + "DNSEntries", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "RoutingMap", +] + + +class Domain(DomainBase, BoundModel): + def update( + self, + data: CustomDomainConfig, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + from .operations import _UPDATE_OP + + response = self._execute( + _UPDATE_OP, team_id=self.team_id, name=self.name, data=data, timeout=timeout + ) + update_model_fields(target=self, source=response) + return response + + def update_workspace_connections( + self, + connections: DomainRouting | RoutingMap, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Domain: + from .operations import _UPDATE_WS_OP + + payload = ( + connections.root if isinstance(connections, DomainRouting) else connections + ) + response = self._execute( + _UPDATE_WS_OP, + team_id=self.team_id, + name=self.name, + data=payload, + timeout=timeout, + ) + update_model_fields(target=self, source=response) + return response + + def verify_status( + self, *, timeout: httpx.Timeout | float | None = None + ) -> DomainVerificationStatus: + from .operations import _VERIFY_OP + + response = self._execute( + _VERIFY_OP, team_id=self.team_id, name=self.name, timeout=timeout + ) + update_model_fields(target=self.domain_verification_status, source=response) + return response + + def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_OP + + self._execute(_DELETE_OP, team_id=self.team_id, name=self.name, timeout=timeout) diff --git a/src/codesphere/_sync/resources/team/operations.py b/src/codesphere/_sync/resources/team/operations.py new file mode 100644 index 0000000..f6e9c25 --- /dev/null +++ b/src/codesphere/_sync/resources/team/operations.py @@ -0,0 +1,33 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/operations.py +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import Team, TeamCreate + +_LIST_TEAMS_OP = APIOperation( + method="GET", + endpoint_template="/teams", + response_model=ResourceList[Team], +) + +_GET_TEAM_OP = APIOperation( + method="GET", + endpoint_template="/teams/{team_id}", + response_model=Team, +) + +_CREATE_TEAM_OP = APIOperation( + method="POST", + endpoint_template="/teams", + input_model=TeamCreate, + response_model=Team, +) + +_DELETE_TEAM_OP = APIOperation( + method="DELETE", + endpoint_template="/teams/{team_id}", + response_model=NoneType, +) diff --git a/src/codesphere/_sync/resources/team/resources.py b/src/codesphere/_sync/resources/team/resources.py new file mode 100644 index 0000000..2663340 --- /dev/null +++ b/src/codesphere/_sync/resources/team/resources.py @@ -0,0 +1,27 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/resources.py +import httpx + +from ...core import ResourceBase +from .operations import ( + _CREATE_TEAM_OP, + _GET_TEAM_OP, + _LIST_TEAMS_OP, +) +from .schemas import Team, TeamCreate + + +class TeamsResource(ResourceBase): + def list(self, *, timeout: httpx.Timeout | float | None = None) -> list[Team]: + result = self._execute(_LIST_TEAMS_OP, timeout=timeout) + return result.root + + def get( + self, team_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> Team: + return self._execute(_GET_TEAM_OP, team_id=team_id, timeout=timeout) + + def create( + self, payload: TeamCreate, *, timeout: httpx.Timeout | float | None = None + ) -> Team: + return self._execute(_CREATE_TEAM_OP, data=payload, timeout=timeout) diff --git a/src/codesphere/_sync/resources/team/schemas.py b/src/codesphere/_sync/resources/team/schemas.py new file mode 100644 index 0000000..9ca9fcb --- /dev/null +++ b/src/codesphere/_sync/resources/team/schemas.py @@ -0,0 +1,29 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/schemas.py +from __future__ import annotations + +from functools import cached_property + +import httpx + +from codesphere.models.team import TeamBase +from codesphere.models.team import TeamCreate as TeamCreate + +from ...core.base import BoundModel +from .domain.resources import TeamDomainManager +from .usage.resources import TeamUsageManager + + +class Team(TeamBase, BoundModel): + def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_TEAM_OP + + self._execute(_DELETE_TEAM_OP, team_id=self.id, timeout=timeout) + + @cached_property + def domains(self) -> TeamDomainManager: + return TeamDomainManager(self._client(), team_id=self.id) + + @cached_property + def usage(self) -> TeamUsageManager: + return TeamUsageManager(self._client(), team_id=self.id) diff --git a/src/codesphere/_sync/resources/team/usage/__init__.py b/src/codesphere/_sync/resources/team/usage/__init__.py new file mode 100644 index 0000000..9e0636d --- /dev/null +++ b/src/codesphere/_sync/resources/team/usage/__init__.py @@ -0,0 +1,23 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/usage/__init__.py +"""Team usage history resources.""" + +from .resources import TeamUsageManager +from .schemas import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + PaginatedResponse, + ServiceAction, + UsageEventsResponse, + UsageSummaryResponse, +) + +__all__ = [ + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "TeamUsageManager", + "UsageEventsResponse", + "UsageSummaryResponse", +] diff --git a/src/codesphere/_sync/resources/team/usage/operations.py b/src/codesphere/_sync/resources/team/usage/operations.py new file mode 100644 index 0000000..5a7c7e5 --- /dev/null +++ b/src/codesphere/_sync/resources/team/usage/operations.py @@ -0,0 +1,17 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/usage/operations.py +from codesphere.core.operations import APIOperation + +from .schemas import UsageEventsResponse, UsageSummaryResponse + +_GET_LANDSCAPE_SUMMARY_OP = APIOperation( + method="GET", + endpoint_template="/usage/teams/{team_id}/resources/landscape-service/summary", + response_model=UsageSummaryResponse, +) + +_GET_LANDSCAPE_EVENTS_OP = APIOperation( + method="GET", + endpoint_template="/usage/teams/{team_id}/resources/landscape-service/{resource_id}/events", + response_model=UsageEventsResponse, +) diff --git a/src/codesphere/_sync/resources/team/usage/resources.py b/src/codesphere/_sync/resources/team/usage/resources.py new file mode 100644 index 0000000..17e8c2c --- /dev/null +++ b/src/codesphere/_sync/resources/team/usage/resources.py @@ -0,0 +1,183 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/usage/resources.py +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from datetime import datetime +from functools import partial + +import httpx + +from ....core.base import TeamScopedResource +from .operations import _GET_LANDSCAPE_EVENTS_OP, _GET_LANDSCAPE_SUMMARY_OP +from .schemas import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + UsageEventsResponse, + UsageSummaryResponse, +) + + +def _clamp_page_size(value: int) -> int: + """The usage API accepts page sizes between 1 and 100.""" + return min(max(1, value), 100) + + +class TeamUsageManager(TeamScopedResource): + """Manager for team usage history operations. + + Provides access to landscape service usage summaries and events with + support for pagination and automatic iteration through all results. + + Example: + ```python + summary = team.usage.get_landscape_summary( + begin_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 31), + limit=50 + ) + print(f"Total items: {summary.total_items}") + print(f"Page {summary.current_page} of {summary.total_pages}") + + for item in summary.items: + print(f"{item.resource_name}: {item.usage_seconds}s") + + summary.refresh() + + for item in team.usage.iter_all_landscape_summary( + begin_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 31) + ): + print(f"{item.resource_name}: {item.usage_seconds}s") + ``` + """ + + def get_landscape_summary( + self, + begin_date: datetime | str, + end_date: datetime | str, + limit: int = 25, + offset: int = 0, + *, + timeout: httpx.Timeout | float | None = None, + ) -> UsageSummaryResponse: + params = { + "beginDate": begin_date.isoformat() + if isinstance(begin_date, datetime) + else begin_date, + "endDate": end_date.isoformat() + if isinstance(end_date, datetime) + else end_date, + "limit": _clamp_page_size(limit), + "offset": max(0, offset), + } + result = self._execute( + _GET_LANDSCAPE_SUMMARY_OP, + team_id=self.team_id, + params=params, + timeout=timeout, + ) + + result._refresh_op = partial( + self._execute, _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id + ) + result._team_id = self.team_id + + return result + + def iter_all_landscape_summary( + self, + begin_date: datetime | str, + end_date: datetime | str, + page_size: int = 100, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Iterator[LandscapeServiceSummary]: + offset = 0 + page_size = _clamp_page_size(page_size) + + while True: + response = self.get_landscape_summary( + begin_date=begin_date, + end_date=end_date, + limit=page_size, + offset=offset, + timeout=timeout, + ) + + for item in response.items: + yield item + + if not response.has_next_page: + break + + offset += page_size + + def get_landscape_events( + self, + resource_id: str, + begin_date: datetime | str, + end_date: datetime | str, + limit: int = 25, + offset: int = 0, + *, + timeout: httpx.Timeout | float | None = None, + ) -> UsageEventsResponse: + params = { + "beginDate": begin_date.isoformat() + if isinstance(begin_date, datetime) + else begin_date, + "endDate": end_date.isoformat() + if isinstance(end_date, datetime) + else end_date, + "limit": _clamp_page_size(limit), + "offset": max(0, offset), + } + result = self._execute( + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, + params=params, + timeout=timeout, + ) + + result._refresh_op = partial( + self._execute, + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, + ) + result._team_id = self.team_id + result._resource_id = resource_id + + return result + + def iter_all_landscape_events( + self, + resource_id: str, + begin_date: datetime | str, + end_date: datetime | str, + page_size: int = 100, + *, + timeout: httpx.Timeout | float | None = None, + ) -> Iterator[LandscapeServiceEvent]: + offset = 0 + page_size = _clamp_page_size(page_size) + + while True: + response = self.get_landscape_events( + resource_id=resource_id, + begin_date=begin_date, + end_date=end_date, + limit=page_size, + offset=offset, + timeout=timeout, + ) + + for item in response.items: + yield item + + if not response.has_next_page: + break + + offset += page_size diff --git a/src/codesphere/_sync/resources/team/usage/schemas.py b/src/codesphere/_sync/resources/team/usage/schemas.py new file mode 100644 index 0000000..9c4da2e --- /dev/null +++ b/src/codesphere/_sync/resources/team/usage/schemas.py @@ -0,0 +1,97 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/team/usage/schemas.py +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any, Generic, Self, TypeVar + +import httpx +from pydantic import Field + +from codesphere.models.usage import ( + LandscapeServiceEvent, + LandscapeServiceSummary, + ServiceAction, +) + +from ....core.base import CamelModel + +__all__ = [ + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "PaginatedResponse", + "ServiceAction", + "UsageEventsResponse", + "UsageSummaryResponse", +] + +ItemT = TypeVar("ItemT") + + +class PaginatedResponse(CamelModel, Generic[ItemT]): + total_items: int + limit: int = 25 + offset: int = 0 + begin_date: datetime + end_date: datetime + + # Loosely typed so the sync flavor (no Awaitable) shares the shape. + _refresh_op: Callable[..., Any] | None = None + + @property + def has_next_page(self) -> bool: + return (self.offset + self.limit) < self.total_items + + @property + def has_prev_page(self) -> bool: + return self.offset > 0 + + @property + def current_page(self) -> int: + return (self.offset // self.limit) + 1 + + @property + def total_pages(self) -> int: + if self.total_items == 0: + return 1 + return (self.total_items + self.limit - 1) // self.limit + + def refresh(self, *, timeout: httpx.Timeout | float | None = None) -> Self: + """Re-fetch this page with the same query parameters, in place.""" + if self._refresh_op is None: + raise RuntimeError( + "Refresh operation not available. Use manager methods instead." + ) + result = self._refresh_op( + params={ + "beginDate": self.begin_date.isoformat(), + "endDate": self.end_date.isoformat(), + "limit": self.limit, + "offset": self.offset, + }, + timeout=timeout, + ) + for field_name in result.model_fields_set: + setattr(self, field_name, getattr(result, field_name)) + return self + + +class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): + summary: list[LandscapeServiceSummary] = Field(default_factory=list) + _team_id: int | None = None + + @property + def items(self) -> list[LandscapeServiceSummary]: + return self.summary + + +class UsageEventsResponse(PaginatedResponse[LandscapeServiceEvent]): + events: list[LandscapeServiceEvent] = Field(default_factory=list) + + _team_id: int | None = None + _resource_id: str | None = None + + @property + def items(self) -> list[LandscapeServiceEvent]: + return self.events diff --git a/src/codesphere/_sync/resources/workspace/__init__.py b/src/codesphere/_sync/resources/workspace/__init__.py new file mode 100644 index 0000000..11b5591 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/__init__.py @@ -0,0 +1,30 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/__init__.py +from .git import GitHead, WorkspaceGitManager +from .logs import LogEntry, LogProblem, LogStage, LogStream, WorkspaceLogManager +from .resources import WorkspacesResource +from .schemas import ( + CommandInput, + CommandOutput, + Workspace, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, +) + +__all__ = [ + "CommandInput", + "CommandOutput", + "GitHead", + "LogEntry", + "LogProblem", + "LogStage", + "LogStream", + "Workspace", + "WorkspaceCreate", + "WorkspaceGitManager", + "WorkspaceLogManager", + "WorkspaceStatus", + "WorkspaceUpdate", + "WorkspacesResource", +] diff --git a/src/codesphere/_sync/resources/workspace/env_vars/__init__.py b/src/codesphere/_sync/resources/workspace/env_vars/__init__.py new file mode 100644 index 0000000..3864a3a --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/env_vars/__init__.py @@ -0,0 +1,6 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/env_vars/__init__.py +from .resources import WorkspaceEnvVarManager +from .schemas import EnvVar + +__all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/_sync/resources/workspace/env_vars/operations.py b/src/codesphere/_sync/resources/workspace/env_vars/operations.py new file mode 100644 index 0000000..5160178 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/env_vars/operations.py @@ -0,0 +1,26 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/env_vars/operations.py +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import EnvVar + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/env-vars", + response_model=ResourceList[EnvVar], +) + +_BULK_SET_OP = APIOperation( + method="PUT", + endpoint_template="/workspaces/{id}/env-vars", + response_model=NoneType, +) + +_BULK_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}/env-vars", + response_model=NoneType, +) diff --git a/src/codesphere/_sync/resources/workspace/env_vars/resources.py b/src/codesphere/_sync/resources/workspace/env_vars/resources.py new file mode 100644 index 0000000..d18777d --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/env_vars/resources.py @@ -0,0 +1,53 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/env_vars/resources.py +from __future__ import annotations + +import logging +from collections.abc import Sequence + +import httpx + +from ....core.base import ResourceList, WorkspaceScopedResource +from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP +from .schemas import EnvVar + +log = logging.getLogger(__name__) + + +class WorkspaceEnvVarManager(WorkspaceScopedResource): + def get( + self, *, timeout: httpx.Timeout | float | None = None + ) -> ResourceList[EnvVar]: + return self._execute(_GET_OP, id=self.workspace_id, timeout=timeout) + + def set( + self, + env_vars: ResourceList[EnvVar] | Sequence[EnvVar | dict[str, str]], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + payload = ResourceList[EnvVar].model_validate(env_vars) + self._execute(_BULK_SET_OP, id=self.workspace_id, data=payload, timeout=timeout) + + def delete( + self, + items: Sequence[str | EnvVar | dict[str, str]] | ResourceList[EnvVar], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if not items: + return + payload: list[str] = [] + + for item in items: + if isinstance(item, str): + payload.append(item) + elif isinstance(item, EnvVar): + payload.append(item.name) + elif isinstance(item, dict) and "name" in item: + payload.append(item["name"]) + + if payload: + self._execute( + _BULK_DELETE_OP, id=self.workspace_id, data=payload, timeout=timeout + ) diff --git a/src/codesphere/_sync/resources/workspace/env_vars/schemas.py b/src/codesphere/_sync/resources/workspace/env_vars/schemas.py new file mode 100644 index 0000000..c1da93b --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/env_vars/schemas.py @@ -0,0 +1,5 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/env_vars/schemas.py +from codesphere.models.env_vars import EnvVar + +__all__ = ["EnvVar"] diff --git a/src/codesphere/_sync/resources/workspace/git/__init__.py b/src/codesphere/_sync/resources/workspace/git/__init__.py new file mode 100644 index 0000000..b867945 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/git/__init__.py @@ -0,0 +1,6 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/git/__init__.py +from .resources import WorkspaceGitManager +from .schemas import GitHead + +__all__ = ["GitHead", "WorkspaceGitManager"] diff --git a/src/codesphere/_sync/resources/workspace/git/operations.py b/src/codesphere/_sync/resources/workspace/git/operations.py new file mode 100644 index 0000000..e605baa --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/git/operations.py @@ -0,0 +1,33 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/git/operations.py +from __future__ import annotations + +from types import NoneType + +from codesphere.core.operations import APIOperation + +from .schemas import GitHead + +_GET_HEAD_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/git/head", + response_model=GitHead, +) + +_PULL_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull", + response_model=NoneType, +) + +_PULL_WITH_REMOTE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}", + response_model=NoneType, +) + +_PULL_WITH_REMOTE_AND_BRANCH_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/git/pull/{remote}/{branch}", + response_model=NoneType, +) diff --git a/src/codesphere/_sync/resources/workspace/git/resources.py b/src/codesphere/_sync/resources/workspace/git/resources.py new file mode 100644 index 0000000..fff9b9a --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/git/resources.py @@ -0,0 +1,50 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/git/resources.py +from __future__ import annotations + +import logging + +import httpx + +from ....core.base import WorkspaceScopedResource +from .operations import ( + _GET_HEAD_OP, + _PULL_OP, + _PULL_WITH_REMOTE_AND_BRANCH_OP, + _PULL_WITH_REMOTE_OP, +) +from .schemas import GitHead + +log = logging.getLogger(__name__) + + +class WorkspaceGitManager(WorkspaceScopedResource): + """Manager for git operations on a workspace.""" + + def get_head(self, *, timeout: httpx.Timeout | float | None = None) -> GitHead: + return self._execute(_GET_HEAD_OP, id=self.workspace_id, timeout=timeout) + + def pull( + self, + remote: str | None = None, + branch: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if remote is not None and branch is not None: + self._execute( + _PULL_WITH_REMOTE_AND_BRANCH_OP, + id=self.workspace_id, + remote=remote, + branch=branch, + timeout=timeout, + ) + elif remote is not None: + self._execute( + _PULL_WITH_REMOTE_OP, + id=self.workspace_id, + remote=remote, + timeout=timeout, + ) + else: + self._execute(_PULL_OP, id=self.workspace_id, timeout=timeout) diff --git a/src/codesphere/_sync/resources/workspace/git/schemas.py b/src/codesphere/_sync/resources/workspace/git/schemas.py new file mode 100644 index 0000000..f71be71 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/git/schemas.py @@ -0,0 +1,5 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/git/schemas.py +from codesphere.models.git import GitHead + +__all__ = ["GitHead"] diff --git a/src/codesphere/_sync/resources/workspace/landscape/__init__.py b/src/codesphere/_sync/resources/workspace/landscape/__init__.py new file mode 100644 index 0000000..94659e8 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/landscape/__init__.py @@ -0,0 +1,43 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/landscape/__init__.py +from .resources import WorkspaceLandscapeManager +from .schemas import ( + ManagedServiceBuilder, + ManagedServiceConfig, + NetworkConfig, + PathConfig, + PipelineStage, + PipelineState, + PipelineStatus, + PipelineStatusList, + PortConfig, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceConfig, + StageConfig, + Step, + StepStatus, +) + +__all__ = [ + "ManagedServiceBuilder", + "ManagedServiceConfig", + "NetworkConfig", + "PathConfig", + "PipelineStage", + "PipelineState", + "PipelineStatus", + "PipelineStatusList", + "PortConfig", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "ReactiveServiceBuilder", + "ReactiveServiceConfig", + "StageConfig", + "Step", + "StepStatus", + "WorkspaceLandscapeManager", +] diff --git a/src/codesphere/_sync/resources/workspace/landscape/operations.py b/src/codesphere/_sync/resources/workspace/landscape/operations.py new file mode 100644 index 0000000..2c65427 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/landscape/operations.py @@ -0,0 +1,56 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/landscape/operations.py +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ....core.base import ResourceList +from .schemas import PipelineStatus + +_DEPLOY_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy", + response_model=NoneType, +) + +_DEPLOY_WITH_PROFILE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/landscape/deploy/{profile}", + response_model=NoneType, +) + +_TEARDOWN_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}/landscape/teardown", + response_model=NoneType, +) + +_SCALE_OP = APIOperation( + method="PATCH", + endpoint_template="/workspaces/{id}/landscape/scale", + response_model=NoneType, +) + +_START_PIPELINE_STAGE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/start", + response_model=NoneType, +) + +_START_PIPELINE_STAGE_WITH_PROFILE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/start/{profile}", + response_model=NoneType, +) + +_STOP_PIPELINE_STAGE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/pipeline/{stage}/stop", + response_model=NoneType, +) + +_GET_PIPELINE_STATUS_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/pipeline/{stage}", + response_model=ResourceList[PipelineStatus], +) diff --git a/src/codesphere/_sync/resources/workspace/landscape/resources.py b/src/codesphere/_sync/resources/workspace/landscape/resources.py new file mode 100644 index 0000000..c725d05 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/landscape/resources.py @@ -0,0 +1,255 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/landscape/resources.py +from __future__ import annotations + +import asyncio +import base64 +import logging +import re +from typing import TYPE_CHECKING + +import httpx + +from ....core.base import ResourceList, WorkspaceScopedResource +from .operations import ( + _DEPLOY_OP, + _DEPLOY_WITH_PROFILE_OP, + _GET_PIPELINE_STATUS_OP, + _SCALE_OP, + _START_PIPELINE_STAGE_OP, + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + _STOP_PIPELINE_STAGE_OP, + _TEARDOWN_OP, +) +from .schemas import ( + PipelineStage, + PipelineState, + PipelineStatusList, + Profile, + ProfileConfig, +) +import time + +if TYPE_CHECKING: + from ..schemas import CommandOutput + +log = logging.getLogger(__name__) + +# Regex pattern to match ci..yml files +_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") +# Pattern for valid profile names +_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_profile_name(name: str) -> None: + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + +def _profile_filename(name: str) -> str: + _validate_profile_name(name) + return f"ci.{name}.yml" + + +class WorkspaceLandscapeManager(WorkspaceScopedResource): + def _run_command( + self, command: str, *, timeout: httpx.Timeout | float | None = None + ) -> CommandOutput: + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + return self._execute( + _EXECUTE_COMMAND_OP, + id=self.workspace_id, + data=CommandInput(command=command), + timeout=timeout, + ) + + def list_profiles( + self, *, timeout: httpx.Timeout | float | None = None + ) -> ResourceList[Profile]: + result = self._run_command("ls -1 *.yml 2>/dev/null || true", timeout=timeout) + + profiles: list[Profile] = [] + if result.output: + for line in result.output.strip().split("\n"): + if match := _PROFILE_FILE_PATTERN.match(line.strip()): + profiles.append(Profile(name=match.group(1))) + + return ResourceList[Profile](root=profiles) + + def save_profile( + self, + name: str, + config: ProfileConfig | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + filename = _profile_filename(name) + + yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config + + body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" + # Transport the content base64-encoded: the base64 alphabet is inert + # inside single quotes, so arbitrary YAML cannot break out of the + # shell command (the filename is regex-validated above). + encoded = base64.b64encode(body.encode("utf-8")).decode("ascii") + self._run_command( + f"printf '%s' '{encoded}' | base64 -d > '{filename}'", timeout=timeout + ) + + def get_profile( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> str: + result = self._run_command(f"cat '{_profile_filename(name)}'", timeout=timeout) + return result.output + + def delete_profile( + self, name: str, *, timeout: httpx.Timeout | float | None = None + ) -> None: + self._run_command(f"rm -f '{_profile_filename(name)}'", timeout=timeout) + + def deploy( + self, + profile: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if profile is not None: + _validate_profile_name(profile) + self._execute( + _DEPLOY_WITH_PROFILE_OP, + id=self.workspace_id, + profile=profile, + timeout=timeout, + ) + else: + self._execute(_DEPLOY_OP, id=self.workspace_id, timeout=timeout) + + def teardown(self, *, timeout: httpx.Timeout | float | None = None) -> None: + self._execute(_TEARDOWN_OP, id=self.workspace_id, timeout=timeout) + + def scale( + self, + services: dict[str, int], + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + self._execute(_SCALE_OP, id=self.workspace_id, data=services, timeout=timeout) + + def start_stage( + self, + stage: PipelineStage | str, + profile: str | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + if profile is not None: + _validate_profile_name(profile) + self._execute( + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + id=self.workspace_id, + stage=stage, + profile=profile, + timeout=timeout, + ) + else: + self._execute( + _START_PIPELINE_STAGE_OP, + id=self.workspace_id, + stage=stage, + timeout=timeout, + ) + + def stop_stage( + self, + stage: PipelineStage | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + self._execute( + _STOP_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage, timeout=timeout + ) + + def get_stage_status( + self, + stage: PipelineStage | str, + *, + timeout: httpx.Timeout | float | None = None, + ) -> PipelineStatusList: + if isinstance(stage, PipelineStage): + stage = stage.value + + return self._execute( + _GET_PIPELINE_STATUS_OP, + id=self.workspace_id, + stage=stage, + timeout=timeout, + ) + + def wait_for_stage( + self, + stage: PipelineStage | str, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + server: str | None = None, + ) -> PipelineStatusList: + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + stage_name = stage.value if isinstance(stage, PipelineStage) else stage + elapsed = 0.0 + + while elapsed < timeout: + status_list = self.get_stage_status(stage) + + relevant_statuses = [] + for s in status_list: + if server is not None: + if s.server == server: + relevant_statuses.append(s) + else: + if s.steps or s.state != PipelineState.WAITING: + relevant_statuses.append(s) + + if not relevant_statuses: + log.debug( + "Pipeline stage '%s': no servers with steps yet, waiting...", + stage_name, + ) + time.sleep(poll_interval) + elapsed += poll_interval + continue + + all_completed = all( + s.state + in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED) + for s in relevant_statuses + ) + + if all_completed: + log.debug("Pipeline stage '%s' completed.", stage_name) + return PipelineStatusList(root=relevant_statuses) + + states = [f"{s.server}={s.state.value}" for s in relevant_statuses] + log.debug( + "Pipeline stage '%s' status: %s (elapsed: %.1fs)", + stage_name, + ", ".join(states), + elapsed, + ) + time.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds." + ) diff --git a/src/codesphere/_sync/resources/workspace/landscape/schemas.py b/src/codesphere/_sync/resources/workspace/landscape/schemas.py new file mode 100644 index 0000000..81f2d00 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/landscape/schemas.py @@ -0,0 +1,55 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/landscape/schemas.py +from codesphere.models.landscape import ( + ManagedServiceBuilder, + ManagedServiceBuilderContext, + ManagedServiceConfig, + NetworkConfig, + PathBuilder, + PathConfig, + PipelineStage, + PipelineState, + PipelineStatus, + PipelineStatusList, + PortBuilder, + PortConfig, + PrepareStageBuilder, + Profile, + ProfileBuilder, + ProfileConfig, + ReactiveServiceBuilder, + ReactiveServiceBuilderContext, + ReactiveServiceConfig, + StageConfig, + Step, + StepBuilder, + StepStatus, + TestStageBuilder, +) + +__all__ = [ + "ManagedServiceBuilder", + "ManagedServiceBuilderContext", + "ManagedServiceConfig", + "NetworkConfig", + "PathBuilder", + "PathConfig", + "PipelineStage", + "PipelineState", + "PipelineStatus", + "PipelineStatusList", + "PortBuilder", + "PortConfig", + "PrepareStageBuilder", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "ReactiveServiceBuilder", + "ReactiveServiceBuilderContext", + "ReactiveServiceConfig", + "StageConfig", + "Step", + "StepBuilder", + "StepStatus", + "TestStageBuilder", +] diff --git a/src/codesphere/_sync/resources/workspace/logs/__init__.py b/src/codesphere/_sync/resources/workspace/logs/__init__.py new file mode 100644 index 0000000..297b214 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/logs/__init__.py @@ -0,0 +1,24 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/logs/__init__.py +from .resources import ( + LogStream, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, + WorkspaceLogManager, +) +from .schemas import LogEntry, LogKind, LogProblem, LogStage + +__all__ = [ + "LogEntry", + "LogKind", + "LogProblem", + "LogStage", + "LogStream", + "LogTarget", + "ReplicaTarget", + "ServerTarget", + "StageTarget", + "WorkspaceLogManager", +] diff --git a/src/codesphere/_sync/resources/workspace/logs/operations.py b/src/codesphere/_sync/resources/workspace/logs/operations.py new file mode 100644 index 0000000..dcf7d87 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/logs/operations.py @@ -0,0 +1,20 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/logs/operations.py +from codesphere.core.operations import StreamOperation + +from .schemas import LogEntry + +_STREAM_STAGE_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/{stage}/{step}", + entry_model=LogEntry, +) + +_STREAM_SERVER_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/run/{step}/server/{server}", + entry_model=LogEntry, +) + +_STREAM_REPLICA_LOGS_OP = StreamOperation( + endpoint_template="/workspaces/{id}/logs/run/{step}/replica/{replica}", + entry_model=LogEntry, +) diff --git a/src/codesphere/_sync/resources/workspace/logs/resources.py b/src/codesphere/_sync/resources/workspace/logs/resources.py new file mode 100644 index 0000000..d1fd677 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/logs/resources.py @@ -0,0 +1,341 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/logs/resources.py +from __future__ import annotations + +import json +import logging +import time +import warnings +from collections.abc import AsyncIterator, Iterator +from typing import Any, cast + +import httpx + +from codesphere.core.operations import StreamOperation +from codesphere.exceptions import APIError, ValidationError, raise_for_status + +from ....core.base import WorkspaceScopedResource +from ....http_client import APIHttpClient +from .operations import ( + _STREAM_REPLICA_LOGS_OP, + _STREAM_SERVER_LOGS_OP, + _STREAM_STAGE_LOGS_OP, +) +from .schemas import ( + LogEntry, + LogProblem, + LogStage, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, +) + +log = logging.getLogger(__name__) + +# SSE event names that terminate a stream. +_STREAM_END_EVENTS = frozenset({"end", "close", "done", "complete"}) + + +def _resolve_target( + target: LogTarget, +) -> tuple[StreamOperation[LogEntry], dict[str, Any]]: + match target: + case StageTarget(stage=stage, step=step): + stage_value = stage.value if isinstance(stage, LogStage) else stage + return _STREAM_STAGE_LOGS_OP, {"stage": stage_value, "step": step} + case ServerTarget(step=step, server=server): + return _STREAM_SERVER_LOGS_OP, {"step": step, "server": server} + case ReplicaTarget(step=step, replica=replica): + return _STREAM_REPLICA_LOGS_OP, {"step": step, "replica": replica} + raise TypeError(f"Unsupported log target: {target!r}") + + +def _deprecated(old: str, new: str) -> None: + warnings.warn( + f"'{old}' is deprecated; use '{new}' instead.", + DeprecationWarning, + stacklevel=3, + ) + + +class LogStream: + """Async context manager for streaming logs via SSE.""" + + def __init__( + self, + http_client: APIHttpClient, + endpoint: str, + entry_model: type[LogEntry], + timeout: float | None = None, + ): + self._http_client = http_client + self._endpoint = endpoint + self._entry_model = entry_model + self._timeout = timeout + self._response: httpx.Response | None = None + self._stream_context: Any = None + + def __enter__(self) -> LogStream: + headers = {"Accept": "text/event-stream"} + # A user timeout bounds the read timeout too, so a silent stream + # wakes up instead of blocking past the deadline. + stream_timeout = self._http_client.stream_timeout + if self._timeout is not None: + connect = cast("float | None", stream_timeout.connect) + stream_timeout = httpx.Timeout(connect, read=self._timeout) + self._stream_context = self._http_client.stream( + "GET", + self._endpoint, + headers=headers, + timeout=stream_timeout, + ) + self._response = self._stream_context.__enter__() + + if not self._response.is_success: + self._response.read() + raise_for_status(self._response) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self._stream_context: + self._stream_context.__exit__(exc_type, exc_val, exc_tb) + + def __iter__(self) -> Iterator[LogEntry]: + return self._iterate() + + def _iterate(self) -> Iterator[LogEntry]: + if self._response is None: + raise RuntimeError("LogStream must be used as a context manager") + + if self._timeout is None: + for entry in self._parse_sse_stream(): + yield entry + return + + # Total-duration deadline, checked between entries; blocked reads + # are bounded by the read timeout set in __aenter__. Best effort: + # a stream trickling bytes can overrun the deadline by up to one + # read-timeout interval. + deadline = time.monotonic() + self._timeout + try: + for entry in self._parse_sse_stream(): + if time.monotonic() >= deadline: + raise TimeoutError( + f"Log stream exceeded timeout of {self._timeout}s" + ) + yield entry + except httpx.ReadTimeout as e: + raise TimeoutError( + f"Log stream exceeded timeout of {self._timeout}s" + ) from e + + def _parse_sse_stream(self) -> Iterator[LogEntry]: + if self._response is None: + raise RuntimeError("LogStream must be used as async context manager") + + event_type: str | None = None + data_buffer: list[str] = [] + + for line in self._response.iter_lines(): + line = line.strip() + + if not line: + if event_type and data_buffer: + data_str = "\n".join(data_buffer) + + if event_type in _STREAM_END_EVENTS: + return + + if event_type == "problem": + self._handle_problem(data_str) + elif event_type == "data": + for entry in self._parse_data(data_str): + yield entry + + elif event_type in _STREAM_END_EVENTS: + return + + event_type = None + data_buffer = [] + continue + + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_buffer.append(line[5:].strip()) + elif not line.startswith(":") and event_type: + data_buffer.append(line) + + def _parse_data(self, data_str: str) -> list[LogEntry]: + entries = [] + try: + json_data = json.loads(data_str) + if isinstance(json_data, list): + for item in json_data: + entries.append(self._entry_model.model_validate(item)) + else: + entries.append(self._entry_model.model_validate(json_data)) + except json.JSONDecodeError as e: + log.warning(f"Failed to parse log entry JSON: {e}") + except Exception as e: + log.warning(f"Failed to validate log entry: {e}") + return entries + + def _handle_problem(self, data_str: str) -> None: + try: + problem_data = json.loads(data_str) + problem = LogProblem.model_validate(problem_data) + + if problem.status == 400: + raise ValidationError( + message=problem.reason, + errors=[{"detail": problem.detail}] if problem.detail else None, + ) + else: + raise APIError( + message=problem.reason, + status_code=problem.status, + response_body=problem_data, + ) + except json.JSONDecodeError as e: + raise APIError(message=f"Invalid problem event: {data_str}") from e + + +class WorkspaceLogManager(WorkspaceScopedResource): + """Manager for streaming workspace logs via SSE. + + The unified API takes a :data:`LogTarget`: + + ```python + for entry in workspace.logs.stream(StageTarget(LogStage.RUN, step=1)): + ... + entries = workspace.logs.collect(ServerTarget(step=1, server="web")) + ``` + """ + + def _build_endpoint(self, operation: StreamOperation, **kwargs: Any) -> str: + return operation.endpoint_template.format(id=self.workspace_id, **kwargs) + + def open(self, target: LogTarget, timeout: float | None = None) -> LogStream: + """Open a log stream for ``target`` as an async context manager.""" + op, params = _resolve_target(target) + endpoint = self._build_endpoint(op, **params) + return LogStream(self._http_client, endpoint, op.entry_model, timeout=timeout) + + def stream( + self, + target: LogTarget | LogStage | str, + step: int | None = None, + timeout: float | None = 30.0, + ) -> Iterator[LogEntry]: + """Stream log entries for ``target`` as they arrive.""" + resolved = self._coerce_target(target, step, "stream") + with self.open(resolved, timeout) as stream: + for entry in stream: + yield entry + + def collect( + self, + target: LogTarget | LogStage | str, + step: int | None = None, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Collect log entries for ``target`` into a list.""" + resolved = self._coerce_target(target, step, "collect") + entries: list[LogEntry] = [] + try: + with self.open(resolved, timeout) as stream: + for entry in stream: + entries.append(entry) + if max_entries and len(entries) >= max_entries: + break + except TimeoutError: + pass + return entries + + @staticmethod + def _coerce_target( + target: LogTarget | LogStage | str, step: int | None, method: str + ) -> LogTarget: + if isinstance(target, StageTarget | ServerTarget | ReplicaTarget): + return target + _deprecated(f"{method}(stage, step)", f"{method}(StageTarget(stage, step))") + return StageTarget(stage=target, step=step if step is not None else 0) + + # ------------------------------------------------------------------ + # Deprecated aliases (kept for backwards compatibility) + # ------------------------------------------------------------------ + + def open_stream( + self, stage: LogStage | str, step: int, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`StageTarget`.""" + _deprecated("open_stream", "open(StageTarget(...))") + return self.open(StageTarget(stage=stage, step=step), timeout=timeout) + + def open_server_stream( + self, step: int, server: str, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`ServerTarget`.""" + _deprecated("open_server_stream", "open(ServerTarget(...))") + return self.open(ServerTarget(step=step, server=server), timeout=timeout) + + def open_replica_stream( + self, step: int, replica: str, timeout: float | None = None + ) -> LogStream: + """Deprecated: use :meth:`open` with a :class:`ReplicaTarget`.""" + _deprecated("open_replica_stream", "open(ReplicaTarget(...))") + return self.open(ReplicaTarget(step=step, replica=replica), timeout=timeout) + + def stream_server( + self, step: int, server: str, timeout: float | None = None + ) -> Iterator[LogEntry]: + """Deprecated: use :meth:`stream` with a :class:`ServerTarget`.""" + _deprecated("stream_server", "stream(ServerTarget(...))") + for entry in self.stream( + ServerTarget(step=step, server=server), timeout=timeout + ): + yield entry + + def stream_replica( + self, step: int, replica: str, timeout: float | None = None + ) -> Iterator[LogEntry]: + """Deprecated: use :meth:`stream` with a :class:`ReplicaTarget`.""" + _deprecated("stream_replica", "stream(ReplicaTarget(...))") + for entry in self.stream( + ReplicaTarget(step=step, replica=replica), timeout=timeout + ): + yield entry + + def collect_server( + self, + step: int, + server: str, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Deprecated: use :meth:`collect` with a :class:`ServerTarget`.""" + _deprecated("collect_server", "collect(ServerTarget(...))") + return self.collect( + ServerTarget(step=step, server=server), + max_entries=max_entries, + timeout=timeout, + ) + + def collect_replica( + self, + step: int, + replica: str, + max_entries: int | None = None, + timeout: float | None = 30.0, + ) -> list[LogEntry]: + """Deprecated: use :meth:`collect` with a :class:`ReplicaTarget`.""" + _deprecated("collect_replica", "collect(ReplicaTarget(...))") + return self.collect( + ReplicaTarget(step=step, replica=replica), + max_entries=max_entries, + timeout=timeout, + ) diff --git a/src/codesphere/_sync/resources/workspace/logs/schemas.py b/src/codesphere/_sync/resources/workspace/logs/schemas.py new file mode 100644 index 0000000..f3b22ed --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/logs/schemas.py @@ -0,0 +1,23 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/logs/schemas.py +from codesphere.models.logs import ( + LogEntry, + LogKind, + LogProblem, + LogStage, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, +) + +__all__ = [ + "LogEntry", + "LogKind", + "LogProblem", + "LogStage", + "LogTarget", + "ReplicaTarget", + "ServerTarget", + "StageTarget", +] diff --git a/src/codesphere/_sync/resources/workspace/operations.py b/src/codesphere/_sync/resources/workspace/operations.py new file mode 100644 index 0000000..a48dcde --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/operations.py @@ -0,0 +1,60 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/operations.py +from __future__ import annotations + +from types import NoneType + +from codesphere.core.operations import APIOperation + +from ...core.base import ResourceList +from .schemas import ( + CommandInput, + CommandOutput, + Workspace, + WorkspaceCreate, + WorkspaceStatus, +) + +_LIST_BY_TEAM_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/team/{team_id}", + response_model=ResourceList[Workspace], +) + +_GET_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{workspace_id}", + response_model=Workspace, +) + +_CREATE_OP = APIOperation( + method="POST", + endpoint_template="/workspaces", + input_model=WorkspaceCreate, + response_model=Workspace, +) + +_UPDATE_OP = APIOperation( + method="PATCH", + endpoint_template="/workspaces/{id}", + response_model=NoneType, +) + +_DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/workspaces/{id}", + response_model=NoneType, +) + +_GET_STATUS_OP = APIOperation( + method="GET", + endpoint_template="/workspaces/{id}/status", + response_model=WorkspaceStatus, +) + +_EXECUTE_COMMAND_OP = APIOperation( + method="POST", + endpoint_template="/workspaces/{id}/execute", + input_model=CommandInput, + response_model=CommandOutput, +) diff --git a/src/codesphere/_sync/resources/workspace/resources.py b/src/codesphere/_sync/resources/workspace/resources.py new file mode 100644 index 0000000..4669cc4 --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/resources.py @@ -0,0 +1,35 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/resources.py +import httpx + +from codesphere.exceptions import ValidationError + +from ...core import ResourceBase +from .operations import ( + _CREATE_OP, + _GET_OP, + _LIST_BY_TEAM_OP, +) +from .schemas import Workspace, WorkspaceCreate + + +class WorkspacesResource(ResourceBase): + def list( + self, team_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> list[Workspace]: + if team_id <= 0: + raise ValidationError("team_id must be a positive integer") + result = self._execute(_LIST_BY_TEAM_OP, team_id=team_id, timeout=timeout) + return result.root + + def get( + self, workspace_id: int, *, timeout: httpx.Timeout | float | None = None + ) -> Workspace: + if workspace_id <= 0: + raise ValidationError("workspace_id must be a positive integer") + return self._execute(_GET_OP, workspace_id=workspace_id, timeout=timeout) + + def create( + self, payload: WorkspaceCreate, *, timeout: httpx.Timeout | float | None = None + ) -> Workspace: + return self._execute(_CREATE_OP, data=payload, timeout=timeout) diff --git a/src/codesphere/_sync/resources/workspace/schemas.py b/src/codesphere/_sync/resources/workspace/schemas.py new file mode 100644 index 0000000..6a5089b --- /dev/null +++ b/src/codesphere/_sync/resources/workspace/schemas.py @@ -0,0 +1,139 @@ +# Do not edit this file directly. It has been autogenerated from +# src/codesphere/_async/resources/workspace/schemas.py +from __future__ import annotations + +import asyncio +import logging +from functools import cached_property + +import httpx + +from codesphere.models.workspace import ( + CommandInput, + CommandOutput, + WorkspaceBase, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, +) +from codesphere.utils import update_model_fields + +from ...core.base import BoundModel +from .env_vars import EnvVar, WorkspaceEnvVarManager +from .git import WorkspaceGitManager +from .landscape import WorkspaceLandscapeManager +from .logs import WorkspaceLogManager +import time + +log = logging.getLogger(__name__) + +__all__ = [ + "CommandInput", + "CommandOutput", + "EnvVar", + "Workspace", + "WorkspaceBase", + "WorkspaceCreate", + "WorkspaceStatus", + "WorkspaceUpdate", +] + + +class Workspace(WorkspaceBase, BoundModel): + def update( + self, + data: WorkspaceUpdate, + *, + timeout: httpx.Timeout | float | None = None, + ) -> None: + from .operations import _UPDATE_OP + + self._execute(_UPDATE_OP, id=self.id, data=data, timeout=timeout) + update_model_fields(target=self, source=data) + + def delete(self, *, timeout: httpx.Timeout | float | None = None) -> None: + from .operations import _DELETE_OP + + self._execute(_DELETE_OP, id=self.id, timeout=timeout) + + def get_status( + self, *, timeout: httpx.Timeout | float | None = None + ) -> WorkspaceStatus: + from .operations import _GET_STATUS_OP + + return self._execute(_GET_STATUS_OP, id=self.id, timeout=timeout) + + def wait_until_running( + self, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + ) -> None: + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + elapsed = 0.0 + while elapsed < timeout: + status = self.get_status() + if status.is_running: + log.debug("Workspace %s is now running.", self.id) + return + log.debug( + "Workspace %s not running yet, waiting %ss... (elapsed: %.1fs)", + self.id, + poll_interval, + elapsed, + ) + time.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Workspace {self.id} did not reach running state within {timeout} seconds." + ) + + def execute_command( + self, + command: str, + env: dict[str, str] | None = None, + *, + timeout: httpx.Timeout | float | None = None, + ) -> CommandOutput: + from .operations import _EXECUTE_COMMAND_OP + + command_data = CommandInput(command=command, env=env) + return self._execute( + _EXECUTE_COMMAND_OP, id=self.id, data=command_data, timeout=timeout + ) + + @cached_property + def env_vars(self) -> WorkspaceEnvVarManager: + return WorkspaceEnvVarManager(self._client(), workspace_id=self.id) + + @cached_property + def landscape(self) -> WorkspaceLandscapeManager: + """Manager for landscape operations (Multi Server Deployments).""" + return WorkspaceLandscapeManager(self._client(), workspace_id=self.id) + + @cached_property + def git(self) -> WorkspaceGitManager: + """Manager for git operations (head, pull).""" + return WorkspaceGitManager(self._client(), workspace_id=self.id) + + @cached_property + def logs(self) -> WorkspaceLogManager: + """Manager for streaming workspace logs. + + Provides methods to stream or collect logs from pipeline stages + (prepare, test, run) and Multi Server Deployment servers/replicas. + + Example: + ```python + # Stream logs as they arrive + for entry in workspace.logs.stream(stage="prepare", step=1): + print(entry.message) + + # Collect all logs at once + entries = workspace.logs.collect(stage="test", step=1) + ``` + """ + return WorkspaceLogManager(self._client(), workspace_id=self.id) diff --git a/src/codesphere/client.py b/src/codesphere/client.py index 30ada2a..478f7c5 100644 --- a/src/codesphere/client.py +++ b/src/codesphere/client.py @@ -1,93 +1,5 @@ -from types import TracebackType +"""Compatibility shim; the client lives in codesphere._async.client.""" -import httpx -from pydantic import SecretStr +from ._async.client import CodesphereSDK -from .config import RetryConfig, Settings -from .exceptions import AuthenticationError -from .http_client import APIHttpClient -from .resources.metadata import MetadataResource -from .resources.team import TeamsResource -from .resources.workspace import WorkspacesResource - - -class CodesphereSDK: - """Entry point to the Codesphere API. - - Configuration is resolved per argument: explicit constructor argument, - then the corresponding ``CS_*`` environment variable (``CS_TOKEN``, - ``CS_BASE_URL``, ``CS_CLIENT_TIMEOUT_CONNECT``/``_READ``; a local - ``.env`` file is honored), then the built-in default. A missing token - raises :class:`~codesphere.AuthenticationError` at construction. - """ - - teams: TeamsResource - workspaces: WorkspacesResource - metadata: MetadataResource - - def __init__( - self, - token: str | SecretStr | None = None, - base_url: str | None = None, - timeout: httpx.Timeout | None = None, - retry: RetryConfig | None = None, - http_client: APIHttpClient | None = None, - ): - if http_client is not None: - self._http_client = http_client - else: - settings = Settings() - - resolved_token = self._resolve_token(token, settings) - resolved_base_url = ( - base_url if base_url is not None else str(settings.base_url) - ) - resolved_timeout = ( - timeout - if timeout is not None - else httpx.Timeout( - settings.client_timeout_connect, - read=settings.client_timeout_read, - ) - ) - self._http_client = APIHttpClient( - token=resolved_token, - base_url=resolved_base_url, - timeout=resolved_timeout, - retry=retry, - ) - - self.teams = TeamsResource(self._http_client) - self.workspaces = WorkspacesResource(self._http_client) - self.metadata = MetadataResource(self._http_client) - - @staticmethod - def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr: - if token is not None: - return token if isinstance(token, SecretStr) else SecretStr(token) - if settings.token is not None: - return settings.token - raise AuthenticationError() - - async def open(self) -> None: - await self._http_client.open() - - async def close( - self, - exc_type: type[BaseException] | None = None, - exc_val: BaseException | None = None, - exc_tb: TracebackType | None = None, - ) -> None: - await self._http_client.close(exc_type, exc_val, exc_tb) - - async def __aenter__(self) -> "CodesphereSDK": - await self.open() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - await self.close(exc_type, exc_val, exc_tb) +__all__ = ["CodesphereSDK"] diff --git a/src/codesphere/config.py b/src/codesphere/config.py index 242e556..6c3615f 100644 --- a/src/codesphere/config.py +++ b/src/codesphere/config.py @@ -8,18 +8,18 @@ @dataclass(frozen=True, slots=True) class RetryConfig: - """Opt-in retry behavior for transient failures. + """Retry behavior for transient failures. - Disabled by default (``max_retries=0``). When enabled, requests using a - method in ``retry_methods`` are retried on the statuses in - ``retry_statuses`` and on connect/timeout errors. The delay honors a + Enabled by default (``max_retries=2``); set ``max_retries=0`` to disable. + Requests using a method in ``retry_methods`` are retried on the statuses + in ``retry_statuses`` and on connect/timeout errors. The delay honors a ``Retry-After`` response header (seconds or HTTP-date) when present, otherwise exponential backoff with jitter is used. Only idempotent methods are retried by default; add ``"POST"`` to ``retry_methods`` explicitly if your endpoints tolerate it. """ - max_retries: int = 0 + max_retries: int = 2 backoff_factor: float = 0.5 retry_statuses: frozenset[int] = frozenset({429, 502, 503, 504}) retry_methods: frozenset[str] = frozenset({"GET", "HEAD", "PUT", "DELETE"}) diff --git a/src/codesphere/core/base.py b/src/codesphere/core/base.py index 2a2e7e0..5cfdc1e 100644 --- a/src/codesphere/core/base.py +++ b/src/codesphere/core/base.py @@ -1,215 +1,19 @@ -from collections.abc import Mapping -from typing import Any, Generic, Literal, TypeVar - -import yaml -from pydantic import BaseModel, ConfigDict, PrivateAttr, RootModel -from pydantic.alias_generators import to_camel - -from ..http_client import APIHttpClient -from .handler import RequestData, execute_operation -from .operations import APIOperation - -ModelT = TypeVar("ModelT", bound=BaseModel) -ResponseT = TypeVar("ResponseT") - - -class ResourceBase: - """Base for resource and manager classes bound to an HTTP client.""" - - def __init__(self, http_client: APIHttpClient): - self._http_client = http_client - - async def _execute( - self, - op: APIOperation[ResponseT], - *, - data: RequestData | None = None, - params: Mapping[str, Any] | None = None, - **path_params: Any, - ) -> ResponseT: - return await execute_operation( - self._http_client, op, data=data, params=params, path_params=path_params - ) - - -class TeamScopedResource(ResourceBase): - """Base for managers operating within one team.""" - - def __init__(self, http_client: APIHttpClient, team_id: int): - super().__init__(http_client) - self.team_id = team_id - - -class WorkspaceScopedResource(ResourceBase): - """Base for managers operating within one workspace.""" - - def __init__(self, http_client: APIHttpClient, workspace_id: int): - super().__init__(http_client) - self.workspace_id = workspace_id - - -class CamelModel(BaseModel): - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - serialize_by_alias=True, - ) - - def to_dict( - self, *, by_alias: bool = True, exclude_none: bool = False - ) -> dict[str, Any]: - """Export model as a Python dictionary. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - - Returns: - Dictionary representation of the model. - """ - return self.model_dump(by_alias=by_alias, exclude_none=exclude_none) - - def to_json( - self, - *, - by_alias: bool = True, - exclude_none: bool = False, - indent: int | None = None, - ) -> str: - """Export model as a JSON string. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - indent: Number of spaces for indentation. None for compact output. - - Returns: - JSON string representation of the model. - """ - return self.model_dump_json( - by_alias=by_alias, exclude_none=exclude_none, indent=indent - ) - - def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: - """Export model as a YAML string. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - - Returns: - YAML string representation of the model. - """ - data = self.model_dump( - by_alias=by_alias, exclude_none=exclude_none, mode="json" - ) - return yaml.safe_dump( - data, default_flow_style=False, allow_unicode=True, sort_keys=False - ) - - -class BoundModel(CamelModel): - """API entity that carries the HTTP client it was fetched with. - - Instances returned by the SDK get their client attached automatically, - which lets entity methods (e.g. ``workspace.delete()``) make further - API calls. - """ - - _http_client: APIHttpClient | None = PrivateAttr(default=None) - - def _client(self) -> APIHttpClient: - if self._http_client is None or not hasattr(self._http_client, "request"): - raise RuntimeError( - "Cannot access resource on a detached model. HTTP client missing." - ) - return self._http_client - - async def _execute( - self, - op: APIOperation[ResponseT], - *, - data: RequestData | None = None, - params: Mapping[str, Any] | None = None, - **path_params: Any, - ) -> ResponseT: - return await execute_operation( - self._client(), op, data=data, params=params, path_params=path_params - ) - - -class ResourceList(RootModel[list[ModelT]], Generic[ModelT]): - root: list[ModelT] - - def __iter__(self): - return iter(self.root) - - def __getitem__(self, item): - return self.root[item] - - def __len__(self): - return len(self.root) - - def to_list( - self, - *, - by_alias: bool = True, - exclude_none: bool = False, - mode: Literal["python", "json"] = "python", - ) -> list[dict[str, Any]]: - """Export all items as a list of dictionaries. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - mode: Serialization mode. "python" returns native Python objects, - "json" returns JSON-compatible types (e.g., datetime as ISO string). - - Returns: - List of dictionary representations. - """ - return [ - item.model_dump(by_alias=by_alias, exclude_none=exclude_none, mode=mode) - for item in self.root - ] - - def to_json( - self, - *, - by_alias: bool = True, - exclude_none: bool = False, - indent: int | None = None, - ) -> str: - """Export all items as a JSON array string. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - indent: Number of spaces for indentation. None for compact output. - - Returns: - JSON array string representation. - """ - import json - - return json.dumps( - self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), - indent=indent, - ) - - def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: - """Export all items as a YAML string. - - Args: - by_alias: Use camelCase keys (API format) if True, snake_case if False. - exclude_none: Exclude fields with None values if True. - - Returns: - YAML string representation. - """ - return yaml.safe_dump( - self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - ) +"""Compatibility shim; implementations live in codesphere._async.core.base +(client-bound bases) and codesphere.core.models (shared pydantic bases).""" + +from .._async.core.base import ( + BoundModel, + ResourceBase, + TeamScopedResource, + WorkspaceScopedResource, +) +from .models import CamelModel, ResourceList + +__all__ = [ + "BoundModel", + "CamelModel", + "ResourceBase", + "ResourceList", + "TeamScopedResource", + "WorkspaceScopedResource", +] diff --git a/src/codesphere/core/handler.py b/src/codesphere/core/handler.py index 9c59a4e..0aa34ac 100644 --- a/src/codesphere/core/handler.py +++ b/src/codesphere/core/handler.py @@ -1,107 +1,9 @@ -import logging -from collections.abc import Mapping, Sequence -from types import NoneType -from typing import Any, TypeVar, cast +"""Compatibility shim; the executor lives in codesphere._async.core.handler.""" -import httpx -from pydantic import BaseModel, RootModel, ValidationError +from .._async.core.handler import ( + RequestData, + _bind_client, + execute_operation, +) -from ..http_client import APIHttpClient -from .operations import APIOperation - -log = logging.getLogger(__name__) - -ResponseT = TypeVar("ResponseT") - -type RequestData = BaseModel | Mapping[str, Any] | Sequence[Any] - - -async def execute_operation( - client: APIHttpClient, - op: APIOperation[ResponseT], - *, - data: RequestData | None = None, - params: Mapping[str, Any] | None = None, - path_params: Mapping[str, Any] | None = None, -) -> ResponseT: - """Execute a declarative :class:`APIOperation` against the API. - - Path parameters are passed explicitly and formatted into - ``op.endpoint_template``. When ``op.input_model`` is set, ``data`` is - validated against it before the request is sent. A non-``None`` ``data`` - is always sent as the JSON body, even when empty. - """ - endpoint = _format_endpoint(op.endpoint_template, path_params or {}) - - request_kwargs: dict[str, Any] = {} - if params is not None: - request_kwargs["params"] = params - if data is not None: - request_kwargs["json"] = _serialize_payload(data, op.input_model) - - response = await client.request( - method=op.method, endpoint=endpoint, **request_kwargs - ) - return _parse_response(response, op.response_model, client, endpoint) - - -def _format_endpoint(template: str, path_params: Mapping[str, Any]) -> str: - try: - return template.format(**path_params) - except KeyError as e: - raise ValueError( - f"Missing path parameter {e.args[0]!r} for endpoint template {template!r}" - ) from e - - -def _serialize_payload(data: RequestData, input_model: type[BaseModel] | None) -> Any: - if input_model is not None: - model = ( - data if isinstance(data, input_model) else input_model.model_validate(data) - ) - return model.model_dump(exclude_none=True) - if isinstance(data, BaseModel): - return data.model_dump(exclude_none=True) - return data - - -def _parse_response( - response: httpx.Response, - response_model: type[ResponseT], - client: APIHttpClient | None, - endpoint_for_logging: str, -) -> ResponseT: - if response_model is NoneType: - return cast(ResponseT, None) - - try: - json_response = response.json() - log.debug(f"Validating JSON response for endpoint: {endpoint_for_logging}") - except httpx.ResponseNotRead: - log.warning(f"No JSON response body for {endpoint_for_logging}") - return cast(ResponseT, None) - - model_cls = cast(type[BaseModel], response_model) - try: - instance = model_cls.model_validate(json_response) - except ValidationError as e: - log.error(f"Pydantic validation failed for {endpoint_for_logging}. Error: {e}") - log.error(f"Failing JSON data: {json_response}") - raise - - _bind_client(instance, client) - log.debug("Successfully validated response for %s.", endpoint_for_logging) - return cast(ResponseT, instance) - - -def _bind_client(instance: BaseModel, client: APIHttpClient | None) -> None: - """Attach the HTTP client to returned models so they can make calls.""" - from .base import BoundModel - - if isinstance(instance, BoundModel): - instance._http_client = client - - if isinstance(instance, RootModel) and isinstance(instance.root, list): - for item in instance.root: - if isinstance(item, BoundModel): - item._http_client = client +__all__ = ["RequestData", "_bind_client", "execute_operation"] diff --git a/src/codesphere/core/models.py b/src/codesphere/core/models.py new file mode 100644 index 0000000..ca6940b --- /dev/null +++ b/src/codesphere/core/models.py @@ -0,0 +1,150 @@ +"""Shared pydantic bases: camelCase aliasing and list roots. + +These are pure data helpers with no HTTP or async dependencies; both the +async and the sync client trees build on them. +""" + +from typing import Any, Generic, Literal, TypeVar + +import yaml +from pydantic import BaseModel, ConfigDict, RootModel +from pydantic.alias_generators import to_camel + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class CamelModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, + ) + + def to_dict( + self, *, by_alias: bool = True, exclude_none: bool = False + ) -> dict[str, Any]: + """Export model as a Python dictionary. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + Dictionary representation of the model. + """ + return self.model_dump(by_alias=by_alias, exclude_none=exclude_none) + + def to_json( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + indent: int | None = None, + ) -> str: + """Export model as a JSON string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + indent: Number of spaces for indentation. None for compact output. + + Returns: + JSON string representation of the model. + """ + return self.model_dump_json( + by_alias=by_alias, exclude_none=exclude_none, indent=indent + ) + + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: + """Export model as a YAML string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + YAML string representation of the model. + """ + data = self.model_dump( + by_alias=by_alias, exclude_none=exclude_none, mode="json" + ) + return yaml.safe_dump( + data, default_flow_style=False, allow_unicode=True, sort_keys=False + ) + + +class ResourceList(RootModel[list[ModelT]], Generic[ModelT]): + root: list[ModelT] + + def __iter__(self): + return iter(self.root) + + def __getitem__(self, item): + return self.root[item] + + def __len__(self): + return len(self.root) + + def to_list( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + mode: Literal["python", "json"] = "python", + ) -> list[dict[str, Any]]: + """Export all items as a list of dictionaries. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + mode: Serialization mode. "python" returns native Python objects, + "json" returns JSON-compatible types (e.g., datetime as ISO string). + + Returns: + List of dictionary representations. + """ + return [ + item.model_dump(by_alias=by_alias, exclude_none=exclude_none, mode=mode) + for item in self.root + ] + + def to_json( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + indent: int | None = None, + ) -> str: + """Export all items as a JSON array string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + indent: Number of spaces for indentation. None for compact output. + + Returns: + JSON array string representation. + """ + import json + + return json.dumps( + self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), + indent=indent, + ) + + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: + """Export all items as a YAML string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + YAML string representation. + """ + return yaml.safe_dump( + self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) diff --git a/src/codesphere/core/operations.py b/src/codesphere/core/operations.py index 6cc6c1b..c1dfae2 100644 --- a/src/codesphere/core/operations.py +++ b/src/codesphere/core/operations.py @@ -3,6 +3,8 @@ from pydantic import BaseModel +from ..feature_flags import FlagRequirement + ResponseT = TypeVar("ResponseT") EntryT = TypeVar("EntryT", bound=BaseModel) @@ -15,18 +17,25 @@ class APIOperation(Generic[ResponseT]): type of ``_execute``: use a pydantic model class (``ResourceList[Team]`` included) or ``types.NoneType`` for endpoints without a response body. ``input_model``, when set, validates the ``data`` payload before the - request is sent. + request is sent. ``required_flag``, when set, gates the operation on a + platform feature flag — the call fails before any request when the + flag is not enabled on the connected instance. """ method: str endpoint_template: str response_model: type[ResponseT] input_model: type[BaseModel] | None = None + required_flag: FlagRequirement | None = None @dataclass(frozen=True, slots=True) class StreamOperation(Generic[EntryT]): - """Declarative description of an SSE streaming endpoint.""" + """Declarative description of an SSE streaming endpoint. + + Streaming endpoints are not flag-gated declaratively yet; gate their + entry points explicitly with ``await self._require_flag(...)``. + """ endpoint_template: str entry_model: type[EntryT] diff --git a/src/codesphere/exceptions.py b/src/codesphere/exceptions.py index 1ac7cd8..a3d520e 100644 --- a/src/codesphere/exceptions.py +++ b/src/codesphere/exceptions.py @@ -160,6 +160,83 @@ def __init__(self, message: str | None = None): super().__init__(message) +def _flag_label(flag: str, category: str | None) -> str: + if category: + return f"'{flag}' (category '{category}')" + return f"'{flag}'" + + +class FeatureFlagError(CodesphereError): + """Base for feature-flag gating errors. + + Raised before any request is sent when an SDK feature requires a + platform feature flag that the connected instance does not satisfy. + """ + + def __init__( + self, + message: str | None = None, + *, + flag: str = "", + category: str | None = None, + ): + self.flag = flag + self.category = category + if message is None: + message = ( + f"Feature flag {_flag_label(flag, category)} blocks this SDK feature." + ) + super().__init__(message) + + +class FeatureNotAvailableError(FeatureFlagError): + """The required flag is not available on the connected instance. + + The feature does not exist on this platform instance (wrong + environment, or the platform predates the feature). + """ + + def __init__( + self, + message: str | None = None, + *, + flag: str = "", + category: str | None = None, + legacy_platform: bool = False, + ): + if message is None: + message = ( + f"Feature flag {_flag_label(flag, category)} is not available " + "on this Codesphere instance." + ) + if legacy_platform: + message += ( + " The instance did not expose the flags endpoint at all; " + "it may predate feature flags." + ) + message += " Use sdk.flags.get() to inspect available flags." + super().__init__(message, flag=flag, category=category) + + +class FeatureNotEnabledError(FeatureFlagError): + """The required flag is available but not enabled on the connected instance.""" + + def __init__( + self, + message: str | None = None, + *, + flag: str = "", + category: str | None = None, + ): + if message is None: + message = ( + f"Feature flag {_flag_label(flag, category)} is not enabled on " + "this Codesphere instance. Ask an administrator to enable it, " + "or use sdk.flags.get() to inspect the current flags." + ) + super().__init__(message, flag=flag, category=category) + + def raise_for_status(response: httpx.Response) -> None: """Convert HTTP errors to appropriate SDK exceptions. diff --git a/src/codesphere/feature_flags.py b/src/codesphere/feature_flags.py new file mode 100644 index 0000000..143d598 --- /dev/null +++ b/src/codesphere/feature_flags.py @@ -0,0 +1,94 @@ +"""Feature-flag engine: snapshot models, requirement declarations, cache. + +This module lives at the top level (not in ``core``) on purpose: +``core.base`` imports ``http_client`` at module level, so ``http_client`` +must not import from ``codesphere.core``. The engine only depends on +pydantic and :mod:`codesphere.exceptions`, which keeps the import graph +cycle-free. The public read API is :class:`codesphere.resources.flags.FlagsResource`. +""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict + +FLAGS_ENDPOINT = "/ide-service/flags" + + +class FlagCategory(StrEnum): + INTERNAL = "internal" + PREVIEW = "preview" + FEATURES = "features" + + +class CategoryFlags(BaseModel): + """Available and enabled flags of one category.""" + + model_config = ConfigDict(frozen=True) + + available: frozenset[str] = frozenset() + enabled: frozenset[str] = frozenset() + + +class FlagsSnapshot(BaseModel): + """Immutable view of the platform's feature flags at fetch time.""" + + model_config = ConfigDict(frozen=True) + + internal: CategoryFlags = CategoryFlags() + preview: CategoryFlags = CategoryFlags() + features: CategoryFlags = CategoryFlags() + + @classmethod + def empty(cls) -> "FlagsSnapshot": + return cls() + + def category(self, category: FlagCategory) -> CategoryFlags: + match category: + case FlagCategory.INTERNAL: + return self.internal + case FlagCategory.PREVIEW: + return self.preview + case FlagCategory.FEATURES: + return self.features + + def is_available(self, flag: str, category: FlagCategory | None = None) -> bool: + """Whether ``flag`` exists on this instance (in any or one category).""" + if category is not None: + return flag in self.category(category).available + return any(flag in self.category(c).available for c in FlagCategory) + + def is_enabled(self, flag: str, category: FlagCategory | None = None) -> bool: + """Whether ``flag`` is enabled on this instance (in any or one category).""" + if category is not None: + return flag in self.category(category).enabled + return any(flag in self.category(c).enabled for c in FlagCategory) + + def categories_with(self, flag: str) -> tuple[FlagCategory, ...]: + """All categories in which ``flag`` is available (collision insight).""" + return tuple(c for c in FlagCategory if flag in self.category(c).available) + + +@dataclass(frozen=True, slots=True) +class FlagRequirement: + """Declares that an SDK feature needs a platform flag to be enabled. + + Attach to an :class:`~codesphere.core.operations.APIOperation` via + ``required_flag=...`` (enforced automatically before any request), or + check explicitly with ``await self._require_flag(...)``. SDK-internal + declarations should always set an explicit ``category``. + """ + + flag: str + category: FlagCategory | None = None + + +def __getattr__(name: str) -> Any: + # Backward compatibility: FlagsStore historically lived here. The + # async implementation now lives in codesphere._async.flags_store. + if name == "FlagsStore": + from ._async.flags_store import FlagsStore + + return FlagsStore + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/codesphere/http_client.py b/src/codesphere/http_client.py index 46057c5..dec0fd1 100644 --- a/src/codesphere/http_client.py +++ b/src/codesphere/http_client.py @@ -1,183 +1,5 @@ -import asyncio -import logging -import random -from contextlib import AbstractAsyncContextManager -from datetime import UTC, datetime -from email.utils import parsedate_to_datetime -from types import TracebackType -from typing import Any, cast +"""Compatibility shim; the transport lives in codesphere._async.http_client.""" -import httpx -from pydantic import SecretStr +from ._async.http_client import APIHttpClient, _parse_retry_after -from .config import RetryConfig -from .exceptions import NetworkError, TimeoutError, raise_for_status - -log = logging.getLogger(__name__) - - -def _parse_retry_after(value: str | None) -> float | None: - """Parse a Retry-After header: delta-seconds or an HTTP-date.""" - if not value: - return None - if value.isdigit(): - return float(value) - try: - retry_at = parsedate_to_datetime(value) - except (TypeError, ValueError): - return None - return max(0.0, (retry_at - datetime.now(UTC)).total_seconds()) - - -class APIHttpClient: - """Thin async transport around httpx. - - Receives its full configuration from the caller (the SDK resolves - arguments, environment variables, and defaults) and owns nothing but - the connection lifecycle, error mapping, and opt-in retries. - """ - - def __init__( - self, - *, - token: SecretStr, - base_url: str, - timeout: httpx.Timeout, - retry: RetryConfig | None = None, - ): - self._token = token.get_secret_value() - self._base_url = base_url - self._client: httpx.AsyncClient | None = None - self._retry = retry if retry is not None else RetryConfig() - - self._timeout_config = timeout - self._client_config: dict[str, Any] = { - "base_url": self._base_url, - "headers": {"Authorization": f"Bearer {self._token}"}, - "timeout": self._timeout_config, - } - - def _get_client(self) -> httpx.AsyncClient: - if not self._client: - raise RuntimeError( - "Client is not open. Please use 'async with sdk:' " - "or call 'await sdk.open()' before making requests." - ) - return self._client - - @property - def timeout(self) -> httpx.Timeout: - """The configured request timeouts.""" - return self._timeout_config - - @property - def stream_timeout(self) -> httpx.Timeout: - """Timeouts for long-lived streams: configured connect, unbounded read.""" - # httpx.Timeout attributes are untyped upstream; connect is float | None - connect = cast("float | None", self._timeout_config.connect) - return httpx.Timeout(connect, read=None) - - def stream( - self, method: str, endpoint: str, **kwargs: Any - ) -> AbstractAsyncContextManager[httpx.Response]: - """Open a streaming request (e.g. SSE) on the underlying client.""" - return self._get_client().stream(method, endpoint, **kwargs) - - async def open(self) -> None: - if not self._client: - self._client = httpx.AsyncClient(**self._client_config) - await self._client.__aenter__() - - async def close( - self, - exc_type: type[BaseException] | None = None, - exc_val: BaseException | None = None, - exc_tb: TracebackType | None = None, - ) -> None: - if self._client: - await self._client.__aexit__(exc_type, exc_val, exc_tb) - self._client = None - - async def __aenter__(self) -> "APIHttpClient": - await self.open() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - await self.close(exc_type, exc_val, exc_tb) - - async def request( - self, method: str, endpoint: str, **kwargs: Any - ) -> httpx.Response: - client = self._get_client() - - log.debug(f"Request: {method} {endpoint}") - log.debug(f"Request kwargs: {kwargs}") - - retryable_method = method.upper() in self._retry.retry_methods - attempts = self._retry.max_retries + 1 if retryable_method else 1 - - for attempt in range(attempts): - last_attempt = attempt == attempts - 1 - try: - response = await client.request(method, endpoint, **kwargs) - except httpx.TimeoutException as e: - if not last_attempt: - await self._sleep_before_retry(attempt, None, method, endpoint) - continue - log.error(f"Request timeout for {method} {endpoint}: {e}") - raise TimeoutError(f"Request to {endpoint} timed out.") from e - except httpx.ConnectError as e: - if not last_attempt: - await self._sleep_before_retry(attempt, None, method, endpoint) - continue - log.error(f"Connection error for {method} {endpoint}: {e}") - raise NetworkError( - f"Failed to connect to the API: {e}", - original_error=e, - ) from e - except httpx.RequestError as e: - log.error(f"Network error for {method} {endpoint}: {e}") - raise NetworkError( - f"A network error occurred: {e}", - original_error=e, - ) from e - - log.debug( - f"Response: {response.status_code} {response.reason_phrase} " - f"for {method} {endpoint}" - ) - - if response.status_code in self._retry.retry_statuses and not last_attempt: - await self._sleep_before_retry(attempt, response, method, endpoint) - continue - - raise_for_status(response) - return response - - raise AssertionError("unreachable: retry loop must return or raise") - - async def _sleep_before_retry( - self, - attempt: int, - response: httpx.Response | None, - method: str, - endpoint: str, - ) -> None: - delay = None - if response is not None: - delay = _parse_retry_after(response.headers.get("Retry-After")) - if delay is None: - delay = self._retry.backoff_factor * (2**attempt) - # Jitter to avoid thundering herds; not security-relevant. - delay += random.uniform(0, self._retry.backoff_factor / 2) # nosec B311 - - log.debug( - f"Retrying {method} {endpoint} in {delay:.2f}s " - f"(attempt {attempt + 1}/{self._retry.max_retries})" - ) - await asyncio.sleep(delay) +__all__ = ["APIHttpClient", "_parse_retry_after"] diff --git a/src/codesphere/models/__init__.py b/src/codesphere/models/__init__.py new file mode 100644 index 0000000..bfc7768 --- /dev/null +++ b/src/codesphere/models/__init__.py @@ -0,0 +1,89 @@ +"""Shared request/response models (pure DTOs, no HTTP or async code). + +Both the async and the sync client accept and return these classes, so +payloads built here work with either flavor. +""" + +from .domain import ( + CertificateRequestStatus, + CustomDomainConfig, + DNSEntries, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) +from .env_vars import EnvVar +from .git import GitHead +from .landscape import ( + PipelineStage, + PipelineState, + PipelineStatus, + PipelineStatusList, + Profile, + ProfileBuilder, + ProfileConfig, +) +from .logs import ( + LogEntry, + LogKind, + LogProblem, + LogStage, + LogTarget, + ReplicaTarget, + ServerTarget, + StageTarget, +) +from .metadata import Characteristic, Datacenter, Image, WsPlan +from .team import TeamBase, TeamCreate +from .usage import LandscapeServiceEvent, LandscapeServiceSummary, ServiceAction +from .workspace import ( + CommandInput, + CommandOutput, + WorkspaceBase, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, +) + +__all__ = [ + "CertificateRequestStatus", + "Characteristic", + "CommandInput", + "CommandOutput", + "CustomDomainConfig", + "DNSEntries", + "Datacenter", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "EnvVar", + "GitHead", + "Image", + "LandscapeServiceEvent", + "LandscapeServiceSummary", + "LogEntry", + "LogKind", + "LogProblem", + "LogStage", + "LogTarget", + "PipelineStage", + "PipelineState", + "PipelineStatus", + "PipelineStatusList", + "Profile", + "ProfileBuilder", + "ProfileConfig", + "ReplicaTarget", + "RoutingMap", + "ServerTarget", + "ServiceAction", + "StageTarget", + "TeamBase", + "TeamCreate", + "WorkspaceBase", + "WorkspaceCreate", + "WorkspaceStatus", + "WorkspaceUpdate", + "WsPlan", +] diff --git a/src/codesphere/models/domain.py b/src/codesphere/models/domain.py new file mode 100644 index 0000000..67051b0 --- /dev/null +++ b/src/codesphere/models/domain.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pydantic import Field, RootModel + +from ..core.models import CamelModel + +type RoutingMap = dict[str, list[int]] + + +class CertificateRequestStatus(CamelModel): + issued: bool + reason: str | None = None + + +class DNSEntries(CamelModel): + a: str + cname: str + txt: str + + +class DomainVerificationStatus(CamelModel): + verified: bool + reason: str | None = None + + +class CustomDomainConfig(CamelModel): + restricted: bool | None = None + max_body_size_mb: int | None = None + max_connection_timeout_s: int | None = None + use_regex: bool | None = None + + +class DomainRouting(RootModel): + root: RoutingMap = Field(default_factory=dict) + + def add(self, path: str, workspace_ids: list[int]) -> DomainRouting: + self.root[path] = workspace_ids + return self + + +class DomainBase(CamelModel): + name: str + team_id: int + data_center_id: int + workspaces: RoutingMap + certificate_request_status: CertificateRequestStatus + dns_entries: DNSEntries + domain_verification_status: DomainVerificationStatus + custom_config_revision: int | None = None + custom_config: CustomDomainConfig | None = None diff --git a/src/codesphere/models/env_vars.py b/src/codesphere/models/env_vars.py new file mode 100644 index 0000000..8f7abb4 --- /dev/null +++ b/src/codesphere/models/env_vars.py @@ -0,0 +1,8 @@ +from ..core.models import CamelModel + + +class EnvVar(CamelModel): + """Environment variable model.""" + + name: str + value: str diff --git a/src/codesphere/models/git.py b/src/codesphere/models/git.py new file mode 100644 index 0000000..02f4bd2 --- /dev/null +++ b/src/codesphere/models/git.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from ..core.models import CamelModel + + +class GitHead(CamelModel): + head: str diff --git a/src/codesphere/models/landscape.py b/src/codesphere/models/landscape.py new file mode 100644 index 0000000..d1aef3a --- /dev/null +++ b/src/codesphere/models/landscape.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, Field + +from ..core.models import CamelModel, ResourceList + + +class PipelineStage(str, Enum): + PREPARE = "prepare" + TEST = "test" + RUN = "run" + + +class PipelineState(str, Enum): + WAITING = "waiting" + RUNNING = "running" + SUCCESS = "success" + FAILURE = "failure" + ABORTED = "aborted" + + +class StepStatus(CamelModel): + state: PipelineState + started_at: str | None = None + finished_at: str | None = None + + +class PipelineStatus(CamelModel): + state: PipelineState + started_at: str | None = None + finished_at: str | None = None + steps: list[StepStatus] = Field(default_factory=list) + replica: str + server: str + + +# Alias kept for backwards compatibility; ResourceList provides the same API. +PipelineStatusList = ResourceList[PipelineStatus] + + +class Profile(BaseModel): + name: str + + +class Step(CamelModel): + name: str | None = None + command: str + + +class PortConfig(CamelModel): + port: int = Field(ge=1, le=65535) + is_public: bool = False + + +class PathConfig(CamelModel): + port: int = Field(ge=1, le=65535) + path: str + strip_path: bool | None = None + + +class NetworkConfig(CamelModel): + ports: list[PortConfig] = Field(default_factory=list) + paths: list[PathConfig] = Field(default_factory=list) + + +class ReactiveServiceConfig(CamelModel): + steps: list[Step] = Field(default_factory=list) + plan: int + replicas: int = 1 + env: dict[str, str] | None = None + base_image: str | None = None + run_as_user: int | None = Field(default=None, ge=0, le=65534) + run_as_group: int | None = Field(default=None, ge=0, le=65534) + mount_sub_path: str | None = None + health_endpoint: str | None = None + network: NetworkConfig | None = None + + +class ManagedServiceConfig(CamelModel): + provider: str + plan: str + config: dict[str, Any] | None = None + secrets: dict[str, str] | None = None + + +class StageConfig(CamelModel): + steps: list[Step] = Field(default_factory=list) + + +class ProfileConfig(CamelModel): + schema_version: Literal["v0.2"] = Field(default="v0.2", alias="schemaVersion") + prepare: StageConfig = Field(default_factory=StageConfig) + test: StageConfig = Field(default_factory=StageConfig) + run: dict[str, ReactiveServiceConfig | ManagedServiceConfig] = Field( + default_factory=dict + ) + + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = True) -> str: + data = self.model_dump( + by_alias=by_alias, exclude_none=exclude_none, mode="json" + ) + return yaml.safe_dump( + data, default_flow_style=False, allow_unicode=True, sort_keys=False + ) + + +class StepBuilder: + def __init__(self, command: str, name: str | None = None): + self._command = command + self._name = name + + def build(self) -> Step: + return Step(command=self._command, name=self._name) + + +class PortBuilder: + def __init__(self, port: int): + self._port = port + self._is_public = False + + def public(self, is_public: bool = True) -> PortBuilder: + self._is_public = is_public + return self + + def build(self) -> PortConfig: + return PortConfig(port=self._port, is_public=self._is_public) + + +class PathBuilder: + def __init__(self, path: str, port: int): + self._path = path + self._port = port + self._strip_path: bool | None = None + + def strip_path(self, strip: bool = True) -> PathBuilder: + self._strip_path = strip + return self + + def build(self) -> PathConfig: + return PathConfig(port=self._port, path=self._path, strip_path=self._strip_path) + + +class ReactiveServiceBuilder: + def __init__(self, name: str): + self._name = name + self._steps: list[Step] = [] + self._env: dict[str, str] = {} + self._plan: int | None = None + self._replicas: int = 1 + self._base_image: str | None = None + self._run_as_user: int | None = None + self._run_as_group: int | None = None + self._mount_sub_path: str | None = None + self._health_endpoint: str | None = None + self._ports: list[PortConfig] = [] + self._paths: list[PathConfig] = [] + + @property + def name(self) -> str: + return self._name + + def add_step(self, command: str, name: str | None = None) -> ReactiveServiceBuilder: + self._steps.append(Step(command=command, name=name)) + return self + + def env(self, key: str, value: str) -> ReactiveServiceBuilder: + self._env[key] = value + return self + + def envs(self, env_vars: dict[str, str]) -> ReactiveServiceBuilder: + self._env.update(env_vars) + return self + + def plan(self, plan_id: int) -> ReactiveServiceBuilder: + self._plan = plan_id + return self + + def replicas(self, count: int) -> ReactiveServiceBuilder: + self._replicas = max(1, count) + return self + + def base_image(self, image: str) -> ReactiveServiceBuilder: + self._base_image = image + return self + + def run_as( + self, user: int | None = None, group: int | None = None + ) -> ReactiveServiceBuilder: + self._run_as_user = user + self._run_as_group = group + return self + + def mount_sub_path(self, path: str) -> ReactiveServiceBuilder: + self._mount_sub_path = path + return self + + def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilder: + self._health_endpoint = endpoint + return self + + def add_port(self, port: int, *, public: bool = False) -> ReactiveServiceBuilder: + self._ports.append(PortConfig(port=port, is_public=public)) + return self + + def add_path( + self, path: str, port: int, *, strip_path: bool | None = None + ) -> ReactiveServiceBuilder: + self._paths.append(PathConfig(port=port, path=path, strip_path=strip_path)) + return self + + def build(self) -> tuple[str, ReactiveServiceConfig]: + if self._plan is None: + raise ValueError( + f"Service '{self._name}' requires a plan ID. " + "Use .plan(plan_id) to set it." + ) + + network = None + if self._ports or self._paths: + network = NetworkConfig(ports=self._ports, paths=self._paths) + + config = ReactiveServiceConfig( + steps=self._steps, + plan=self._plan, + replicas=self._replicas, + env=self._env if self._env else None, + base_image=self._base_image, + run_as_user=self._run_as_user, + run_as_group=self._run_as_group, + mount_sub_path=self._mount_sub_path, + health_endpoint=self._health_endpoint, + network=network, + ) + return self._name, config + + +class ManagedServiceBuilder: + def __init__(self, name: str, provider: str, plan: str): + self._name = name + self._provider = provider + self._plan = plan + self._config: dict[str, Any] = {} + self._secrets: dict[str, str] = {} + + @property + def name(self) -> str: + return self._name + + def config(self, key: str, value: Any) -> ManagedServiceBuilder: + self._config[key] = value + return self + + def configs(self, config: dict[str, Any]) -> ManagedServiceBuilder: + self._config.update(config) + return self + + def secret(self, key: str, value: str) -> ManagedServiceBuilder: + self._secrets[key] = value + return self + + def secrets(self, secrets: dict[str, str]) -> ManagedServiceBuilder: + self._secrets.update(secrets) + return self + + def build(self) -> tuple[str, ManagedServiceConfig]: + config = ManagedServiceConfig( + provider=self._provider, + plan=self._plan, + config=self._config if self._config else None, + secrets=self._secrets if self._secrets else None, + ) + return self._name, config + + +class ProfileBuilder: + """Fluent builder for creating landscape profile configurations. + + Example: + ```python + profile = ( + ProfileBuilder() + .prepare() + .add_step("npm install") + .add_step("npm run build") + .done() + .add_reactive_service("web") + .add_step("npm start") + .add_port(3000, public=True) + .add_path("/api", port=3000) + .replicas(2) + .env("NODE_ENV", "production") + .done() + .add_managed_service("db", provider="postgres", plan="small") + .config("max_connections", 100) + .done() + .build() + ) + + # Save to workspace + await workspace.landscape.save_profile("production", profile) + ``` + """ + + def __init__(self) -> None: + self._prepare_steps: list[Step] = [] + self._test_steps: list[Step] = [] + self._services: dict[str, ReactiveServiceConfig | ManagedServiceConfig] = {} + self._current_service: Any | None = None + + def prepare(self) -> PrepareStageBuilder: + return PrepareStageBuilder(self) + + def test(self) -> TestStageBuilder: + return TestStageBuilder(self) + + def add_reactive_service(self, name: str) -> ReactiveServiceBuilderContext: + self._finalize_current_service() + self._current_service = ReactiveServiceBuilderContext(self, name) + return self._current_service + + def add_managed_service( + self, name: str, *, provider: str, plan: str + ) -> ManagedServiceBuilderContext: + self._finalize_current_service() + self._current_service = ManagedServiceBuilderContext(self, name, provider, plan) + return self._current_service + + def __getattr__(self, name: str) -> Any: + """Delegate unknown methods to the current service builder.""" + if self._current_service is not None and hasattr(self._current_service, name): + method = getattr(self._current_service, name) + if callable(method): + + def wrapper(*args: Any, **kwargs: Any) -> ProfileBuilder: + method(*args, **kwargs) + return self + + return wrapper + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") + + def _finalize_current_service(self) -> None: + if self._current_service is not None: + name, config = self._current_service._builder.build() + self._services[name] = config + self._current_service = None + + def build(self) -> ProfileConfig: + """Build the final profile configuration.""" + self._finalize_current_service() + return ProfileConfig( + prepare=StageConfig(steps=self._prepare_steps), + test=StageConfig(steps=self._test_steps), + run=self._services, + ) + + +class PrepareStageBuilder: + def __init__(self, parent: ProfileBuilder): + self._parent = parent + + def add_step(self, command: str, name: str | None = None) -> PrepareStageBuilder: + self._parent._prepare_steps.append(Step(command=command, name=name)) + return self + + def done(self) -> ProfileBuilder: + return self._parent + + +class TestStageBuilder: + def __init__(self, parent: ProfileBuilder): + self._parent = parent + + def add_step(self, command: str, name: str | None = None) -> TestStageBuilder: + self._parent._test_steps.append(Step(command=command, name=name)) + return self + + def done(self) -> ProfileBuilder: + return self._parent + + +class ReactiveServiceBuilderContext: + def __init__(self, parent: ProfileBuilder, name: str): + self._parent = parent + self._builder = ReactiveServiceBuilder(name) + + def add_step( + self, command: str, name: str | None = None + ) -> ReactiveServiceBuilderContext: + self._builder.add_step(command, name) + return self + + def env(self, key: str, value: str) -> ReactiveServiceBuilderContext: + self._builder.env(key, value) + return self + + def envs(self, env_vars: dict[str, str]) -> ReactiveServiceBuilderContext: + self._builder.envs(env_vars) + return self + + def plan(self, plan_id: int) -> ReactiveServiceBuilderContext: + self._builder.plan(plan_id) + return self + + def replicas(self, count: int) -> ReactiveServiceBuilderContext: + self._builder.replicas(count) + return self + + def base_image(self, image: str) -> ReactiveServiceBuilderContext: + self._builder.base_image(image) + return self + + def run_as( + self, user: int | None = None, group: int | None = None + ) -> ReactiveServiceBuilderContext: + self._builder.run_as(user, group) + return self + + def mount_sub_path(self, path: str) -> ReactiveServiceBuilderContext: + self._builder.mount_sub_path(path) + return self + + def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilderContext: + self._builder.health_endpoint(endpoint) + return self + + def add_port( + self, port: int, *, public: bool = False + ) -> ReactiveServiceBuilderContext: + self._builder.add_port(port, public=public) + return self + + def add_path( + self, path: str, port: int, *, strip_path: bool | None = None + ) -> ReactiveServiceBuilderContext: + self._builder.add_path(path, port, strip_path=strip_path) + return self + + def done(self) -> ProfileBuilder: + name, config = self._builder.build() + self._parent._services[name] = config + return self._parent + + +class ManagedServiceBuilderContext: + def __init__(self, parent: ProfileBuilder, name: str, provider: str, plan: str): + self._parent = parent + self._builder = ManagedServiceBuilder(name, provider, plan) + + def config(self, key: str, value: Any) -> ManagedServiceBuilderContext: + self._builder.config(key, value) + return self + + def configs(self, config: dict[str, Any]) -> ManagedServiceBuilderContext: + self._builder.configs(config) + return self + + def secret(self, key: str, value: str) -> ManagedServiceBuilderContext: + self._builder.secret(key, value) + return self + + def secrets(self, secrets: dict[str, str]) -> ManagedServiceBuilderContext: + self._builder.secrets(secrets) + return self + + def done(self) -> ProfileBuilder: + name, config = self._builder.build() + self._parent._services[name] = config + return self._parent diff --git a/src/codesphere/models/logs.py b/src/codesphere/models/logs.py new file mode 100644 index 0000000..99762ae --- /dev/null +++ b/src/codesphere/models/logs.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from pydantic import ConfigDict, Field + +from ..core.models import CamelModel + + +class LogStage(str, Enum): + PREPARE = "prepare" + TEST = "test" + RUN = "run" + + +class LogKind(str, Enum): + INFO = "I" + ERROR = "E" + + +class LogEntry(CamelModel): + model_config = ConfigDict(extra="allow") + + timestamp: str | None = None + # Known kinds are typed; unknown values fall back to the raw string. + kind: LogKind | str | None = Field(default=None, union_mode="left_to_right") + data: str | None = None # The actual log content + + def get_text(self) -> str: + return self.data or "" + + +class LogProblem(CamelModel): + status: int + reason: str + detail: str | None = None + + +# --------------------------------------------------------------------------- +# Stream targets (shared between the async and sync clients so isinstance +# checks work regardless of which client flavor created them). +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class StageTarget: + """Logs of a pipeline stage (prepare/test/run).""" + + stage: LogStage | str + step: int = 0 + + +@dataclass(frozen=True, slots=True) +class ServerTarget: + """Run logs of a specific server in a Multi Server Deployment.""" + + step: int + server: str + + +@dataclass(frozen=True, slots=True) +class ReplicaTarget: + """Run logs of a specific replica.""" + + step: int + replica: str + + +type LogTarget = StageTarget | ServerTarget | ReplicaTarget diff --git a/src/codesphere/models/metadata.py b/src/codesphere/models/metadata.py new file mode 100644 index 0000000..eefaa3a --- /dev/null +++ b/src/codesphere/models/metadata.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import datetime + +from pydantic import Field + +from ..core.models import CamelModel + + +class Datacenter(CamelModel): + """Represents a physical data center location.""" + + id: int + name: str + city: str + country_code: str + + +class Characteristic(CamelModel): + """Defines the resource specifications for a WsPlan.""" + + id: int + cpu: float = Field(validation_alias="CPU") + gpu: int = Field(validation_alias="GPU") + ram: int = Field(validation_alias="RAM") + ssd: int = Field(validation_alias="SSD") + temp_storage: int = Field(validation_alias="TempStorage") + on_demand: bool + + +class WsPlan(CamelModel): + """ + Represents a purchasable workspace plan. + """ + + id: int + price_usd: int + title: str + deprecated: bool + characteristics: Characteristic + max_replicas: int + + +class Image(CamelModel): + """Represents a runnable workspace base image.""" + + id: str + name: str + supported_until: datetime.datetime diff --git a/src/codesphere/models/team.py b/src/codesphere/models/team.py new file mode 100644 index 0000000..580902f --- /dev/null +++ b/src/codesphere/models/team.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from ..core.models import CamelModel + + +class TeamCreate(CamelModel): + name: str + dc: int + + +class TeamBase(CamelModel): + id: int + name: str + description: str | None = None + avatar_id: str | None = None + avatar_url: str | None = None + is_first: bool + default_data_center_id: int + role: int | None = None diff --git a/src/codesphere/models/usage.py b/src/codesphere/models/usage.py new file mode 100644 index 0000000..299bcae --- /dev/null +++ b/src/codesphere/models/usage.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from ..core.models import CamelModel + + +class ServiceAction(str, Enum): + START = "start" + STOP = "stop" + + +class LandscapeServiceSummary(CamelModel): + resource_id: str + resource_name: str + usage_seconds: float + plan_name: str + always_on: bool + replicas: int + type: str + + +class LandscapeServiceEvent(CamelModel): + id: int + initiator_id: str + initiator_email: str + resource_id: str + date: datetime + action: ServiceAction + always_on: bool + replicas: int + service_name: str diff --git a/src/codesphere/models/workspace.py b/src/codesphere/models/workspace.py new file mode 100644 index 0000000..9b4c498 --- /dev/null +++ b/src/codesphere/models/workspace.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from ..core.models import CamelModel +from .env_vars import EnvVar + + +class WorkspaceCreate(CamelModel): + team_id: int + name: str + plan_id: int + base_image: str | None = None + is_private_repo: bool = True + replicas: int = 1 + git_url: str | None = None + initial_branch: str | None = None + clone_depth: int | None = None + source_workspace_id: int | None = None + welcome_message: str | None = None + vpn_config: str | None = None + restricted: bool | None = None + env: list[EnvVar] | None = None + + +class WorkspaceBase(CamelModel): + id: int + team_id: int + name: str + plan_id: int + is_private_repo: bool + replicas: int + base_image: str | None = None + data_center_id: int + user_id: int + git_url: str | None = None + initial_branch: str | None = None + source_workspace_id: int | None = None + welcome_message: str | None = None + vpn_config: str | None = None + restricted: bool + + +class WorkspaceUpdate(CamelModel): + plan_id: int | None = None + base_image: str | None = None + name: str | None = None + replicas: int | None = None + vpn_config: str | None = None + restricted: bool | None = None + + +class CommandInput(CamelModel): + command: str + env: dict[str, str] | None = None + + +class CommandOutput(CamelModel): + command: str + working_dir: str + output: str + error: str + + +class WorkspaceStatus(CamelModel): + is_running: bool diff --git a/src/codesphere/resources/__init__.py b/src/codesphere/resources/__init__.py index e69de29..6a0a57e 100644 --- a/src/codesphere/resources/__init__.py +++ b/src/codesphere/resources/__init__.py @@ -0,0 +1,3 @@ +"""Compatibility shim; implementation lives in codesphere._async.resources.""" + +from codesphere._async.resources import * diff --git a/src/codesphere/resources/flags/__init__.py b/src/codesphere/resources/flags/__init__.py new file mode 100644 index 0000000..db2ede9 --- /dev/null +++ b/src/codesphere/resources/flags/__init__.py @@ -0,0 +1,3 @@ +"""Compatibility shim; implementation lives in codesphere._async.resources.flags.""" + +from codesphere._async.resources.flags import * diff --git a/src/codesphere/resources/flags/resources.py b/src/codesphere/resources/flags/resources.py new file mode 100644 index 0000000..ce6a451 --- /dev/null +++ b/src/codesphere/resources/flags/resources.py @@ -0,0 +1,3 @@ +"""Compatibility shim; implementation lives in codesphere._async.resources.flags.resources.""" + +from codesphere._async.resources.flags.resources import * diff --git a/src/codesphere/resources/flags/schemas.py b/src/codesphere/resources/flags/schemas.py new file mode 100644 index 0000000..457254b --- /dev/null +++ b/src/codesphere/resources/flags/schemas.py @@ -0,0 +1,3 @@ +"""Compatibility shim; implementation lives in codesphere._async.resources.flags.schemas.""" + +from codesphere._async.resources.flags.schemas import * diff --git a/src/codesphere/resources/metadata/__init__.py b/src/codesphere/resources/metadata/__init__.py index 178d70d..66f8ec1 100644 --- a/src/codesphere/resources/metadata/__init__.py +++ b/src/codesphere/resources/metadata/__init__.py @@ -1,6 +1,3 @@ -"""Metadata Resource & Models""" +"""Compatibility shim; implementation lives in codesphere._async.resources.metadata.""" -from .resources import MetadataResource -from .schemas import Characteristic, Datacenter, Image, WsPlan - -__all__ = ["Characteristic", "Datacenter", "Image", "MetadataResource", "WsPlan"] +from codesphere._async.resources.metadata import * diff --git a/src/codesphere/resources/metadata/operations.py b/src/codesphere/resources/metadata/operations.py index cd1e414..793f4e1 100644 --- a/src/codesphere/resources/metadata/operations.py +++ b/src/codesphere/resources/metadata/operations.py @@ -1,21 +1,3 @@ -from ...core.base import ResourceList -from ...core.operations import APIOperation -from .schemas import Datacenter, Image, WsPlan +"""Compatibility shim; implementation lives in codesphere._async.resources.metadata.operations.""" -_LIST_DC_OP = APIOperation( - method="GET", - endpoint_template="/metadata/datacenters", - response_model=ResourceList[Datacenter], -) - -_LIST_PLANS_OP = APIOperation( - method="GET", - endpoint_template="/metadata/workspace-plans", - response_model=ResourceList[WsPlan], -) - -_LIST_IMAGES_OP = APIOperation( - method="GET", - endpoint_template="/metadata/workspace-base-images", - response_model=ResourceList[Image], -) +from codesphere._async.resources.metadata.operations import * diff --git a/src/codesphere/resources/metadata/resources.py b/src/codesphere/resources/metadata/resources.py index ee4969d..cfb11b4 100644 --- a/src/codesphere/resources/metadata/resources.py +++ b/src/codesphere/resources/metadata/resources.py @@ -1,17 +1,3 @@ -from ...core import ResourceBase -from .operations import _LIST_DC_OP, _LIST_IMAGES_OP, _LIST_PLANS_OP -from .schemas import Datacenter, Image, WsPlan +"""Compatibility shim; implementation lives in codesphere._async.resources.metadata.resources.""" - -class MetadataResource(ResourceBase): - async def list_datacenters(self) -> list[Datacenter]: - result = await self._execute(_LIST_DC_OP) - return result.root - - async def list_plans(self) -> list[WsPlan]: - result = await self._execute(_LIST_PLANS_OP) - return result.root - - async def list_images(self) -> list[Image]: - result = await self._execute(_LIST_IMAGES_OP) - return result.root +from codesphere._async.resources.metadata.resources import * diff --git a/src/codesphere/resources/metadata/schemas.py b/src/codesphere/resources/metadata/schemas.py index 4450792..f8db277 100644 --- a/src/codesphere/resources/metadata/schemas.py +++ b/src/codesphere/resources/metadata/schemas.py @@ -1,49 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.metadata.schemas.""" -import datetime - -from pydantic import Field - -from ...core.base import CamelModel - - -class Datacenter(CamelModel): - """Represents a physical data center location.""" - - id: int - name: str - city: str - country_code: str - - -class Characteristic(CamelModel): - """Defines the resource specifications for a WsPlan.""" - - id: int - cpu: float = Field(validation_alias="CPU") - gpu: int = Field(validation_alias="GPU") - ram: int = Field(validation_alias="RAM") - ssd: int = Field(validation_alias="SSD") - temp_storage: int = Field(validation_alias="TempStorage") - on_demand: bool - - -class WsPlan(CamelModel): - """ - Represents a purchasable workspace plan. - """ - - id: int - price_usd: int - title: str - deprecated: bool - characteristics: Characteristic - max_replicas: int - - -class Image(CamelModel): - """Represents a runnable workspace base image.""" - - id: str - name: str - supported_until: datetime.datetime +from codesphere._async.resources.metadata.schemas import * diff --git a/src/codesphere/resources/team/__init__.py b/src/codesphere/resources/team/__init__.py index 5734d43..09c115f 100644 --- a/src/codesphere/resources/team/__init__.py +++ b/src/codesphere/resources/team/__init__.py @@ -1,39 +1,3 @@ -from .domain import ( - CustomDomainConfig, - Domain, - DomainBase, - DomainRouting, - DomainVerificationStatus, - TeamDomainManager, -) -from .resources import TeamsResource -from .schemas import Team, TeamBase, TeamCreate -from .usage import ( - LandscapeServiceEvent, - LandscapeServiceSummary, - PaginatedResponse, - ServiceAction, - TeamUsageManager, - UsageEventsResponse, - UsageSummaryResponse, -) +"""Compatibility shim; implementation lives in codesphere._async.resources.team.""" -__all__ = [ - "CustomDomainConfig", - "Domain", - "DomainBase", - "DomainRouting", - "DomainVerificationStatus", - "LandscapeServiceEvent", - "LandscapeServiceSummary", - "PaginatedResponse", - "ServiceAction", - "Team", - "TeamBase", - "TeamCreate", - "TeamDomainManager", - "TeamUsageManager", - "TeamsResource", - "UsageEventsResponse", - "UsageSummaryResponse", -] +from codesphere._async.resources.team import * diff --git a/src/codesphere/resources/team/domain/__init__.py b/src/codesphere/resources/team/domain/__init__.py index fb53171..5fe6ff9 100644 --- a/src/codesphere/resources/team/domain/__init__.py +++ b/src/codesphere/resources/team/domain/__init__.py @@ -1,19 +1,3 @@ -from .resources import TeamDomainManager -from .schemas import ( - CustomDomainConfig, - Domain, - DomainBase, - DomainRouting, - DomainVerificationStatus, - RoutingMap, -) +"""Compatibility shim; implementation lives in codesphere._async.resources.team.domain.""" -__all__ = [ - "CustomDomainConfig", - "Domain", - "DomainBase", - "DomainRouting", - "DomainVerificationStatus", - "RoutingMap", - "TeamDomainManager", -] +from codesphere._async.resources.team.domain import * diff --git a/src/codesphere/resources/team/domain/operations.py b/src/codesphere/resources/team/domain/operations.py index 659e262..2f61dd1 100644 --- a/src/codesphere/resources/team/domain/operations.py +++ b/src/codesphere/resources/team/domain/operations.py @@ -1,48 +1,3 @@ -from types import NoneType +"""Compatibility shim; implementation lives in codesphere._async.resources.team.domain.operations.""" -from ....core.base import ResourceList -from ....core.operations import APIOperation -from .schemas import CustomDomainConfig, Domain, DomainVerificationStatus - -_LIST_OP = APIOperation( - method="GET", - endpoint_template="/domains/team/{team_id}", - response_model=ResourceList[Domain], -) - -_GET_OP = APIOperation( - method="GET", - endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=Domain, -) - -_CREATE_OP = APIOperation( - method="POST", - endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=Domain, -) - -_UPDATE_OP = APIOperation( - method="PATCH", - endpoint_template="/domains/team/{team_id}/domain/{name}", - input_model=CustomDomainConfig, - response_model=Domain, -) - -_UPDATE_WS_OP = APIOperation( - method="PUT", - endpoint_template="/domains/team/{team_id}/domain/{name}/workspace-connections", - response_model=Domain, -) - -_VERIFY_OP = APIOperation( - method="POST", - endpoint_template="/domains/team/{team_id}/domain/{name}/verify", - response_model=DomainVerificationStatus, -) - -_DELETE_OP = APIOperation( - method="DELETE", - endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=NoneType, -) +from codesphere._async.resources.team.domain.operations import * diff --git a/src/codesphere/resources/team/domain/resources.py b/src/codesphere/resources/team/domain/resources.py index 0edb762..b2d2638 100644 --- a/src/codesphere/resources/team/domain/resources.py +++ b/src/codesphere/resources/team/domain/resources.py @@ -1,30 +1,3 @@ -from ....core.base import TeamScopedResource -from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP -from .schemas import CustomDomainConfig, Domain, DomainRouting, RoutingMap +"""Compatibility shim; implementation lives in codesphere._async.resources.team.domain.resources.""" - -class TeamDomainManager(TeamScopedResource): - async def list(self) -> list[Domain]: - result = await self._execute(_LIST_OP, team_id=self.team_id) - return result.root - - async def get(self, name: str) -> Domain: - return await self._execute(_GET_OP, team_id=self.team_id, name=name) - - async def create(self, name: str) -> Domain: - return await self._execute(_CREATE_OP, team_id=self.team_id, name=name) - - async def update(self, name: str, config: CustomDomainConfig) -> Domain: - return await self._execute( - _UPDATE_OP, team_id=self.team_id, name=name, data=config - ) - - async def update_workspace_connections( - self, name: str, connections: DomainRouting | RoutingMap - ) -> Domain: - payload = ( - connections.root if isinstance(connections, DomainRouting) else connections - ) - return await self._execute( - _UPDATE_WS_OP, team_id=self.team_id, name=name, data=payload - ) +from codesphere._async.resources.team.domain.resources import * diff --git a/src/codesphere/resources/team/domain/schemas.py b/src/codesphere/resources/team/domain/schemas.py index 4d3a54d..1cd4461 100644 --- a/src/codesphere/resources/team/domain/schemas.py +++ b/src/codesphere/resources/team/domain/schemas.py @@ -1,88 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.team.domain.schemas.""" -from pydantic import Field, RootModel - -from ....core.base import BoundModel, CamelModel -from ....utils import update_model_fields - -type RoutingMap = dict[str, list[int]] - - -class CertificateRequestStatus(CamelModel): - issued: bool - reason: str | None = None - - -class DNSEntries(CamelModel): - a: str - cname: str - txt: str - - -class DomainVerificationStatus(CamelModel): - verified: bool - reason: str | None = None - - -class CustomDomainConfig(CamelModel): - restricted: bool | None = None - max_body_size_mb: int | None = None - max_connection_timeout_s: int | None = None - use_regex: bool | None = None - - -class DomainRouting(RootModel): - root: RoutingMap = Field(default_factory=dict) - - def add(self, path: str, workspace_ids: list[int]) -> DomainRouting: - self.root[path] = workspace_ids - return self - - -class DomainBase(CamelModel): - name: str - team_id: int - data_center_id: int - workspaces: RoutingMap - certificate_request_status: CertificateRequestStatus - dns_entries: DNSEntries - domain_verification_status: DomainVerificationStatus - custom_config_revision: int | None = None - custom_config: CustomDomainConfig | None = None - - -class Domain(DomainBase, BoundModel): - async def update(self, data: CustomDomainConfig) -> Domain: - from .operations import _UPDATE_OP - - response = await self._execute( - _UPDATE_OP, team_id=self.team_id, name=self.name, data=data - ) - update_model_fields(target=self, source=response) - return response - - async def update_workspace_connections( - self, connections: DomainRouting | RoutingMap - ) -> Domain: - from .operations import _UPDATE_WS_OP - - payload = ( - connections.root if isinstance(connections, DomainRouting) else connections - ) - response = await self._execute( - _UPDATE_WS_OP, team_id=self.team_id, name=self.name, data=payload - ) - update_model_fields(target=self, source=response) - return response - - async def verify_status(self) -> DomainVerificationStatus: - from .operations import _VERIFY_OP - - response = await self._execute(_VERIFY_OP, team_id=self.team_id, name=self.name) - update_model_fields(target=self.domain_verification_status, source=response) - return response - - async def delete(self) -> None: - from .operations import _DELETE_OP - - await self._execute(_DELETE_OP, team_id=self.team_id, name=self.name) +from codesphere._async.resources.team.domain.schemas import * diff --git a/src/codesphere/resources/team/operations.py b/src/codesphere/resources/team/operations.py index 489da74..29d6fc1 100644 --- a/src/codesphere/resources/team/operations.py +++ b/src/codesphere/resources/team/operations.py @@ -1,30 +1,3 @@ -from types import NoneType +"""Compatibility shim; implementation lives in codesphere._async.resources.team.operations.""" -from ...core.base import ResourceList -from ...core.operations import APIOperation -from .schemas import Team, TeamCreate - -_LIST_TEAMS_OP = APIOperation( - method="GET", - endpoint_template="/teams", - response_model=ResourceList[Team], -) - -_GET_TEAM_OP = APIOperation( - method="GET", - endpoint_template="/teams/{team_id}", - response_model=Team, -) - -_CREATE_TEAM_OP = APIOperation( - method="POST", - endpoint_template="/teams", - input_model=TeamCreate, - response_model=Team, -) - -_DELETE_TEAM_OP = APIOperation( - method="DELETE", - endpoint_template="/teams/{team_id}", - response_model=NoneType, -) +from codesphere._async.resources.team.operations import * diff --git a/src/codesphere/resources/team/resources.py b/src/codesphere/resources/team/resources.py index ef69221..b8df253 100644 --- a/src/codesphere/resources/team/resources.py +++ b/src/codesphere/resources/team/resources.py @@ -1,19 +1,3 @@ -from ...core import ResourceBase -from .operations import ( - _CREATE_TEAM_OP, - _GET_TEAM_OP, - _LIST_TEAMS_OP, -) -from .schemas import Team, TeamCreate +"""Compatibility shim; implementation lives in codesphere._async.resources.team.resources.""" - -class TeamsResource(ResourceBase): - async def list(self) -> list[Team]: - result = await self._execute(_LIST_TEAMS_OP) - return result.root - - async def get(self, team_id: int) -> Team: - return await self._execute(_GET_TEAM_OP, team_id=team_id) - - async def create(self, payload: TeamCreate) -> Team: - return await self._execute(_CREATE_TEAM_OP, data=payload) +from codesphere._async.resources.team.resources import * diff --git a/src/codesphere/resources/team/schemas.py b/src/codesphere/resources/team/schemas.py index 086400b..4270f72 100644 --- a/src/codesphere/resources/team/schemas.py +++ b/src/codesphere/resources/team/schemas.py @@ -1,38 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.team.schemas.""" -from functools import cached_property - -from ...core.base import BoundModel, CamelModel -from .domain.resources import TeamDomainManager -from .usage.resources import TeamUsageManager - - -class TeamCreate(CamelModel): - name: str - dc: int - - -class TeamBase(CamelModel): - id: int - name: str - description: str | None = None - avatar_id: str | None = None - avatar_url: str | None = None - is_first: bool - default_data_center_id: int - role: int | None = None - - -class Team(TeamBase, BoundModel): - async def delete(self) -> None: - from .operations import _DELETE_TEAM_OP - - await self._execute(_DELETE_TEAM_OP, team_id=self.id) - - @cached_property - def domains(self) -> TeamDomainManager: - return TeamDomainManager(self._client(), team_id=self.id) - - @cached_property - def usage(self) -> TeamUsageManager: - return TeamUsageManager(self._client(), team_id=self.id) +from codesphere._async.resources.team.schemas import * diff --git a/src/codesphere/resources/team/usage/__init__.py b/src/codesphere/resources/team/usage/__init__.py index d53a802..931e207 100644 --- a/src/codesphere/resources/team/usage/__init__.py +++ b/src/codesphere/resources/team/usage/__init__.py @@ -1,21 +1,3 @@ -"""Team usage history resources.""" +"""Compatibility shim; implementation lives in codesphere._async.resources.team.usage.""" -from .resources import TeamUsageManager -from .schemas import ( - LandscapeServiceEvent, - LandscapeServiceSummary, - PaginatedResponse, - ServiceAction, - UsageEventsResponse, - UsageSummaryResponse, -) - -__all__ = [ - "LandscapeServiceEvent", - "LandscapeServiceSummary", - "PaginatedResponse", - "ServiceAction", - "TeamUsageManager", - "UsageEventsResponse", - "UsageSummaryResponse", -] +from codesphere._async.resources.team.usage import * diff --git a/src/codesphere/resources/team/usage/operations.py b/src/codesphere/resources/team/usage/operations.py index 04a4d78..beeb901 100644 --- a/src/codesphere/resources/team/usage/operations.py +++ b/src/codesphere/resources/team/usage/operations.py @@ -1,14 +1,3 @@ -from ....core.operations import APIOperation -from .schemas import UsageEventsResponse, UsageSummaryResponse +"""Compatibility shim; implementation lives in codesphere._async.resources.team.usage.operations.""" -_GET_LANDSCAPE_SUMMARY_OP = APIOperation( - method="GET", - endpoint_template="/usage/teams/{team_id}/resources/landscape-service/summary", - response_model=UsageSummaryResponse, -) - -_GET_LANDSCAPE_EVENTS_OP = APIOperation( - method="GET", - endpoint_template="/usage/teams/{team_id}/resources/landscape-service/{resource_id}/events", - response_model=UsageEventsResponse, -) +from codesphere._async.resources.team.usage.operations import * diff --git a/src/codesphere/resources/team/usage/resources.py b/src/codesphere/resources/team/usage/resources.py index c50d386..627085f 100644 --- a/src/codesphere/resources/team/usage/resources.py +++ b/src/codesphere/resources/team/usage/resources.py @@ -1,165 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.team.usage.resources.""" -from collections.abc import AsyncIterator -from datetime import datetime -from functools import partial - -from ....core.base import TeamScopedResource -from .operations import _GET_LANDSCAPE_EVENTS_OP, _GET_LANDSCAPE_SUMMARY_OP -from .schemas import ( - LandscapeServiceEvent, - LandscapeServiceSummary, - UsageEventsResponse, - UsageSummaryResponse, -) - - -def _clamp_page_size(value: int) -> int: - """The usage API accepts page sizes between 1 and 100.""" - return min(max(1, value), 100) - - -class TeamUsageManager(TeamScopedResource): - """Manager for team usage history operations. - - Provides access to landscape service usage summaries and events with - support for pagination and automatic iteration through all results. - - Example: - ```python - summary = await team.usage.get_landscape_summary( - begin_date=datetime(2024, 1, 1), - end_date=datetime(2024, 1, 31), - limit=50 - ) - print(f"Total items: {summary.total_items}") - print(f"Page {summary.current_page} of {summary.total_pages}") - - for item in summary.items: - print(f"{item.resource_name}: {item.usage_seconds}s") - - await summary.refresh() - - async for item in team.usage.iter_all_landscape_summary( - begin_date=datetime(2024, 1, 1), - end_date=datetime(2024, 1, 31) - ): - print(f"{item.resource_name}: {item.usage_seconds}s") - ``` - """ - - async def get_landscape_summary( - self, - begin_date: datetime | str, - end_date: datetime | str, - limit: int = 25, - offset: int = 0, - ) -> UsageSummaryResponse: - params = { - "beginDate": begin_date.isoformat() - if isinstance(begin_date, datetime) - else begin_date, - "endDate": end_date.isoformat() - if isinstance(end_date, datetime) - else end_date, - "limit": _clamp_page_size(limit), - "offset": max(0, offset), - } - result = await self._execute( - _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id, params=params - ) - - result._refresh_op = partial( - self._execute, _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id - ) - result._team_id = self.team_id - - return result - - async def iter_all_landscape_summary( - self, - begin_date: datetime | str, - end_date: datetime | str, - page_size: int = 100, - ) -> AsyncIterator[LandscapeServiceSummary]: - offset = 0 - page_size = _clamp_page_size(page_size) - - while True: - response = await self.get_landscape_summary( - begin_date=begin_date, - end_date=end_date, - limit=page_size, - offset=offset, - ) - - for item in response.items: - yield item - - if not response.has_next_page: - break - - offset += page_size - - async def get_landscape_events( - self, - resource_id: str, - begin_date: datetime | str, - end_date: datetime | str, - limit: int = 25, - offset: int = 0, - ) -> UsageEventsResponse: - params = { - "beginDate": begin_date.isoformat() - if isinstance(begin_date, datetime) - else begin_date, - "endDate": end_date.isoformat() - if isinstance(end_date, datetime) - else end_date, - "limit": _clamp_page_size(limit), - "offset": max(0, offset), - } - result = await self._execute( - _GET_LANDSCAPE_EVENTS_OP, - team_id=self.team_id, - resource_id=resource_id, - params=params, - ) - - result._refresh_op = partial( - self._execute, - _GET_LANDSCAPE_EVENTS_OP, - team_id=self.team_id, - resource_id=resource_id, - ) - result._team_id = self.team_id - result._resource_id = resource_id - - return result - - async def iter_all_landscape_events( - self, - resource_id: str, - begin_date: datetime | str, - end_date: datetime | str, - page_size: int = 100, - ) -> AsyncIterator[LandscapeServiceEvent]: - offset = 0 - page_size = _clamp_page_size(page_size) - - while True: - response = await self.get_landscape_events( - resource_id=resource_id, - begin_date=begin_date, - end_date=end_date, - limit=page_size, - offset=offset, - ) - - for item in response.items: - yield item - - if not response.has_next_page: - break - - offset += page_size +from codesphere._async.resources.team.usage.resources import * diff --git a/src/codesphere/resources/team/usage/schemas.py b/src/codesphere/resources/team/usage/schemas.py index 1167573..ef580b6 100644 --- a/src/codesphere/resources/team/usage/schemas.py +++ b/src/codesphere/resources/team/usage/schemas.py @@ -1,106 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.team.usage.schemas.""" -from collections.abc import Awaitable, Callable -from datetime import datetime -from enum import Enum -from typing import Any, Generic, Self, TypeVar - -from pydantic import Field - -from ....core.base import CamelModel - - -class ServiceAction(str, Enum): - START = "start" - STOP = "stop" - - -class LandscapeServiceSummary(CamelModel): - resource_id: str - resource_name: str - usage_seconds: float - plan_name: str - always_on: bool - replicas: int - type: str - - -class LandscapeServiceEvent(CamelModel): - id: int - initiator_id: str - initiator_email: str - resource_id: str - date: datetime - action: ServiceAction - always_on: bool - replicas: int - service_name: str - - -ItemT = TypeVar("ItemT") - - -class PaginatedResponse(CamelModel, Generic[ItemT]): - total_items: int - limit: int = 25 - offset: int = 0 - begin_date: datetime - end_date: datetime - - _refresh_op: Callable[..., Awaitable[Any]] | None = None - - @property - def has_next_page(self) -> bool: - return (self.offset + self.limit) < self.total_items - - @property - def has_prev_page(self) -> bool: - return self.offset > 0 - - @property - def current_page(self) -> int: - return (self.offset // self.limit) + 1 - - @property - def total_pages(self) -> int: - if self.total_items == 0: - return 1 - return (self.total_items + self.limit - 1) // self.limit - - async def refresh(self) -> Self: - """Re-fetch this page with the same query parameters, in place.""" - if self._refresh_op is None: - raise RuntimeError( - "Refresh operation not available. Use manager methods instead." - ) - result = await self._refresh_op( - params={ - "beginDate": self.begin_date.isoformat(), - "endDate": self.end_date.isoformat(), - "limit": self.limit, - "offset": self.offset, - } - ) - for field_name in result.model_fields_set: - setattr(self, field_name, getattr(result, field_name)) - return self - - -class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): - summary: list[LandscapeServiceSummary] = Field(default_factory=list) - _team_id: int | None = None - - @property - def items(self) -> list[LandscapeServiceSummary]: - return self.summary - - -class UsageEventsResponse(PaginatedResponse[LandscapeServiceEvent]): - events: list[LandscapeServiceEvent] = Field(default_factory=list) - - _team_id: int | None = None - _resource_id: str | None = None - - @property - def items(self) -> list[LandscapeServiceEvent]: - return self.events +from codesphere._async.resources.team.usage.schemas import * diff --git a/src/codesphere/resources/workspace/__init__.py b/src/codesphere/resources/workspace/__init__.py index f8bebba..b348f6f 100644 --- a/src/codesphere/resources/workspace/__init__.py +++ b/src/codesphere/resources/workspace/__init__.py @@ -1,28 +1,3 @@ -from .git import GitHead, WorkspaceGitManager -from .logs import LogEntry, LogProblem, LogStage, LogStream, WorkspaceLogManager -from .resources import WorkspacesResource -from .schemas import ( - CommandInput, - CommandOutput, - Workspace, - WorkspaceCreate, - WorkspaceStatus, - WorkspaceUpdate, -) +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.""" -__all__ = [ - "CommandInput", - "CommandOutput", - "GitHead", - "LogEntry", - "LogProblem", - "LogStage", - "LogStream", - "Workspace", - "WorkspaceCreate", - "WorkspaceGitManager", - "WorkspaceLogManager", - "WorkspaceStatus", - "WorkspaceUpdate", - "WorkspacesResource", -] +from codesphere._async.resources.workspace import * diff --git a/src/codesphere/resources/workspace/envVars.py b/src/codesphere/resources/workspace/envVars.py deleted file mode 100644 index 63eef18..0000000 --- a/src/codesphere/resources/workspace/envVars.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deprecated alias for :mod:`codesphere.resources.workspace.env_vars`.""" - -import warnings - -from .env_vars import EnvVar, WorkspaceEnvVarManager - -warnings.warn( - "'codesphere.resources.workspace.envVars' is deprecated; " - "import from 'codesphere.resources.workspace.env_vars' instead.", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/resources/workspace/env_vars/__init__.py b/src/codesphere/resources/workspace/env_vars/__init__.py index 8e2327d..35cc9fb 100644 --- a/src/codesphere/resources/workspace/env_vars/__init__.py +++ b/src/codesphere/resources/workspace/env_vars/__init__.py @@ -1,4 +1,3 @@ -from .resources import WorkspaceEnvVarManager -from .schemas import EnvVar +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.env_vars.""" -__all__ = ["EnvVar", "WorkspaceEnvVarManager"] +from codesphere._async.resources.workspace.env_vars import * diff --git a/src/codesphere/resources/workspace/env_vars/operations.py b/src/codesphere/resources/workspace/env_vars/operations.py index b4063e6..c202846 100644 --- a/src/codesphere/resources/workspace/env_vars/operations.py +++ b/src/codesphere/resources/workspace/env_vars/operations.py @@ -1,23 +1,3 @@ -from types import NoneType +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.env_vars.operations.""" -from ....core.base import ResourceList -from ....core.operations import APIOperation -from .schemas import EnvVar - -_GET_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{id}/env-vars", - response_model=ResourceList[EnvVar], -) - -_BULK_SET_OP = APIOperation( - method="PUT", - endpoint_template="/workspaces/{id}/env-vars", - response_model=NoneType, -) - -_BULK_DELETE_OP = APIOperation( - method="DELETE", - endpoint_template="/workspaces/{id}/env-vars", - response_model=NoneType, -) +from codesphere._async.resources.workspace.env_vars.operations import * diff --git a/src/codesphere/resources/workspace/env_vars/resources.py b/src/codesphere/resources/workspace/env_vars/resources.py index 56dd12a..23be80b 100644 --- a/src/codesphere/resources/workspace/env_vars/resources.py +++ b/src/codesphere/resources/workspace/env_vars/resources.py @@ -1,39 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.env_vars.resources.""" -import logging -from collections.abc import Sequence - -from ....core.base import ResourceList, WorkspaceScopedResource -from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP -from .schemas import EnvVar - -log = logging.getLogger(__name__) - - -class WorkspaceEnvVarManager(WorkspaceScopedResource): - async def get(self) -> ResourceList[EnvVar]: - return await self._execute(_GET_OP, id=self.workspace_id) - - async def set( - self, env_vars: ResourceList[EnvVar] | Sequence[EnvVar | dict[str, str]] - ) -> None: - payload = ResourceList[EnvVar].model_validate(env_vars) - await self._execute(_BULK_SET_OP, id=self.workspace_id, data=payload) - - async def delete( - self, items: Sequence[str | EnvVar | dict[str, str]] | ResourceList[EnvVar] - ) -> None: - if not items: - return - payload: list[str] = [] - - for item in items: - if isinstance(item, str): - payload.append(item) - elif isinstance(item, EnvVar): - payload.append(item.name) - elif isinstance(item, dict) and "name" in item: - payload.append(item["name"]) - - if payload: - await self._execute(_BULK_DELETE_OP, id=self.workspace_id, data=payload) +from codesphere._async.resources.workspace.env_vars.resources import * diff --git a/src/codesphere/resources/workspace/env_vars/schemas.py b/src/codesphere/resources/workspace/env_vars/schemas.py index 89fd1c7..0777f56 100644 --- a/src/codesphere/resources/workspace/env_vars/schemas.py +++ b/src/codesphere/resources/workspace/env_vars/schemas.py @@ -1,8 +1,3 @@ -from ....core.base import CamelModel +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.env_vars.schemas.""" - -class EnvVar(CamelModel): - """Environment variable model.""" - - name: str - value: str +from codesphere._async.resources.workspace.env_vars.schemas import * diff --git a/src/codesphere/resources/workspace/git/__init__.py b/src/codesphere/resources/workspace/git/__init__.py index e91a0c2..f707f57 100644 --- a/src/codesphere/resources/workspace/git/__init__.py +++ b/src/codesphere/resources/workspace/git/__init__.py @@ -1,4 +1,3 @@ -from .resources import WorkspaceGitManager -from .schemas import GitHead +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.git.""" -__all__ = ["GitHead", "WorkspaceGitManager"] +from codesphere._async.resources.workspace.git import * diff --git a/src/codesphere/resources/workspace/git/operations.py b/src/codesphere/resources/workspace/git/operations.py index 31896c9..8d8f375 100644 --- a/src/codesphere/resources/workspace/git/operations.py +++ b/src/codesphere/resources/workspace/git/operations.py @@ -1,30 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.git.operations.""" -from types import NoneType - -from ....core.operations import APIOperation -from .schemas import GitHead - -_GET_HEAD_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{id}/git/head", - response_model=GitHead, -) - -_PULL_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/git/pull", - response_model=NoneType, -) - -_PULL_WITH_REMOTE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/git/pull/{remote}", - response_model=NoneType, -) - -_PULL_WITH_REMOTE_AND_BRANCH_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/git/pull/{remote}/{branch}", - response_model=NoneType, -) +from codesphere._async.resources.workspace.git.operations import * diff --git a/src/codesphere/resources/workspace/git/resources.py b/src/codesphere/resources/workspace/git/resources.py index d6da64c..4d6cc32 100644 --- a/src/codesphere/resources/workspace/git/resources.py +++ b/src/codesphere/resources/workspace/git/resources.py @@ -1,40 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.git.resources.""" -import logging - -from ....core.base import WorkspaceScopedResource -from .operations import ( - _GET_HEAD_OP, - _PULL_OP, - _PULL_WITH_REMOTE_AND_BRANCH_OP, - _PULL_WITH_REMOTE_OP, -) -from .schemas import GitHead - -log = logging.getLogger(__name__) - - -class WorkspaceGitManager(WorkspaceScopedResource): - """Manager for git operations on a workspace.""" - - async def get_head(self) -> GitHead: - return await self._execute(_GET_HEAD_OP, id=self.workspace_id) - - async def pull( - self, - remote: str | None = None, - branch: str | None = None, - ) -> None: - if remote is not None and branch is not None: - await self._execute( - _PULL_WITH_REMOTE_AND_BRANCH_OP, - id=self.workspace_id, - remote=remote, - branch=branch, - ) - elif remote is not None: - await self._execute( - _PULL_WITH_REMOTE_OP, id=self.workspace_id, remote=remote - ) - else: - await self._execute(_PULL_OP, id=self.workspace_id) +from codesphere._async.resources.workspace.git.resources import * diff --git a/src/codesphere/resources/workspace/git/schemas.py b/src/codesphere/resources/workspace/git/schemas.py index adda47d..095b1e7 100644 --- a/src/codesphere/resources/workspace/git/schemas.py +++ b/src/codesphere/resources/workspace/git/schemas.py @@ -1,7 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.git.schemas.""" -from ....core.base import CamelModel - - -class GitHead(CamelModel): - head: str +from codesphere._async.resources.workspace.git.schemas import * diff --git a/src/codesphere/resources/workspace/landscape/__init__.py b/src/codesphere/resources/workspace/landscape/__init__.py index cb77be2..08d7513 100644 --- a/src/codesphere/resources/workspace/landscape/__init__.py +++ b/src/codesphere/resources/workspace/landscape/__init__.py @@ -1,41 +1,3 @@ -from .resources import WorkspaceLandscapeManager -from .schemas import ( - ManagedServiceBuilder, - ManagedServiceConfig, - NetworkConfig, - PathConfig, - PipelineStage, - PipelineState, - PipelineStatus, - PipelineStatusList, - PortConfig, - Profile, - ProfileBuilder, - ProfileConfig, - ReactiveServiceBuilder, - ReactiveServiceConfig, - StageConfig, - Step, - StepStatus, -) +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.landscape.""" -__all__ = [ - "ManagedServiceBuilder", - "ManagedServiceConfig", - "NetworkConfig", - "PathConfig", - "PipelineStage", - "PipelineState", - "PipelineStatus", - "PipelineStatusList", - "PortConfig", - "Profile", - "ProfileBuilder", - "ProfileConfig", - "ReactiveServiceBuilder", - "ReactiveServiceConfig", - "StageConfig", - "Step", - "StepStatus", - "WorkspaceLandscapeManager", -] +from codesphere._async.resources.workspace.landscape import * diff --git a/src/codesphere/resources/workspace/landscape/operations.py b/src/codesphere/resources/workspace/landscape/operations.py index 4da6622..15da6f6 100644 --- a/src/codesphere/resources/workspace/landscape/operations.py +++ b/src/codesphere/resources/workspace/landscape/operations.py @@ -1,53 +1,3 @@ -from types import NoneType +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.landscape.operations.""" -from ....core.base import ResourceList -from ....core.operations import APIOperation -from .schemas import PipelineStatus - -_DEPLOY_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/landscape/deploy", - response_model=NoneType, -) - -_DEPLOY_WITH_PROFILE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/landscape/deploy/{profile}", - response_model=NoneType, -) - -_TEARDOWN_OP = APIOperation( - method="DELETE", - endpoint_template="/workspaces/{id}/landscape/teardown", - response_model=NoneType, -) - -_SCALE_OP = APIOperation( - method="PATCH", - endpoint_template="/workspaces/{id}/landscape/scale", - response_model=NoneType, -) - -_START_PIPELINE_STAGE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/pipeline/{stage}/start", - response_model=NoneType, -) - -_START_PIPELINE_STAGE_WITH_PROFILE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/pipeline/{stage}/start/{profile}", - response_model=NoneType, -) - -_STOP_PIPELINE_STAGE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/pipeline/{stage}/stop", - response_model=NoneType, -) - -_GET_PIPELINE_STATUS_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{id}/pipeline/{stage}", - response_model=ResourceList[PipelineStatus], -) +from codesphere._async.resources.workspace.landscape.operations import * diff --git a/src/codesphere/resources/workspace/landscape/resources.py b/src/codesphere/resources/workspace/landscape/resources.py index bbb9ef3..f0b9488 100644 --- a/src/codesphere/resources/workspace/landscape/resources.py +++ b/src/codesphere/resources/workspace/landscape/resources.py @@ -1,199 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.landscape.resources.""" -import asyncio -import base64 -import logging -import re -from typing import TYPE_CHECKING - -from ....core.base import ResourceList, WorkspaceScopedResource -from .operations import ( - _DEPLOY_OP, - _DEPLOY_WITH_PROFILE_OP, - _GET_PIPELINE_STATUS_OP, - _SCALE_OP, - _START_PIPELINE_STAGE_OP, - _START_PIPELINE_STAGE_WITH_PROFILE_OP, - _STOP_PIPELINE_STAGE_OP, - _TEARDOWN_OP, -) -from .schemas import ( - PipelineStage, - PipelineState, - PipelineStatusList, - Profile, - ProfileConfig, -) - -if TYPE_CHECKING: - from ..schemas import CommandOutput - -log = logging.getLogger(__name__) - -# Regex pattern to match ci..yml files -_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") -# Pattern for valid profile names -_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") - - -def _validate_profile_name(name: str) -> None: - if not _VALID_PROFILE_NAME.match(name): - raise ValueError( - f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" - ) - - -def _profile_filename(name: str) -> str: - _validate_profile_name(name) - return f"ci.{name}.yml" - - -class WorkspaceLandscapeManager(WorkspaceScopedResource): - async def _run_command(self, command: str) -> CommandOutput: - from ..operations import _EXECUTE_COMMAND_OP - from ..schemas import CommandInput - - return await self._execute( - _EXECUTE_COMMAND_OP, - id=self.workspace_id, - data=CommandInput(command=command), - ) - - async def list_profiles(self) -> ResourceList[Profile]: - result = await self._run_command("ls -1 *.yml 2>/dev/null || true") - - profiles: list[Profile] = [] - if result.output: - for line in result.output.strip().split("\n"): - if match := _PROFILE_FILE_PATTERN.match(line.strip()): - profiles.append(Profile(name=match.group(1))) - - return ResourceList[Profile](root=profiles) - - async def save_profile(self, name: str, config: ProfileConfig | str) -> None: - filename = _profile_filename(name) - - yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config - - body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" - # Transport the content base64-encoded: the base64 alphabet is inert - # inside single quotes, so arbitrary YAML cannot break out of the - # shell command (the filename is regex-validated above). - encoded = base64.b64encode(body.encode("utf-8")).decode("ascii") - await self._run_command(f"printf '%s' '{encoded}' | base64 -d > '{filename}'") - - async def get_profile(self, name: str) -> str: - result = await self._run_command(f"cat '{_profile_filename(name)}'") - return result.output - - async def delete_profile(self, name: str) -> None: - await self._run_command(f"rm -f '{_profile_filename(name)}'") - - async def deploy(self, profile: str | None = None) -> None: - if profile is not None: - _validate_profile_name(profile) - await self._execute( - _DEPLOY_WITH_PROFILE_OP, id=self.workspace_id, profile=profile - ) - else: - await self._execute(_DEPLOY_OP, id=self.workspace_id) - - async def teardown(self) -> None: - await self._execute(_TEARDOWN_OP, id=self.workspace_id) - - async def scale(self, services: dict[str, int]) -> None: - await self._execute(_SCALE_OP, id=self.workspace_id, data=services) - - async def start_stage( - self, - stage: PipelineStage | str, - profile: str | None = None, - ) -> None: - if isinstance(stage, PipelineStage): - stage = stage.value - - if profile is not None: - _validate_profile_name(profile) - await self._execute( - _START_PIPELINE_STAGE_WITH_PROFILE_OP, - id=self.workspace_id, - stage=stage, - profile=profile, - ) - else: - await self._execute( - _START_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage - ) - - async def stop_stage(self, stage: PipelineStage | str) -> None: - if isinstance(stage, PipelineStage): - stage = stage.value - - await self._execute(_STOP_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage) - - async def get_stage_status(self, stage: PipelineStage | str) -> PipelineStatusList: - if isinstance(stage, PipelineStage): - stage = stage.value - - return await self._execute( - _GET_PIPELINE_STATUS_OP, id=self.workspace_id, stage=stage - ) - - async def wait_for_stage( - self, - stage: PipelineStage | str, - *, - timeout: float = 300.0, - poll_interval: float = 5.0, - server: str | None = None, - ) -> PipelineStatusList: - if poll_interval <= 0: - raise ValueError("poll_interval must be greater than 0") - - stage_name = stage.value if isinstance(stage, PipelineStage) else stage - elapsed = 0.0 - - while elapsed < timeout: - status_list = await self.get_stage_status(stage) - - relevant_statuses = [] - for s in status_list: - if server is not None: - if s.server == server: - relevant_statuses.append(s) - else: - if s.steps or s.state != PipelineState.WAITING: - relevant_statuses.append(s) - - if not relevant_statuses: - log.debug( - "Pipeline stage '%s': no servers with steps yet, waiting...", - stage_name, - ) - await asyncio.sleep(poll_interval) - elapsed += poll_interval - continue - - all_completed = all( - s.state - in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED) - for s in relevant_statuses - ) - - if all_completed: - log.debug("Pipeline stage '%s' completed.", stage_name) - return PipelineStatusList(root=relevant_statuses) - - states = [f"{s.server}={s.state.value}" for s in relevant_statuses] - log.debug( - "Pipeline stage '%s' status: %s (elapsed: %.1fs)", - stage_name, - ", ".join(states), - elapsed, - ) - await asyncio.sleep(poll_interval) - elapsed += poll_interval - - raise TimeoutError( - f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds." - ) +from codesphere._async.resources.workspace.landscape.resources import * diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py index 2f7ebe0..44ce186 100644 --- a/src/codesphere/resources/workspace/landscape/schemas.py +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -1,472 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.landscape.schemas.""" -from enum import Enum -from typing import Any, Literal - -import yaml -from pydantic import BaseModel, Field - -from ....core.base import CamelModel, ResourceList - - -class PipelineStage(str, Enum): - PREPARE = "prepare" - TEST = "test" - RUN = "run" - - -class PipelineState(str, Enum): - WAITING = "waiting" - RUNNING = "running" - SUCCESS = "success" - FAILURE = "failure" - ABORTED = "aborted" - - -class StepStatus(CamelModel): - state: PipelineState - started_at: str | None = None - finished_at: str | None = None - - -class PipelineStatus(CamelModel): - state: PipelineState - started_at: str | None = None - finished_at: str | None = None - steps: list[StepStatus] = Field(default_factory=list) - replica: str - server: str - - -# Alias kept for backwards compatibility; ResourceList provides the same API. -PipelineStatusList = ResourceList[PipelineStatus] - - -class Profile(BaseModel): - name: str - - -class Step(CamelModel): - name: str | None = None - command: str - - -class PortConfig(CamelModel): - port: int = Field(ge=1, le=65535) - is_public: bool = False - - -class PathConfig(CamelModel): - port: int = Field(ge=1, le=65535) - path: str - strip_path: bool | None = None - - -class NetworkConfig(CamelModel): - ports: list[PortConfig] = Field(default_factory=list) - paths: list[PathConfig] = Field(default_factory=list) - - -class ReactiveServiceConfig(CamelModel): - steps: list[Step] = Field(default_factory=list) - plan: int - replicas: int = 1 - env: dict[str, str] | None = None - base_image: str | None = None - run_as_user: int | None = Field(default=None, ge=0, le=65534) - run_as_group: int | None = Field(default=None, ge=0, le=65534) - mount_sub_path: str | None = None - health_endpoint: str | None = None - network: NetworkConfig | None = None - - -class ManagedServiceConfig(CamelModel): - provider: str - plan: str - config: dict[str, Any] | None = None - secrets: dict[str, str] | None = None - - -class StageConfig(CamelModel): - steps: list[Step] = Field(default_factory=list) - - -class ProfileConfig(CamelModel): - schema_version: Literal["v0.2"] = Field(default="v0.2", alias="schemaVersion") - prepare: StageConfig = Field(default_factory=StageConfig) - test: StageConfig = Field(default_factory=StageConfig) - run: dict[str, ReactiveServiceConfig | ManagedServiceConfig] = Field( - default_factory=dict - ) - - def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = True) -> str: - data = self.model_dump( - by_alias=by_alias, exclude_none=exclude_none, mode="json" - ) - return yaml.safe_dump( - data, default_flow_style=False, allow_unicode=True, sort_keys=False - ) - - -class StepBuilder: - def __init__(self, command: str, name: str | None = None): - self._command = command - self._name = name - - def build(self) -> Step: - return Step(command=self._command, name=self._name) - - -class PortBuilder: - def __init__(self, port: int): - self._port = port - self._is_public = False - - def public(self, is_public: bool = True) -> PortBuilder: - self._is_public = is_public - return self - - def build(self) -> PortConfig: - return PortConfig(port=self._port, is_public=self._is_public) - - -class PathBuilder: - def __init__(self, path: str, port: int): - self._path = path - self._port = port - self._strip_path: bool | None = None - - def strip_path(self, strip: bool = True) -> PathBuilder: - self._strip_path = strip - return self - - def build(self) -> PathConfig: - return PathConfig(port=self._port, path=self._path, strip_path=self._strip_path) - - -class ReactiveServiceBuilder: - def __init__(self, name: str): - self._name = name - self._steps: list[Step] = [] - self._env: dict[str, str] = {} - self._plan: int | None = None - self._replicas: int = 1 - self._base_image: str | None = None - self._run_as_user: int | None = None - self._run_as_group: int | None = None - self._mount_sub_path: str | None = None - self._health_endpoint: str | None = None - self._ports: list[PortConfig] = [] - self._paths: list[PathConfig] = [] - - @property - def name(self) -> str: - return self._name - - def add_step(self, command: str, name: str | None = None) -> ReactiveServiceBuilder: - self._steps.append(Step(command=command, name=name)) - return self - - def env(self, key: str, value: str) -> ReactiveServiceBuilder: - self._env[key] = value - return self - - def envs(self, env_vars: dict[str, str]) -> ReactiveServiceBuilder: - self._env.update(env_vars) - return self - - def plan(self, plan_id: int) -> ReactiveServiceBuilder: - self._plan = plan_id - return self - - def replicas(self, count: int) -> ReactiveServiceBuilder: - self._replicas = max(1, count) - return self - - def base_image(self, image: str) -> ReactiveServiceBuilder: - self._base_image = image - return self - - def run_as( - self, user: int | None = None, group: int | None = None - ) -> ReactiveServiceBuilder: - self._run_as_user = user - self._run_as_group = group - return self - - def mount_sub_path(self, path: str) -> ReactiveServiceBuilder: - self._mount_sub_path = path - return self - - def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilder: - self._health_endpoint = endpoint - return self - - def add_port(self, port: int, *, public: bool = False) -> ReactiveServiceBuilder: - self._ports.append(PortConfig(port=port, is_public=public)) - return self - - def add_path( - self, path: str, port: int, *, strip_path: bool | None = None - ) -> ReactiveServiceBuilder: - self._paths.append(PathConfig(port=port, path=path, strip_path=strip_path)) - return self - - def build(self) -> tuple[str, ReactiveServiceConfig]: - if self._plan is None: - raise ValueError( - f"Service '{self._name}' requires a plan ID. " - "Use .plan(plan_id) to set it." - ) - - network = None - if self._ports or self._paths: - network = NetworkConfig(ports=self._ports, paths=self._paths) - - config = ReactiveServiceConfig( - steps=self._steps, - plan=self._plan, - replicas=self._replicas, - env=self._env if self._env else None, - base_image=self._base_image, - run_as_user=self._run_as_user, - run_as_group=self._run_as_group, - mount_sub_path=self._mount_sub_path, - health_endpoint=self._health_endpoint, - network=network, - ) - return self._name, config - - -class ManagedServiceBuilder: - def __init__(self, name: str, provider: str, plan: str): - self._name = name - self._provider = provider - self._plan = plan - self._config: dict[str, Any] = {} - self._secrets: dict[str, str] = {} - - @property - def name(self) -> str: - return self._name - - def config(self, key: str, value: Any) -> ManagedServiceBuilder: - self._config[key] = value - return self - - def configs(self, config: dict[str, Any]) -> ManagedServiceBuilder: - self._config.update(config) - return self - - def secret(self, key: str, value: str) -> ManagedServiceBuilder: - self._secrets[key] = value - return self - - def secrets(self, secrets: dict[str, str]) -> ManagedServiceBuilder: - self._secrets.update(secrets) - return self - - def build(self) -> tuple[str, ManagedServiceConfig]: - config = ManagedServiceConfig( - provider=self._provider, - plan=self._plan, - config=self._config if self._config else None, - secrets=self._secrets if self._secrets else None, - ) - return self._name, config - - -class ProfileBuilder: - """Fluent builder for creating landscape profile configurations. - - Example: - ```python - profile = ( - ProfileBuilder() - .prepare() - .add_step("npm install") - .add_step("npm run build") - .done() - .add_reactive_service("web") - .add_step("npm start") - .add_port(3000, public=True) - .add_path("/api", port=3000) - .replicas(2) - .env("NODE_ENV", "production") - .done() - .add_managed_service("db", provider="postgres", plan="small") - .config("max_connections", 100) - .done() - .build() - ) - - # Save to workspace - await workspace.landscape.save_profile("production", profile) - ``` - """ - - def __init__(self) -> None: - self._prepare_steps: list[Step] = [] - self._test_steps: list[Step] = [] - self._services: dict[str, ReactiveServiceConfig | ManagedServiceConfig] = {} - self._current_service: Any | None = None - - def prepare(self) -> PrepareStageBuilder: - return PrepareStageBuilder(self) - - def test(self) -> TestStageBuilder: - return TestStageBuilder(self) - - def add_reactive_service(self, name: str) -> ReactiveServiceBuilderContext: - self._finalize_current_service() - self._current_service = ReactiveServiceBuilderContext(self, name) - return self._current_service - - def add_managed_service( - self, name: str, *, provider: str, plan: str - ) -> ManagedServiceBuilderContext: - self._finalize_current_service() - self._current_service = ManagedServiceBuilderContext(self, name, provider, plan) - return self._current_service - - def __getattr__(self, name: str) -> Any: - """Delegate unknown methods to the current service builder.""" - if self._current_service is not None and hasattr(self._current_service, name): - method = getattr(self._current_service, name) - if callable(method): - - def wrapper(*args: Any, **kwargs: Any) -> ProfileBuilder: - method(*args, **kwargs) - return self - - return wrapper - raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") - - def _finalize_current_service(self) -> None: - if self._current_service is not None: - name, config = self._current_service._builder.build() - self._services[name] = config - self._current_service = None - - def build(self) -> ProfileConfig: - """Build the final profile configuration.""" - self._finalize_current_service() - return ProfileConfig( - prepare=StageConfig(steps=self._prepare_steps), - test=StageConfig(steps=self._test_steps), - run=self._services, - ) - - -class PrepareStageBuilder: - def __init__(self, parent: ProfileBuilder): - self._parent = parent - - def add_step(self, command: str, name: str | None = None) -> PrepareStageBuilder: - self._parent._prepare_steps.append(Step(command=command, name=name)) - return self - - def done(self) -> ProfileBuilder: - return self._parent - - -class TestStageBuilder: - def __init__(self, parent: ProfileBuilder): - self._parent = parent - - def add_step(self, command: str, name: str | None = None) -> TestStageBuilder: - self._parent._test_steps.append(Step(command=command, name=name)) - return self - - def done(self) -> ProfileBuilder: - return self._parent - - -class ReactiveServiceBuilderContext: - def __init__(self, parent: ProfileBuilder, name: str): - self._parent = parent - self._builder = ReactiveServiceBuilder(name) - - def add_step( - self, command: str, name: str | None = None - ) -> ReactiveServiceBuilderContext: - self._builder.add_step(command, name) - return self - - def env(self, key: str, value: str) -> ReactiveServiceBuilderContext: - self._builder.env(key, value) - return self - - def envs(self, env_vars: dict[str, str]) -> ReactiveServiceBuilderContext: - self._builder.envs(env_vars) - return self - - def plan(self, plan_id: int) -> ReactiveServiceBuilderContext: - self._builder.plan(plan_id) - return self - - def replicas(self, count: int) -> ReactiveServiceBuilderContext: - self._builder.replicas(count) - return self - - def base_image(self, image: str) -> ReactiveServiceBuilderContext: - self._builder.base_image(image) - return self - - def run_as( - self, user: int | None = None, group: int | None = None - ) -> ReactiveServiceBuilderContext: - self._builder.run_as(user, group) - return self - - def mount_sub_path(self, path: str) -> ReactiveServiceBuilderContext: - self._builder.mount_sub_path(path) - return self - - def health_endpoint(self, endpoint: str) -> ReactiveServiceBuilderContext: - self._builder.health_endpoint(endpoint) - return self - - def add_port( - self, port: int, *, public: bool = False - ) -> ReactiveServiceBuilderContext: - self._builder.add_port(port, public=public) - return self - - def add_path( - self, path: str, port: int, *, strip_path: bool | None = None - ) -> ReactiveServiceBuilderContext: - self._builder.add_path(path, port, strip_path=strip_path) - return self - - def done(self) -> ProfileBuilder: - name, config = self._builder.build() - self._parent._services[name] = config - return self._parent - - -class ManagedServiceBuilderContext: - def __init__(self, parent: ProfileBuilder, name: str, provider: str, plan: str): - self._parent = parent - self._builder = ManagedServiceBuilder(name, provider, plan) - - def config(self, key: str, value: Any) -> ManagedServiceBuilderContext: - self._builder.config(key, value) - return self - - def configs(self, config: dict[str, Any]) -> ManagedServiceBuilderContext: - self._builder.configs(config) - return self - - def secret(self, key: str, value: str) -> ManagedServiceBuilderContext: - self._builder.secret(key, value) - return self - - def secrets(self, secrets: dict[str, str]) -> ManagedServiceBuilderContext: - self._builder.secrets(secrets) - return self - - def done(self) -> ProfileBuilder: - name, config = self._builder.build() - self._parent._services[name] = config - return self._parent +from codesphere._async.resources.workspace.landscape.schemas import * diff --git a/src/codesphere/resources/workspace/logs/__init__.py b/src/codesphere/resources/workspace/logs/__init__.py index 176ecfd..3984df0 100644 --- a/src/codesphere/resources/workspace/logs/__init__.py +++ b/src/codesphere/resources/workspace/logs/__init__.py @@ -1,22 +1,3 @@ -from .resources import ( - LogStream, - LogTarget, - ReplicaTarget, - ServerTarget, - StageTarget, - WorkspaceLogManager, -) -from .schemas import LogEntry, LogKind, LogProblem, LogStage +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.logs.""" -__all__ = [ - "LogEntry", - "LogKind", - "LogProblem", - "LogStage", - "LogStream", - "LogTarget", - "ReplicaTarget", - "ServerTarget", - "StageTarget", - "WorkspaceLogManager", -] +from codesphere._async.resources.workspace.logs import * diff --git a/src/codesphere/resources/workspace/logs/operations.py b/src/codesphere/resources/workspace/logs/operations.py index 12d0e74..de1629f 100644 --- a/src/codesphere/resources/workspace/logs/operations.py +++ b/src/codesphere/resources/workspace/logs/operations.py @@ -1,17 +1,7 @@ -from ....core.operations import StreamOperation -from .schemas import LogEntry +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.logs.operations.""" -_STREAM_STAGE_LOGS_OP = StreamOperation( - endpoint_template="/workspaces/{id}/logs/{stage}/{step}", - entry_model=LogEntry, -) - -_STREAM_SERVER_LOGS_OP = StreamOperation( - endpoint_template="/workspaces/{id}/logs/run/{step}/server/{server}", - entry_model=LogEntry, -) - -_STREAM_REPLICA_LOGS_OP = StreamOperation( - endpoint_template="/workspaces/{id}/logs/run/{step}/replica/{replica}", - entry_model=LogEntry, +from codesphere._async.resources.workspace.logs.operations import ( + _STREAM_REPLICA_LOGS_OP, + _STREAM_SERVER_LOGS_OP, + _STREAM_STAGE_LOGS_OP, ) diff --git a/src/codesphere/resources/workspace/logs/resources.py b/src/codesphere/resources/workspace/logs/resources.py index 40e9808..00b04ce 100644 --- a/src/codesphere/resources/workspace/logs/resources.py +++ b/src/codesphere/resources/workspace/logs/resources.py @@ -1,338 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.logs.resources.""" -import asyncio -import json -import logging -import warnings -from collections.abc import AsyncIterator -from dataclasses import dataclass -from typing import Any - -import httpx - -from ....core.base import WorkspaceScopedResource -from ....core.operations import StreamOperation -from ....exceptions import APIError, ValidationError, raise_for_status -from ....http_client import APIHttpClient -from .operations import ( - _STREAM_REPLICA_LOGS_OP, - _STREAM_SERVER_LOGS_OP, - _STREAM_STAGE_LOGS_OP, -) -from .schemas import LogEntry, LogProblem, LogStage - -log = logging.getLogger(__name__) - -# SSE event names that terminate a stream. -_STREAM_END_EVENTS = frozenset({"end", "close", "done", "complete"}) - - -@dataclass(frozen=True, slots=True) -class StageTarget: - """Logs of a pipeline stage (prepare/test/run).""" - - stage: LogStage | str - step: int = 0 - - -@dataclass(frozen=True, slots=True) -class ServerTarget: - """Run logs of a specific server in a Multi Server Deployment.""" - - step: int - server: str - - -@dataclass(frozen=True, slots=True) -class ReplicaTarget: - """Run logs of a specific replica.""" - - step: int - replica: str - - -type LogTarget = StageTarget | ServerTarget | ReplicaTarget - - -def _resolve_target( - target: LogTarget, -) -> tuple[StreamOperation[LogEntry], dict[str, Any]]: - match target: - case StageTarget(stage=stage, step=step): - stage_value = stage.value if isinstance(stage, LogStage) else stage - return _STREAM_STAGE_LOGS_OP, {"stage": stage_value, "step": step} - case ServerTarget(step=step, server=server): - return _STREAM_SERVER_LOGS_OP, {"step": step, "server": server} - case ReplicaTarget(step=step, replica=replica): - return _STREAM_REPLICA_LOGS_OP, {"step": step, "replica": replica} - raise TypeError(f"Unsupported log target: {target!r}") - - -def _deprecated(old: str, new: str) -> None: - warnings.warn( - f"'{old}' is deprecated; use '{new}' instead.", - DeprecationWarning, - stacklevel=3, - ) - - -class LogStream: - """Async context manager for streaming logs via SSE.""" - - def __init__( - self, - http_client: APIHttpClient, - endpoint: str, - entry_model: type[LogEntry], - timeout: float | None = None, - ): - self._http_client = http_client - self._endpoint = endpoint - self._entry_model = entry_model - self._timeout = timeout - self._response: httpx.Response | None = None - self._stream_context: Any = None - - async def __aenter__(self) -> LogStream: - headers = {"Accept": "text/event-stream"} - self._stream_context = self._http_client.stream( - "GET", - self._endpoint, - headers=headers, - timeout=self._http_client.stream_timeout, - ) - self._response = await self._stream_context.__aenter__() - - if not self._response.is_success: - await self._response.aread() - raise_for_status(self._response) - - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - if self._stream_context: - await self._stream_context.__aexit__(exc_type, exc_val, exc_tb) - - def __aiter__(self) -> AsyncIterator[LogEntry]: - return self._iterate() - - async def _iterate(self) -> AsyncIterator[LogEntry]: - if self._response is None: - raise RuntimeError("LogStream must be used as async context manager") - - if self._timeout is not None: - async with asyncio.timeout(self._timeout): - async for entry in self._parse_sse_stream(): - yield entry - else: - async for entry in self._parse_sse_stream(): - yield entry - - async def _parse_sse_stream(self) -> AsyncIterator[LogEntry]: - if self._response is None: - raise RuntimeError("LogStream must be used as async context manager") - - event_type: str | None = None - data_buffer: list[str] = [] - - async for line in self._response.aiter_lines(): - line = line.strip() - - if not line: - if event_type and data_buffer: - data_str = "\n".join(data_buffer) - - if event_type in _STREAM_END_EVENTS: - return - - if event_type == "problem": - self._handle_problem(data_str) - elif event_type == "data": - for entry in self._parse_data(data_str): - yield entry - - elif event_type in _STREAM_END_EVENTS: - return - - event_type = None - data_buffer = [] - continue - - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data_buffer.append(line[5:].strip()) - elif not line.startswith(":") and event_type: - data_buffer.append(line) - - def _parse_data(self, data_str: str) -> list[LogEntry]: - entries = [] - try: - json_data = json.loads(data_str) - if isinstance(json_data, list): - for item in json_data: - entries.append(self._entry_model.model_validate(item)) - else: - entries.append(self._entry_model.model_validate(json_data)) - except json.JSONDecodeError as e: - log.warning(f"Failed to parse log entry JSON: {e}") - except Exception as e: - log.warning(f"Failed to validate log entry: {e}") - return entries - - def _handle_problem(self, data_str: str) -> None: - try: - problem_data = json.loads(data_str) - problem = LogProblem.model_validate(problem_data) - - if problem.status == 400: - raise ValidationError( - message=problem.reason, - errors=[{"detail": problem.detail}] if problem.detail else None, - ) - else: - raise APIError( - message=problem.reason, - status_code=problem.status, - response_body=problem_data, - ) - except json.JSONDecodeError as e: - raise APIError(message=f"Invalid problem event: {data_str}") from e - - -class WorkspaceLogManager(WorkspaceScopedResource): - """Manager for streaming workspace logs via SSE. - - The unified API takes a :data:`LogTarget`: - - ```python - async for entry in workspace.logs.stream(StageTarget(LogStage.RUN, step=1)): - ... - entries = await workspace.logs.collect(ServerTarget(step=1, server="web")) - ``` - """ - - def _build_endpoint(self, operation: StreamOperation, **kwargs: Any) -> str: - return operation.endpoint_template.format(id=self.workspace_id, **kwargs) - - def open(self, target: LogTarget, timeout: float | None = None) -> LogStream: - """Open a log stream for ``target`` as an async context manager.""" - op, params = _resolve_target(target) - endpoint = self._build_endpoint(op, **params) - return LogStream(self._http_client, endpoint, op.entry_model, timeout=timeout) - - async def stream( - self, - target: LogTarget | LogStage | str, - step: int | None = None, - timeout: float | None = 30.0, - ) -> AsyncIterator[LogEntry]: - """Stream log entries for ``target`` as they arrive.""" - resolved = self._coerce_target(target, step, "stream") - async with self.open(resolved, timeout) as stream: - async for entry in stream: - yield entry - - async def collect( - self, - target: LogTarget | LogStage | str, - step: int | None = None, - max_entries: int | None = None, - timeout: float | None = 30.0, - ) -> list[LogEntry]: - """Collect log entries for ``target`` into a list.""" - resolved = self._coerce_target(target, step, "collect") - entries: list[LogEntry] = [] - try: - async with self.open(resolved, timeout) as stream: - async for entry in stream: - entries.append(entry) - if max_entries and len(entries) >= max_entries: - break - except TimeoutError: - pass - return entries - - @staticmethod - def _coerce_target( - target: LogTarget | LogStage | str, step: int | None, method: str - ) -> LogTarget: - if isinstance(target, StageTarget | ServerTarget | ReplicaTarget): - return target - _deprecated(f"{method}(stage, step)", f"{method}(StageTarget(stage, step))") - return StageTarget(stage=target, step=step if step is not None else 0) - - # ------------------------------------------------------------------ - # Deprecated aliases (kept for backwards compatibility) - # ------------------------------------------------------------------ - - def open_stream( - self, stage: LogStage | str, step: int, timeout: float | None = None - ) -> LogStream: - """Deprecated: use :meth:`open` with a :class:`StageTarget`.""" - _deprecated("open_stream", "open(StageTarget(...))") - return self.open(StageTarget(stage=stage, step=step), timeout=timeout) - - def open_server_stream( - self, step: int, server: str, timeout: float | None = None - ) -> LogStream: - """Deprecated: use :meth:`open` with a :class:`ServerTarget`.""" - _deprecated("open_server_stream", "open(ServerTarget(...))") - return self.open(ServerTarget(step=step, server=server), timeout=timeout) - - def open_replica_stream( - self, step: int, replica: str, timeout: float | None = None - ) -> LogStream: - """Deprecated: use :meth:`open` with a :class:`ReplicaTarget`.""" - _deprecated("open_replica_stream", "open(ReplicaTarget(...))") - return self.open(ReplicaTarget(step=step, replica=replica), timeout=timeout) - - async def stream_server( - self, step: int, server: str, timeout: float | None = None - ) -> AsyncIterator[LogEntry]: - """Deprecated: use :meth:`stream` with a :class:`ServerTarget`.""" - _deprecated("stream_server", "stream(ServerTarget(...))") - async for entry in self.stream( - ServerTarget(step=step, server=server), timeout=timeout - ): - yield entry - - async def stream_replica( - self, step: int, replica: str, timeout: float | None = None - ) -> AsyncIterator[LogEntry]: - """Deprecated: use :meth:`stream` with a :class:`ReplicaTarget`.""" - _deprecated("stream_replica", "stream(ReplicaTarget(...))") - async for entry in self.stream( - ReplicaTarget(step=step, replica=replica), timeout=timeout - ): - yield entry - - async def collect_server( - self, - step: int, - server: str, - max_entries: int | None = None, - timeout: float | None = 30.0, - ) -> list[LogEntry]: - """Deprecated: use :meth:`collect` with a :class:`ServerTarget`.""" - _deprecated("collect_server", "collect(ServerTarget(...))") - return await self.collect( - ServerTarget(step=step, server=server), - max_entries=max_entries, - timeout=timeout, - ) - - async def collect_replica( - self, - step: int, - replica: str, - max_entries: int | None = None, - timeout: float | None = 30.0, - ) -> list[LogEntry]: - """Deprecated: use :meth:`collect` with a :class:`ReplicaTarget`.""" - _deprecated("collect_replica", "collect(ReplicaTarget(...))") - return await self.collect( - ReplicaTarget(step=step, replica=replica), - max_entries=max_entries, - timeout=timeout, - ) +from codesphere._async.resources.workspace.logs.resources import * diff --git a/src/codesphere/resources/workspace/logs/schemas.py b/src/codesphere/resources/workspace/logs/schemas.py index 970f14c..7310a77 100644 --- a/src/codesphere/resources/workspace/logs/schemas.py +++ b/src/codesphere/resources/workspace/logs/schemas.py @@ -1,36 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.logs.schemas.""" -from enum import Enum - -from pydantic import ConfigDict, Field - -from ....core.base import CamelModel - - -class LogStage(str, Enum): - PREPARE = "prepare" - TEST = "test" - RUN = "run" - - -class LogKind(str, Enum): - INFO = "I" - ERROR = "E" - - -class LogEntry(CamelModel): - model_config = ConfigDict(extra="allow") - - timestamp: str | None = None - # Known kinds are typed; unknown values fall back to the raw string. - kind: LogKind | str | None = Field(default=None, union_mode="left_to_right") - data: str | None = None # The actual log content - - def get_text(self) -> str: - return self.data or "" - - -class LogProblem(CamelModel): - status: int - reason: str - detail: str | None = None +from codesphere._async.resources.workspace.logs.schemas import * diff --git a/src/codesphere/resources/workspace/operations.py b/src/codesphere/resources/workspace/operations.py index 42c57b1..2ff7b38 100644 --- a/src/codesphere/resources/workspace/operations.py +++ b/src/codesphere/resources/workspace/operations.py @@ -1,57 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.operations.""" -from types import NoneType - -from ...core.base import ResourceList -from ...core.operations import APIOperation -from .schemas import ( - CommandInput, - CommandOutput, - Workspace, - WorkspaceCreate, - WorkspaceStatus, -) - -_LIST_BY_TEAM_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/team/{team_id}", - response_model=ResourceList[Workspace], -) - -_GET_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{workspace_id}", - response_model=Workspace, -) - -_CREATE_OP = APIOperation( - method="POST", - endpoint_template="/workspaces", - input_model=WorkspaceCreate, - response_model=Workspace, -) - -_UPDATE_OP = APIOperation( - method="PATCH", - endpoint_template="/workspaces/{id}", - response_model=NoneType, -) - -_DELETE_OP = APIOperation( - method="DELETE", - endpoint_template="/workspaces/{id}", - response_model=NoneType, -) - -_GET_STATUS_OP = APIOperation( - method="GET", - endpoint_template="/workspaces/{id}/status", - response_model=WorkspaceStatus, -) - -_EXECUTE_COMMAND_OP = APIOperation( - method="POST", - endpoint_template="/workspaces/{id}/execute", - input_model=CommandInput, - response_model=CommandOutput, -) +from codesphere._async.resources.workspace.operations import * diff --git a/src/codesphere/resources/workspace/resources.py b/src/codesphere/resources/workspace/resources.py index 4847c41..0cb742f 100644 --- a/src/codesphere/resources/workspace/resources.py +++ b/src/codesphere/resources/workspace/resources.py @@ -1,24 +1,3 @@ -from ...core import ResourceBase -from ...exceptions import ValidationError -from .operations import ( - _CREATE_OP, - _GET_OP, - _LIST_BY_TEAM_OP, -) -from .schemas import Workspace, WorkspaceCreate +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.resources.""" - -class WorkspacesResource(ResourceBase): - async def list(self, team_id: int) -> list[Workspace]: - if team_id <= 0: - raise ValidationError("team_id must be a positive integer") - result = await self._execute(_LIST_BY_TEAM_OP, team_id=team_id) - return result.root - - async def get(self, workspace_id: int) -> Workspace: - if workspace_id <= 0: - raise ValidationError("workspace_id must be a positive integer") - return await self._execute(_GET_OP, workspace_id=workspace_id) - - async def create(self, payload: WorkspaceCreate) -> Workspace: - return await self._execute(_CREATE_OP, data=payload) +from codesphere._async.resources.workspace.resources import * diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index dc7cbd7..4a84a54 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -1,161 +1,3 @@ -from __future__ import annotations +"""Compatibility shim; implementation lives in codesphere._async.resources.workspace.schemas.""" -import asyncio -import logging -from functools import cached_property - -from ...core.base import BoundModel, CamelModel -from ...utils import update_model_fields -from .env_vars import EnvVar, WorkspaceEnvVarManager -from .git import WorkspaceGitManager -from .landscape import WorkspaceLandscapeManager -from .logs import WorkspaceLogManager - -log = logging.getLogger(__name__) - - -class WorkspaceCreate(CamelModel): - team_id: int - name: str - plan_id: int - base_image: str | None = None - is_private_repo: bool = True - replicas: int = 1 - git_url: str | None = None - initial_branch: str | None = None - clone_depth: int | None = None - source_workspace_id: int | None = None - welcome_message: str | None = None - vpn_config: str | None = None - restricted: bool | None = None - env: list[EnvVar] | None = None - - -class WorkspaceBase(CamelModel): - id: int - team_id: int - name: str - plan_id: int - is_private_repo: bool - replicas: int - base_image: str | None = None - data_center_id: int - user_id: int - git_url: str | None = None - initial_branch: str | None = None - source_workspace_id: int | None = None - welcome_message: str | None = None - vpn_config: str | None = None - restricted: bool - - -class WorkspaceUpdate(CamelModel): - plan_id: int | None = None - base_image: str | None = None - name: str | None = None - replicas: int | None = None - vpn_config: str | None = None - restricted: bool | None = None - - -class CommandInput(CamelModel): - command: str - env: dict[str, str] | None = None - - -class CommandOutput(CamelModel): - command: str - working_dir: str - output: str - error: str - - -class WorkspaceStatus(CamelModel): - is_running: bool - - -class Workspace(WorkspaceBase, BoundModel): - async def update(self, data: WorkspaceUpdate) -> None: - from .operations import _UPDATE_OP - - await self._execute(_UPDATE_OP, id=self.id, data=data) - update_model_fields(target=self, source=data) - - async def delete(self) -> None: - from .operations import _DELETE_OP - - await self._execute(_DELETE_OP, id=self.id) - - async def get_status(self) -> WorkspaceStatus: - from .operations import _GET_STATUS_OP - - return await self._execute(_GET_STATUS_OP, id=self.id) - - async def wait_until_running( - self, - *, - timeout: float = 300.0, - poll_interval: float = 5.0, - ) -> None: - if poll_interval <= 0: - raise ValueError("poll_interval must be greater than 0") - - elapsed = 0.0 - while elapsed < timeout: - status = await self.get_status() - if status.is_running: - log.debug("Workspace %s is now running.", self.id) - return - log.debug( - "Workspace %s not running yet, waiting %ss... (elapsed: %.1fs)", - self.id, - poll_interval, - elapsed, - ) - await asyncio.sleep(poll_interval) - elapsed += poll_interval - - raise TimeoutError( - f"Workspace {self.id} did not reach running state within {timeout} seconds." - ) - - async def execute_command( - self, command: str, env: dict[str, str] | None = None - ) -> CommandOutput: - from .operations import _EXECUTE_COMMAND_OP - - command_data = CommandInput(command=command, env=env) - return await self._execute(_EXECUTE_COMMAND_OP, id=self.id, data=command_data) - - @cached_property - def env_vars(self) -> WorkspaceEnvVarManager: - return WorkspaceEnvVarManager(self._client(), workspace_id=self.id) - - @cached_property - def landscape(self) -> WorkspaceLandscapeManager: - """Manager for landscape operations (Multi Server Deployments).""" - return WorkspaceLandscapeManager(self._client(), workspace_id=self.id) - - @cached_property - def git(self) -> WorkspaceGitManager: - """Manager for git operations (head, pull).""" - return WorkspaceGitManager(self._client(), workspace_id=self.id) - - @cached_property - def logs(self) -> WorkspaceLogManager: - """Manager for streaming workspace logs. - - Provides methods to stream or collect logs from pipeline stages - (prepare, test, run) and Multi Server Deployment servers/replicas. - - Example: - ```python - # Stream logs as they arrive - async for entry in workspace.logs.stream(stage="prepare", step=1): - print(entry.message) - - # Collect all logs at once - entries = await workspace.logs.collect(stage="test", step=1) - ``` - """ - return WorkspaceLogManager(self._client(), workspace_id=self.id) +from codesphere._async.resources.workspace.schemas import * diff --git a/src/codesphere/sync/__init__.py b/src/codesphere/sync/__init__.py new file mode 100644 index 0000000..8f9d380 --- /dev/null +++ b/src/codesphere/sync/__init__.py @@ -0,0 +1,106 @@ +"""Synchronous Codesphere client. + +Mirrors the async API with identical signatures, minus ``async``/``await``: + + >>> from codesphere.sync import CodesphereSDK + >>> + >>> with CodesphereSDK() as sdk: + ... teams = sdk.teams.list() + +The implementation is generated from the async source +(``codesphere._async``) — see ``make sync-gen``. Request/response models +are shared between both flavors (``codesphere.models``); only entities +with API methods (``Workspace``, ``Team``, ``Domain``, ``LogStream``) +exist per flavor. +""" + +from codesphere._sync.client import CodesphereSDK +from codesphere._sync.resources.flags import FlagsResource +from codesphere._sync.resources.metadata import MetadataResource +from codesphere._sync.resources.team import Team, TeamsResource +from codesphere._sync.resources.team.domain import TeamDomainManager +from codesphere._sync.resources.team.domain.schemas import Domain +from codesphere._sync.resources.team.usage import TeamUsageManager +from codesphere._sync.resources.workspace import ( + LogStream, + Workspace, + WorkspacesResource, +) +from codesphere.config import RetryConfig +from codesphere.exceptions import ( + APIError, + AuthenticationError, + AuthorizationError, + CodesphereError, + ConflictError, + FeatureFlagError, + FeatureNotAvailableError, + FeatureNotEnabledError, + NetworkError, + NotFoundError, + RateLimitError, + TimeoutError, + ValidationError, +) +from codesphere.feature_flags import CategoryFlags, FlagCategory, FlagsSnapshot +from codesphere.models import ( + Characteristic, + CustomDomainConfig, + Datacenter, + DomainBase, + DomainRouting, + DomainVerificationStatus, + EnvVar, + Image, + TeamBase, + TeamCreate, + WorkspaceCreate, + WorkspaceStatus, + WorkspaceUpdate, + WsPlan, +) + +__all__ = [ + "APIError", + "AuthenticationError", + "AuthorizationError", + "CategoryFlags", + "Characteristic", + "CodesphereError", + "CodesphereSDK", + "ConflictError", + "CustomDomainConfig", + "Datacenter", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "EnvVar", + "FeatureFlagError", + "FeatureNotAvailableError", + "FeatureNotEnabledError", + "FlagCategory", + "FlagsResource", + "FlagsSnapshot", + "Image", + "LogStream", + "MetadataResource", + "NetworkError", + "NotFoundError", + "RateLimitError", + "RetryConfig", + "Team", + "TeamBase", + "TeamCreate", + "TeamDomainManager", + "TeamUsageManager", + "TeamsResource", + "TimeoutError", + "ValidationError", + "Workspace", + "WorkspaceCreate", + "WorkspaceStatus", + "WorkspaceUpdate", + "WorkspacesResource", + "WsPlan", +] diff --git a/src/codesphere/utils.py b/src/codesphere/utils.py index e6dfe99..8e00a9d 100644 --- a/src/codesphere/utils.py +++ b/src/codesphere/utils.py @@ -7,8 +7,9 @@ def update_model_fields(target: BaseModel, source: BaseModel) -> None: if log.isEnabledFor(logging.DEBUG): - debug_dump = source.model_dump(exclude_unset=True) - log.debug(f"Updating {target.__class__.__name__} with data: {debug_dump}") + # Field names only — values may contain secrets (e.g. env vars). + fields = ", ".join(sorted(source.model_fields_set)) + log.debug(f"Updating {target.__class__.__name__} fields: {fields}") for field_name in source.model_fields_set: value = getattr(source, field_name) diff --git a/tests/conftest.py b/tests/conftest.py index fe1472a..cdd5627 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,12 +94,16 @@ def mock_token(): def api_http_client(mock_token): from pydantic import SecretStr + from codesphere.config import RetryConfig from codesphere.http_client import APIHttpClient + # Retries off so error-path tests stay single-shot and fast; default + # retry behavior is covered explicitly in test_retry.py. return APIHttpClient( token=SecretStr(mock_token), base_url="https://codesphere.com/api", timeout=httpx.Timeout(10.0, read=30.0), + retry=RetryConfig(max_retries=0), ) @@ -308,8 +312,16 @@ def mock_api(): @pytest.fixture async def sdk(mock_api): - """An opened CodesphereSDK wired to the respx-mocked transport.""" - from codesphere import CodesphereSDK + """An opened CodesphereSDK wired to the respx-mocked transport. - async with CodesphereSDK(token="test-token", base_url=TEST_BASE_URL) as client: + Retries are off so error-path tests stay single-shot and fast; default + retry behavior is covered explicitly in test_retry.py. + """ + from codesphere import CodesphereSDK, RetryConfig + + async with CodesphereSDK( + token="test-token", + base_url=TEST_BASE_URL, + retry=RetryConfig(max_retries=0), + ) as client: yield client diff --git a/tests/core/test_execute_operation.py b/tests/core/test_execute_operation.py index 65e0b28..a66adea 100644 --- a/tests/core/test_execute_operation.py +++ b/tests/core/test_execute_operation.py @@ -47,8 +47,8 @@ class ThingCreate(BaseModel): class ThingsResource(ResourceBase): - async def get(self, thing_id: int) -> Thing: - return await self._execute(GET_THING, thing_id=thing_id) + async def get(self, thing_id: int, *, timeout=None) -> Thing: + return await self._execute(GET_THING, thing_id=thing_id, timeout=timeout) async def list(self) -> list[Thing]: return (await self._execute(LIST_THINGS)).root @@ -101,6 +101,19 @@ async def test_empty_dict_body_is_sent(self, things, mock_api): await things.rename(1, data={}) assert route.calls.last.request.content == b"{}" + async def test_per_call_timeout_overrides_client_default(self, things, mock_api): + route = mock_api.get("/things/1").respond(200, json={"id": 1, "name": "x"}) + await things.get(thing_id=1, timeout=3.5) + timeouts = route.calls.last.request.extensions["timeout"] + assert timeouts["read"] == 3.5 + assert timeouts["connect"] == 3.5 + + async def test_without_timeout_client_default_applies(self, things, mock_api): + route = mock_api.get("/things/1").respond(200, json={"id": 1, "name": "x"}) + await things.get(thing_id=1) + timeouts = route.calls.last.request.extensions["timeout"] + assert timeouts["read"] == 30.0 # Settings default client_timeout_read + class TestResponseHandling: async def test_response_is_validated_into_model(self, things, mock_api): diff --git a/tests/core/test_flag_gating.py b/tests/core/test_flag_gating.py new file mode 100644 index 0000000..954af18 --- /dev/null +++ b/tests/core/test_flag_gating.py @@ -0,0 +1,133 @@ +"""Operation gating via APIOperation.required_flag (pre-request fail-fast).""" + +import asyncio + +import pytest + +from codesphere import ( + FeatureNotAvailableError, + FeatureNotEnabledError, +) +from codesphere.core.base import BoundModel, ResourceBase +from codesphere.core.operations import APIOperation +from codesphere.feature_flags import FlagCategory, FlagRequirement + + +class Gadget(BoundModel): + id: int + + +GATED_OP = APIOperation( + method="GET", + endpoint_template="/gadgets/{gadget_id}", + response_model=Gadget, + required_flag=FlagRequirement("gadgets", FlagCategory.PREVIEW), +) + + +class GadgetsResource(ResourceBase): + async def get(self, gadget_id: int) -> Gadget: + return await self._execute(GATED_OP, gadget_id=gadget_id) + + +FLAGS_ENABLED = {"preview": {"available": ["gadgets"], "enabled": ["gadgets"]}} +FLAGS_DISABLED = {"preview": {"available": ["gadgets"], "enabled": []}} +FLAGS_MISSING = {"preview": {"available": [], "enabled": []}} + + +@pytest.fixture +def gadgets(sdk): + return GadgetsResource(sdk._http_client) + + +class TestOperationGating: + async def test_enabled_flag_lets_the_call_through(self, gadgets, mock_api): + mock_api.get("/ide-service/flags").respond(200, json=FLAGS_ENABLED) + route = mock_api.get("/gadgets/1").respond(200, json={"id": 1}) + + gadget = await gadgets.get(1) + + assert gadget.id == 1 + assert route.call_count == 1 + + async def test_disabled_flag_fails_before_any_request(self, gadgets, mock_api): + mock_api.get("/ide-service/flags").respond(200, json=FLAGS_DISABLED) + route = mock_api.get("/gadgets/1").respond(200, json={"id": 1}) + + with pytest.raises(FeatureNotEnabledError) as exc_info: + await gadgets.get(1) + + assert route.called is False # nothing was sent to the gated endpoint + assert exc_info.value.flag == "gadgets" + assert exc_info.value.category == "preview" + + async def test_unavailable_flag_raises_not_available(self, gadgets, mock_api): + mock_api.get("/ide-service/flags").respond(200, json=FLAGS_MISSING) + route = mock_api.get("/gadgets/1").respond(200, json={"id": 1}) + + with pytest.raises(FeatureNotAvailableError): + await gadgets.get(1) + assert route.called is False + + async def test_legacy_platform_404_fails_closed_with_hint(self, gadgets, mock_api): + mock_api.get("/ide-service/flags").respond(404, json={}) + + with pytest.raises(FeatureNotAvailableError, match="predate feature flags"): + await gadgets.get(1) + + async def test_flags_fetched_once_across_gated_calls(self, gadgets, mock_api): + flags_route = mock_api.get("/ide-service/flags").respond( + 200, json=FLAGS_ENABLED + ) + mock_api.get(url__regex=r".*/gadgets/\d+").respond(200, json={"id": 1}) + + await gadgets.get(1) + await gadgets.get(2) + await asyncio.gather(gadgets.get(3), gadgets.get(4), gadgets.get(5)) + + assert flags_route.call_count == 1 + + async def test_refresh_unlocks_a_newly_enabled_flag(self, sdk, gadgets, mock_api): + flags_route = mock_api.get("/ide-service/flags") + flags_route.side_effect = [ + httpx_response(200, FLAGS_DISABLED), + httpx_response(200, FLAGS_ENABLED), + ] + mock_api.get("/gadgets/1").respond(200, json={"id": 1}) + + with pytest.raises(FeatureNotEnabledError): + await gadgets.get(1) + + await sdk.flags.refresh() + gadget = await gadgets.get(1) + assert gadget.id == 1 + + +def httpx_response(status: int, json_body: dict): + import httpx + + return httpx.Response(status, json=json_body) + + +class TestRequireFlagHelper: + async def test_resource_base_helper(self, sdk, mock_api): + mock_api.get("/ide-service/flags").respond(200, json=FLAGS_ENABLED) + resource = GadgetsResource(sdk._http_client) + + await resource._require_flag( + FlagRequirement("gadgets", FlagCategory.PREVIEW) + ) # enabled -> no raise + with pytest.raises(FeatureNotAvailableError): + await resource._require_flag(FlagRequirement("other", FlagCategory.PREVIEW)) + + async def test_bound_model_helper(self, sdk, mock_api): + mock_api.get("/ide-service/flags").respond(200, json=FLAGS_ENABLED) + gadget = Gadget(id=1) + gadget._http_client = sdk._http_client + + await gadget._require_flag(FlagRequirement("gadgets", FlagCategory.PREVIEW)) + + async def test_detached_bound_model_raises_runtime_error(self): + gadget = Gadget(id=1) + with pytest.raises(RuntimeError, match="detached model"): + await gadget._require_flag(FlagRequirement("gadgets", FlagCategory.PREVIEW)) diff --git a/tests/integration/test_flags.py b/tests/integration/test_flags.py new file mode 100644 index 0000000..3189da3 --- /dev/null +++ b/tests/integration/test_flags.py @@ -0,0 +1,21 @@ +import pytest + +from codesphere import CodesphereSDK +from codesphere.feature_flags import FlagCategory, FlagsSnapshot + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +class TestFlagsIntegration: + async def test_flags_snapshot_has_three_categories(self, sdk_client: CodesphereSDK): + snapshot = await sdk_client.flags.get() + + assert isinstance(snapshot, FlagsSnapshot) + for category in FlagCategory: + flags = snapshot.category(category) + assert flags.enabled <= flags.available # enabled is a subset + + async def test_snapshot_is_cached(self, sdk_client: CodesphereSDK): + first = await sdk_client.flags.get() + second = await sdk_client.flags.get() + assert first is second diff --git a/tests/resources/flags/__init__.py b/tests/resources/flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/flags/test_flags.py b/tests/resources/flags/test_flags.py new file mode 100644 index 0000000..cb8d272 --- /dev/null +++ b/tests/resources/flags/test_flags.py @@ -0,0 +1,135 @@ +"""Tests for sdk.flags and the FlagsSnapshot model (respx transport policy).""" + +import httpx +import pytest + +from codesphere.exceptions import NetworkError +from codesphere.feature_flags import FlagCategory, FlagsSnapshot + + +@pytest.fixture +def sample_flags_data(): + """Real-world shaped payload (three categories, overlapping states).""" + return { + "internal": { + "available": ["msd", "vpn", "gpu-plan", "maintenance-mode"], + "enabled": ["msd", "vpn"], + }, + "preview": { + "available": ["workspace-ssh", "secret-management", "tcp-udp"], + "enabled": ["workspace-ssh", "secret-management"], + }, + "features": { + "available": ["billing", "email-signin", "avatar-upload"], + "enabled": ["email-signin"], + }, + } + + +class TestFlagsSnapshot: + def test_parses_payload(self, sample_flags_data): + snapshot = FlagsSnapshot.model_validate(sample_flags_data) + assert "msd" in snapshot.internal.available + assert snapshot.preview.enabled == frozenset( + {"workspace-ssh", "secret-management"} + ) + + def test_empty_snapshot_has_nothing(self): + snapshot = FlagsSnapshot.empty() + assert not snapshot.is_available("anything") + assert not snapshot.is_enabled("anything") + assert snapshot.categories_with("anything") == () + + def test_union_lookup_across_categories(self, sample_flags_data): + snapshot = FlagsSnapshot.model_validate(sample_flags_data) + assert snapshot.is_enabled("workspace-ssh") # lives in preview + assert snapshot.is_enabled("msd") # lives in internal + assert snapshot.is_available("billing") + assert not snapshot.is_enabled("billing") # available but off + + def test_category_scoped_lookup(self, sample_flags_data): + snapshot = FlagsSnapshot.model_validate(sample_flags_data) + assert snapshot.is_enabled("msd", FlagCategory.INTERNAL) + assert not snapshot.is_enabled("msd", FlagCategory.FEATURES) + assert not snapshot.is_available("msd", FlagCategory.PREVIEW) + + def test_categories_with_reports_collisions(self, sample_flags_data): + data = dict(sample_flags_data) + data["features"] = { + "available": [*data["features"]["available"], "msd"], + "enabled": [], + } + snapshot = FlagsSnapshot.model_validate(data) + assert snapshot.categories_with("msd") == ( + FlagCategory.INTERNAL, + FlagCategory.FEATURES, + ) + + def test_snapshot_is_frozen(self, sample_flags_data): + from pydantic import ValidationError + + snapshot = FlagsSnapshot.model_validate(sample_flags_data) + with pytest.raises(ValidationError): + snapshot.internal = snapshot.preview # ty: ignore[invalid-assignment] + + +class TestFlagsResource: + async def test_get_fetches_and_caches(self, sdk, mock_api, sample_flags_data): + route = mock_api.get("/ide-service/flags").respond(200, json=sample_flags_data) + + first = await sdk.flags.get() + second = await sdk.flags.get() + + assert first == second + assert route.call_count == 1 + + async def test_refresh_refetches(self, sdk, mock_api, sample_flags_data): + route = mock_api.get("/ide-service/flags").respond(200, json=sample_flags_data) + + await sdk.flags.get() + await sdk.flags.refresh() + + assert route.call_count == 2 + + async def test_is_enabled_and_available(self, sdk, mock_api, sample_flags_data): + mock_api.get("/ide-service/flags").respond(200, json=sample_flags_data) + + assert await sdk.flags.is_enabled("msd") is True + assert await sdk.flags.is_enabled("billing") is False + assert await sdk.flags.is_available("billing") is True + assert await sdk.flags.is_available("nonexistent") is False + + async def test_404_yields_cached_empty_snapshot(self, sdk, mock_api): + route = mock_api.get("/ide-service/flags").respond(404, json={}) + + snapshot = await sdk.flags.get() + again = await sdk.flags.get() + + assert snapshot == FlagsSnapshot.empty() + assert again == snapshot + assert route.call_count == 1 # 404 is cached, not retried + + async def test_transient_error_is_not_cached( + self, sdk, mock_api, sample_flags_data + ): + route = mock_api.get("/ide-service/flags") + route.side_effect = [ + httpx.ConnectError("refused"), + httpx.Response(200, json=sample_flags_data), + ] + + with pytest.raises(NetworkError): + await sdk.flags.get() + + snapshot = await sdk.flags.get() # second call succeeds + assert snapshot.is_enabled("msd") + assert route.call_count == 2 + + async def test_require_passes_and_raises(self, sdk, mock_api, sample_flags_data): + from codesphere import FeatureNotEnabledError + + mock_api.get("/ide-service/flags").respond(200, json=sample_flags_data) + + await sdk.flags.require("msd") # enabled -> no raise + with pytest.raises(FeatureNotEnabledError): + await sdk.flags.require("billing", FlagCategory.FEATURES) diff --git a/tests/resources/workspace/env_vars/test_env_vars.py b/tests/resources/workspace/env_vars/test_env_vars.py index 5518766..d2b194d 100644 --- a/tests/resources/workspace/env_vars/test_env_vars.py +++ b/tests/resources/workspace/env_vars/test_env_vars.py @@ -145,18 +145,3 @@ def test_env_var_is_camel_model(self): assert issubclass(EnvVar, CamelModel) env_var = EnvVar(name="DEBUG", value="true") assert env_var.to_dict() == {"name": "DEBUG", "value": "true"} - - -class TestDeprecatedEnvVarsAlias: - """The old camelCase module path must keep working with a warning.""" - - def test_envvars_import_warns_and_reexports(self): - import importlib - import sys - - sys.modules.pop("codesphere.resources.workspace.envVars", None) - with pytest.warns(DeprecationWarning, match="env_vars"): - legacy = importlib.import_module("codesphere.resources.workspace.envVars") - - assert legacy.EnvVar is EnvVar - assert legacy.WorkspaceEnvVarManager is WorkspaceEnvVarManager diff --git a/tests/sync/__init__.py b/tests/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sync/conftest.py b/tests/sync/conftest.py new file mode 100644 index 0000000..a522ca3 --- /dev/null +++ b/tests/sync/conftest.py @@ -0,0 +1,57 @@ +import pytest +import respx + +from codesphere import RetryConfig +from codesphere.sync import CodesphereSDK + +SYNC_BASE_URL = "https://sync.test/api" + + +@pytest.fixture +def mock_api(): + with respx.mock(base_url=SYNC_BASE_URL, assert_all_called=False) as router: + yield router + + +@pytest.fixture +def sdk(mock_api): + """An opened sync CodesphereSDK wired to the respx-mocked transport. + + Retries off so error-path tests stay single-shot; retry behavior has + its own tests. + """ + with CodesphereSDK( + token="test-token", + base_url=SYNC_BASE_URL, + retry=RetryConfig(max_retries=0), + ) as client: + yield client + + +@pytest.fixture +def sample_workspace_json(): + return { + "id": 5, + "teamId": 1, + "name": "ws", + "planId": 8, + "isPrivateRepo": True, + "replicas": 1, + "dataCenterId": 1, + "userId": 1, + "restricted": False, + } + + +@pytest.fixture +def sample_team_json(): + return { + "id": 1, + "name": "Team", + "description": None, + "avatarId": None, + "avatarUrl": None, + "isFirst": True, + "defaultDataCenterId": 1, + "role": 1, + } diff --git a/tests/sync/test_client.py b/tests/sync/test_client.py new file mode 100644 index 0000000..93e8886 --- /dev/null +++ b/tests/sync/test_client.py @@ -0,0 +1,43 @@ +"""Lifecycle tests for the generated sync client.""" + +import pytest + +from codesphere import AuthenticationError, RetryConfig +from codesphere.sync import CodesphereSDK + +BASE = "https://sync.test/api" + + +class TestLifecycle: + def test_context_manager_opens_and_closes(self, mock_api, sample_team_json): + mock_api.get("/teams").respond(200, json=[sample_team_json]) + with CodesphereSDK(token="t", base_url=BASE) as sdk: + assert sdk.teams.list()[0].id == 1 + assert sdk._http_client._client is None + + def test_open_close_explicitly(self, mock_api, sample_team_json): + mock_api.get("/teams").respond(200, json=[sample_team_json]) + sdk = CodesphereSDK(token="t", base_url=BASE) + sdk.open() + try: + assert len(sdk.teams.list()) == 1 + finally: + sdk.close() + + def test_request_before_open_raises(self): + sdk = CodesphereSDK(token="t", base_url=BASE) + with pytest.raises(RuntimeError, match="not open"): + sdk.teams.list() + + def test_missing_token_raises_authentication_error(self, monkeypatch): + monkeypatch.delenv("CS_TOKEN", raising=False) + monkeypatch.setattr( + "codesphere._sync.client.Settings", + lambda: type("S", (), {"token": None})(), + ) + with pytest.raises(AuthenticationError): + CodesphereSDK() + + def test_retry_config_is_wired(self): + sdk = CodesphereSDK(token="t", base_url=BASE, retry=RetryConfig(max_retries=5)) + assert sdk._http_client._retry.max_retries == 5 diff --git a/tests/sync/test_logs.py b/tests/sync/test_logs.py new file mode 100644 index 0000000..f1b73c1 --- /dev/null +++ b/tests/sync/test_logs.py @@ -0,0 +1,73 @@ +"""SSE log streaming through the generated sync LogStream.""" + +import httpx +import pytest + +from codesphere.models.logs import LogStage, StageTarget + +SSE_BODY = ( + "event: data\n" + 'data: {"timestamp": "t1", "kind": "I", "data": "line one"}\n' + "\n" + "event: data\n" + 'data: [{"timestamp": "t2", "kind": "E", "data": "line two"}]\n' + "\n" + "event: end\n" + "data: done\n" + "\n" +) + + +@pytest.fixture +def workspace(sdk, mock_api, sample_workspace_json): + mock_api.get("/workspaces/5").respond(200, json=sample_workspace_json) + return sdk.workspaces.get(5) + + +class TestSyncLogStream: + def test_stream_yields_entries_until_end_event(self, workspace, mock_api): + mock_api.get("/workspaces/5/logs/prepare/0").respond(200, text=SSE_BODY) + + entries = list(workspace.logs.stream(StageTarget(LogStage.PREPARE, step=0))) + + assert [e.data for e in entries] == ["line one", "line two"] + assert entries[1].kind == "E" + + def test_collect_with_max_entries(self, workspace, mock_api): + mock_api.get("/workspaces/5/logs/run/1").respond(200, text=SSE_BODY) + + entries = workspace.logs.collect( + StageTarget(LogStage.RUN, step=1), max_entries=1 + ) + + assert len(entries) == 1 + + def test_open_as_context_manager(self, workspace, mock_api): + mock_api.get("/workspaces/5/logs/test/0").respond(200, text=SSE_BODY) + + with workspace.logs.open(StageTarget(LogStage.TEST, step=0)) as stream: + first = next(iter(stream)) + + assert first.data == "line one" + + def test_http_error_maps_to_sdk_error(self, workspace, mock_api): + from codesphere import NotFoundError + + mock_api.get("/workspaces/5/logs/run/9").respond(404, json={"message": "no"}) + + with ( + pytest.raises(NotFoundError), + workspace.logs.open(StageTarget(LogStage.RUN, step=9)), + ): + pass + + def test_read_timeout_maps_to_timeout_error(self, workspace, mock_api): + mock_api.get("/workspaces/5/logs/run/2").mock( + side_effect=httpx.ReadTimeout("slow") + ) + + with ( + pytest.raises(httpx.ReadTimeout), + workspace.logs.open(StageTarget(LogStage.RUN, step=2), timeout=0.1), + ): + pass diff --git a/tests/sync/test_resources.py b/tests/sync/test_resources.py new file mode 100644 index 0000000..0d7babf --- /dev/null +++ b/tests/sync/test_resources.py @@ -0,0 +1,96 @@ +"""Resource-layer behavior of the sync client at the HTTP boundary.""" + +import json + +import pytest + +import codesphere +from codesphere import FeatureNotEnabledError, NotFoundError +from codesphere.sync import WorkspaceCreate + + +class TestResources: + def test_teams_list(self, sdk, mock_api, sample_team_json): + route = mock_api.get("/teams").respond(200, json=[sample_team_json]) + teams = sdk.teams.list() + assert [t.name for t in teams] == ["Team"] + assert route.called + + def test_bound_model_entity_method(self, sdk, mock_api, sample_workspace_json): + mock_api.get("/workspaces/5").respond(200, json=sample_workspace_json) + delete_route = mock_api.delete("/workspaces/5").respond(204) + + workspace = sdk.workspaces.get(5) + workspace.delete() + + assert delete_route.called + + def test_cached_property_sub_manager(self, sdk, mock_api, sample_workspace_json): + mock_api.get("/workspaces/5").respond(200, json=sample_workspace_json) + env_route = mock_api.get("/workspaces/5/env-vars").respond( + 200, json=[{"name": "A", "value": "b"}] + ) + + workspace = sdk.workspaces.get(5) + env_vars = workspace.env_vars.get() + + assert [e.name for e in env_vars] == ["A"] + assert env_route.called + + def test_error_mapping(self, sdk, mock_api): + mock_api.get("/workspaces/404").respond(404, json={"message": "nope"}) + with pytest.raises(NotFoundError): + sdk.workspaces.get(404) + + def test_per_call_timeout_reaches_the_wire( + self, sdk, mock_api, sample_workspace_json + ): + route = mock_api.get("/workspaces/5").respond(200, json=sample_workspace_json) + sdk.workspaces.get(5, timeout=3.5) + assert route.calls.last.request.extensions["timeout"]["read"] == 3.5 + + def test_input_dto_is_shared_with_async_flavor( + self, sdk, mock_api, sample_workspace_json + ): + """A payload built from the async package must work on the sync client.""" + assert WorkspaceCreate is codesphere.WorkspaceCreate + route = mock_api.post("/workspaces").respond(201, json=sample_workspace_json) + + payload = codesphere.WorkspaceCreate(team_id=1, name="ws", plan_id=8) + sdk.workspaces.create(payload) + + sent = json.loads(route.calls.last.request.content) + assert sent == { + "teamId": 1, + "name": "ws", + "planId": 8, + "isPrivateRepo": True, + "replicas": 1, + } + + +class TestFlagGating: + def test_flags_cached_via_threading_lock(self, sdk, mock_api): + route = mock_api.get("/ide-service/flags").respond( + 200, + json={ + "internal": {"available": [], "enabled": []}, + "preview": {"available": [], "enabled": []}, + "features": {"available": ["msd"], "enabled": []}, + }, + ) + + assert sdk.flags.is_available("msd") is True + assert sdk.flags.is_enabled("msd") is False + assert route.call_count == 1 # cached after first fetch + + with pytest.raises(FeatureNotEnabledError): + sdk.flags.require("msd") + + def test_legacy_platform_404_yields_empty_snapshot(self, sdk, mock_api): + route = mock_api.get("/ide-service/flags").respond(404, json={}) + snapshot = sdk.flags.get() + again = sdk.flags.get() + assert snapshot.is_available("anything") is False + assert again == snapshot + assert route.call_count == 1 diff --git a/tests/sync/test_retry.py b/tests/sync/test_retry.py new file mode 100644 index 0000000..d75d519 --- /dev/null +++ b/tests/sync/test_retry.py @@ -0,0 +1,70 @@ +"""Retry behavior of the generated sync transport (time.sleep based).""" + +from unittest.mock import patch + +import httpx +import pytest +from pydantic import SecretStr + +from codesphere import APIError, RetryConfig +from codesphere._sync.http_client import APIHttpClient + +BASE = "https://sync-retry.test/api" + + +def make_transport(retry: RetryConfig | None = None) -> APIHttpClient: + return APIHttpClient( + token=SecretStr("t"), + base_url=BASE, + timeout=httpx.Timeout(5.0), + retry=retry, + ) + + +@pytest.fixture +def api(): + import respx + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + yield router + + +@pytest.fixture +def sleep_mock(): + with patch("codesphere._sync.http_client.time.sleep") as mock: + yield mock + + +class TestSyncRetry: + def test_429_then_success_honors_retry_after(self, api, sleep_mock): + route = api.get("/x") + route.side_effect = [ + httpx.Response(429, headers={"Retry-After": "1"}), + httpx.Response(200, json={"ok": True}), + ] + with make_transport(RetryConfig(max_retries=2)) as transport: + response = transport.request("GET", "/x") + + assert response.status_code == 200 + assert route.call_count == 2 + assert sleep_mock.call_count == 1 + assert sleep_mock.call_args.args[0] >= 1.0 + + def test_default_config_retries_twice(self, api, sleep_mock): + route = api.get("/x").respond(503, json={}) + with make_transport() as transport, pytest.raises(APIError): + transport.request("GET", "/x") + + assert route.call_count == 3 + assert sleep_mock.call_count == 2 + + def test_post_is_not_retried_by_default(self, api, sleep_mock): + route = api.post("/x").respond(502, json={}) + with ( + make_transport(RetryConfig(max_retries=3)) as transport, + pytest.raises(APIError), + ): + transport.request("POST", "/x") + + assert route.call_count == 1 + sleep_mock.assert_not_called() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index d228488..cb6dbe8 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -13,6 +13,9 @@ AuthorizationError, CodesphereError, ConflictError, + FeatureFlagError, + FeatureNotAvailableError, + FeatureNotEnabledError, NetworkError, NotFoundError, RateLimitError, @@ -160,6 +163,9 @@ class TestDefaultConstructors: (APIError, "API request failed"), (NetworkError, "network error"), (TimeoutError, "timed out"), + (FeatureFlagError, "Feature flag"), + (FeatureNotAvailableError, "not available"), + (FeatureNotEnabledError, "not enabled"), ], ) def test_default_message(self, cls, fragment): @@ -177,6 +183,19 @@ def test_network_error_keeps_original(self): def test_timeout_is_a_network_error(self): assert issubclass(TimeoutError, NetworkError) + def test_flag_errors_share_a_base_and_carry_metadata(self): + assert issubclass(FeatureNotAvailableError, FeatureFlagError) + assert issubclass(FeatureNotEnabledError, FeatureFlagError) + + error = FeatureNotEnabledError(flag="vpn", category="internal") + assert error.flag == "vpn" + assert error.category == "internal" + assert "'vpn' (category 'internal')" in error.message + + def test_not_available_mentions_legacy_platform_when_flagged(self): + error = FeatureNotAvailableError(flag="vpn", legacy_platform=True) + assert "predate feature flags" in error.message + class TestEndToEndViaTransport: """Errors surface through the real client against a mocked transport.""" diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 510434a..d9cf081 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -5,6 +5,7 @@ import respx from pydantic import SecretStr +from codesphere.config import RetryConfig from codesphere.exceptions import NetworkError, TimeoutError from codesphere.http_client import APIHttpClient @@ -13,10 +14,13 @@ @pytest.fixture def transport(): + # Retries off so error-mapping tests stay single-shot (no real sleeps); + # default retry behavior is covered in test_retry.py. return APIHttpClient( token=SecretStr("secret"), base_url=BASE, timeout=httpx.Timeout(7.0, read=21.0), + retry=RetryConfig(max_retries=0), ) diff --git a/tests/test_logging_redaction.py b/tests/test_logging_redaction.py new file mode 100644 index 0000000..0993dd9 --- /dev/null +++ b/tests/test_logging_redaction.py @@ -0,0 +1,63 @@ +"""Debug/error logs must never contain request/response payload values. + +Env-var values and tokens are secrets; only structural information +(endpoint, param names, error locations) may be logged. +""" + +import logging + +import pytest +from pydantic import ValidationError + +SECRET = "s3cret-value-do-not-log" +TOKEN = "test-token" + + +@pytest.fixture +def debug_caplog(caplog): + caplog.set_level(logging.DEBUG, logger="codesphere") + return caplog + + +class TestRequestLogging: + async def test_request_body_values_are_not_logged( + self, sdk, mock_api, debug_caplog + ): + mock_api.put("/workspaces/1/env-vars").respond(204) + workspace = await _workspace(sdk, mock_api) + + await workspace.env_vars.set([{"name": "API_KEY", "value": SECRET}]) + + assert SECRET not in debug_caplog.text + assert TOKEN not in debug_caplog.text + + async def test_validation_error_does_not_log_response_body( + self, sdk, mock_api, debug_caplog + ): + # Break the schema so validation fails while the body holds a secret. + mock_api.get("/workspaces/2").respond( + 200, json={"id": "not-an-int", "secretField": SECRET} + ) + + with pytest.raises(ValidationError): + await sdk.workspaces.get(2) + + assert SECRET not in debug_caplog.text + + +async def _workspace(sdk, mock_api, workspace_id: int = 1): + mock_api.get(f"/workspaces/{workspace_id}").respond( + 200, + json={ + "id": workspace_id, + "teamId": 1, + "name": "ws", + "planId": 8, + "isPrivateRepo": True, + "replicas": 1, + "dataCenterId": 1, + "userId": 1, + "restricted": False, + }, + ) + return await sdk.workspaces.get(workspace_id) diff --git a/tests/test_retry.py b/tests/test_retry.py index b0058f0..6e0207b 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,4 +1,4 @@ -"""Opt-in retry behavior (refactor ticket 07), tested against respx.""" +"""Retry behavior (on by default since max_retries=2), tested against respx.""" from unittest.mock import AsyncMock, patch @@ -131,13 +131,34 @@ async def test_exhausted_timeouts_raise_sdk_timeout(self, api, sleep_mock): assert sleep_mock.await_count == 1 -class TestDefaultsAreOff: - async def test_default_config_performs_zero_retries(self, api, sleep_mock): +class TestDefaults: + async def test_default_config_retries_twice(self, api, sleep_mock): route = api.get("/x").respond(503, json={}) async with make_transport() as transport: # no RetryConfig with pytest.raises(APIError): await transport.request("GET", "/x") + assert route.call_count == 3 # initial + 2 retries by default + assert sleep_mock.await_count == 2 + + async def test_default_config_recovers_from_transient_error(self, api, sleep_mock): + route = api.get("/x") + route.side_effect = [ + httpx.Response(429, headers={"Retry-After": "1"}), + httpx.Response(200, json={"ok": True}), + ] + async with make_transport() as transport: # no RetryConfig + response = await transport.request("GET", "/x") + + assert response.status_code == 200 + assert route.call_count == 2 + + async def test_retries_can_be_disabled(self, api, sleep_mock): + route = api.get("/x").respond(503, json={}) + async with make_transport(RetryConfig(max_retries=0)) as transport: + with pytest.raises(APIError): + await transport.request("GET", "/x") + assert route.call_count == 1 sleep_mock.assert_not_awaited() diff --git a/tests/test_surface_parity.py b/tests/test_surface_parity.py new file mode 100644 index 0000000..410795c --- /dev/null +++ b/tests/test_surface_parity.py @@ -0,0 +1,79 @@ +"""Drift guard: the generated sync surface must mirror the async surface. + +Compares every public class of paired async/sync modules: same classes, +same public methods, same parameter lists. If this fails after touching +src/codesphere/_async, run `make sync-gen`; if it still fails, the +unasyncd configuration lost a construct. +""" + +import importlib +import inspect +import pkgutil + +import pytest + +import codesphere._async as async_pkg + +PAIRED_MODULES = sorted( + name + for _, name, _ in pkgutil.walk_packages(async_pkg.__path__, "codesphere._async.") + if not name.endswith("._compat") +) + + +def _sync_name(async_module_name: str) -> str: + return async_module_name.replace("codesphere._async", "codesphere._sync") + + +def _public_classes(module): + return { + name: obj + for name, obj in vars(module).items() + if inspect.isclass(obj) + and not name.startswith("_") + and obj.__module__ == module.__name__ + } + + +def _public_callables(cls): + result = {} + for name, member in inspect.getmembers(cls): + if name.startswith("_"): + continue + if inspect.isfunction(member) or inspect.iscoroutinefunction(member): + result[name] = member + return result + + +def _param_signature(func) -> list[tuple[str, str, object]]: + return [ + (p.name, str(p.kind), p.default) + for p in inspect.signature(func).parameters.values() + ] + + +@pytest.mark.parametrize("async_module_name", PAIRED_MODULES) +def test_module_surface_matches(async_module_name): + async_module = importlib.import_module(async_module_name) + sync_module = importlib.import_module(_sync_name(async_module_name)) + + async_classes = _public_classes(async_module) + sync_classes = _public_classes(sync_module) + + assert set(async_classes) == set(sync_classes), ( + f"class set differs in {async_module_name}" + ) + + for cls_name, async_cls in async_classes.items(): + sync_cls = sync_classes[cls_name] + async_methods = _public_callables(async_cls) + sync_methods = _public_callables(sync_cls) + + assert set(async_methods) == set(sync_methods), ( + f"method set differs on {async_module_name}.{cls_name}" + ) + + for method_name, async_method in async_methods.items(): + assert _param_signature(async_method) == _param_signature( + sync_methods[method_name] + ), f"signature differs: {async_module_name}.{cls_name}.{method_name}" diff --git a/uv.lock b/uv.lock index 8f8f53d..d5220d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [[package]] name = "annotated-types" @@ -25,6 +30,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "bandit" version = "1.9.3" @@ -58,6 +85,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "codesphere" version = "1.0.0" @@ -84,6 +184,11 @@ dev = [ [package.dev-dependencies] dev = [ { name = "respx" }, + { name = "unasyncd" }, +] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, ] [package.metadata] @@ -104,7 +209,14 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "respx", specifier = ">=0.23.1" }] +dev = [ + { name = "respx", specifier = ">=0.23.1" }, + { name = "unasyncd", specifier = ">=0.10.1" }, +] +docs = [ + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" }, +] [[package]] name = "colorama" @@ -175,6 +287,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -239,6 +372,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "libcst" +version = "1.8.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "pyyaml-ft", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/55/ca4552d7fe79a91b2a7b4fa39991e8a45a17c8bfbcaf264597d95903c777/libcst-1.8.5.tar.gz", hash = "sha256:e72e1816eed63f530668e93a4c22ff1cf8b91ddce0ec53e597d3f6c53e103ec7", size = 884582, upload-time = "2025-09-26T05:29:44.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bb/c7abe0654fcf00292d6959256948ce4ae07785c4f65a45c3e25cc4637074/libcst-1.8.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c7733aba7b43239157661207b1e3a9f3711a7fc061a0eca6a33f0716fdfd21", size = 2196690, upload-time = "2025-09-26T05:28:17.839Z" }, + { url = "https://files.pythonhosted.org/packages/49/25/e7c02209e8ce66e7b75a66d132118f6f812a8b03cd31ee7d96de56c733a1/libcst-1.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b8c3cfbbf6049e3c587713652e4b3c88cfbf7df7878b2eeefaa8dd20a48dc607", size = 2082616, upload-time = "2025-09-26T05:28:19.794Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/a4f49d99e3130256e225d639722440ba2682c12812a30ebd7ba64fd0fd31/libcst-1.8.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:31d86025d8997c853f85c4b5d494f04a157fb962e24f187b4af70c7755c9b27d", size = 2229037, upload-time = "2025-09-26T05:28:21.459Z" }, + { url = "https://files.pythonhosted.org/packages/b2/62/4fa21600a0bf3eb9f4d4f8bbb50ef120fb0b2990195eabba997b0b889566/libcst-1.8.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff9c535cfe99f0be79ac3024772b288570751fc69fc472b44fca12d1912d1561", size = 2292806, upload-time = "2025-09-26T05:28:23.033Z" }, + { url = "https://files.pythonhosted.org/packages/14/df/a01e8d54b62060698e37e3e28f77559ecb70c7b93ffee00d17e40221f419/libcst-1.8.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8204607504563d3606bbaea2b9b04e0cef2b3bdc14c89171a702c1e09b9318a", size = 2294836, upload-time = "2025-09-26T05:28:24.937Z" }, + { url = "https://files.pythonhosted.org/packages/75/4f/c410e7f7ceda0558f688c1ca5dfb3a40ff8dfc527f8e6015fa749e11a650/libcst-1.8.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e6cd3df72d47701b205fa3349ba8899566df82cef248c2fdf5f575d640419c4", size = 2396004, upload-time = "2025-09-26T05:28:26.582Z" }, + { url = "https://files.pythonhosted.org/packages/f0/07/bb77dcb94badad0ad3e5a1e992a4318dbdf40632eac3b5cf18299858ad7d/libcst-1.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:197c2f86dd0ca5c6464184ddef7f6440d64c8da39b78d16fc053da6701ed1209", size = 2107301, upload-time = "2025-09-26T05:28:28.235Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/e688e6d99d6920c3f97bf8bbaec33ac2c71a947730772a1d32dd899dbbf1/libcst-1.8.5-cp312-cp312-win_arm64.whl", hash = "sha256:c5ca109c9a81dff3d947dceba635a08f9c3dfeb7f61b0b824a175ef0a98ea69b", size = 1990870, upload-time = "2025-09-26T05:28:29.858Z" }, + { url = "https://files.pythonhosted.org/packages/b0/77/ca1d2499881c774121ebb7c78c22f371c179f18317961e1e529dafc1af52/libcst-1.8.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9e9563dcd754b65557ba9cdff9a5af32cfa5f007be0db982429580db45bfe", size = 2196687, upload-time = "2025-09-26T05:28:31.769Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1c/fdb7c226ad82fcf3b1bb19c24d8e895588a0c1fd2bc81e30792d041e15bc/libcst-1.8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61d56839d237e9bf3310e6479ffaf6659f298940f0e0d2460ce71ee67a5375df", size = 2082639, upload-time = "2025-09-26T05:28:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/c6e89455483355971d13f6d71ad717624686b50558f7e2c12393c2c8e2f1/libcst-1.8.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b084769dcda2036265fc426eec5894c658af8d4b0e0d0255ab6bb78c8c9d6eb4", size = 2229202, upload-time = "2025-09-26T05:28:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/02/9c/3e4ce737a34c0ada15a35f51d0dbd8bf0ac0cef0c4560ddc0a8364e3f712/libcst-1.8.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c20384b8a4a7801b4416ef96173f1fbb7fafad7529edfdf151811ef70423118a", size = 2293220, upload-time = "2025-09-26T05:28:37.201Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/a68fcb3625b0c218c01aaefef9366f505654a1aa64af99cfe7ff7c97bf41/libcst-1.8.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:271b0b363972ff7d2b8116add13977e7c3b2668c7a424095851d548d222dab18", size = 2295146, upload-time = "2025-09-26T05:28:39.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/f4b6edf204f919c6968eb2d111c338098aebbe3fb5d5d95aceacfcf65d9a/libcst-1.8.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ba728c7aee73b330f49f2df0f0b56b74c95302eeb78860f8d5ff0e0fc52c887", size = 2396597, upload-time = "2025-09-26T05:28:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/d0/94/b5cbe122db8f60e7e05bd56743f91d176f3da9b2101f8234e25bb3c5e493/libcst-1.8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0abf0e87570cd3b06a8cafbb5378a9d1cbf12e4583dc35e0fff2255100da55a1", size = 2107479, upload-time = "2025-09-26T05:28:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/4d/5e47752c37b33ea6fd1fac76f62e2caa37a6f78d841338bb8fd3dcf51498/libcst-1.8.5-cp313-cp313-win_arm64.whl", hash = "sha256:757390c3cf0b45d7ae1d1d4070c839b082926e762e65eab144f37a63ad33b939", size = 1990992, upload-time = "2025-09-26T05:28:44.993Z" }, + { url = "https://files.pythonhosted.org/packages/88/df/d0eaaed2c402f945fd049b990c98242cb6eace640258e9f8d484206a9666/libcst-1.8.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f8934763389cd21ce3ed229b63b994b79dac8be7e84a9da144823f46bc1ffc5c", size = 2187746, upload-time = "2025-09-26T05:28:46.946Z" }, + { url = "https://files.pythonhosted.org/packages/19/05/ca62c80dc5f2cf26c2d5d1428612950c6f04df66f765ab0ca8b7d42b4ba1/libcst-1.8.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b873caf04862b6649a2a961fce847f7515ba882be02376a924732cf82c160861", size = 2072530, upload-time = "2025-09-26T05:28:48.451Z" }, + { url = "https://files.pythonhosted.org/packages/1a/38/34a5825bd87badaf8bc0725e5816d395f43ea2f8d1f3cb6982cccc70a1a2/libcst-1.8.5-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:50e095d18c4f76da0e03f25c50b52a2999acbcbe4598a3cf41842ee3c13b54f1", size = 2219819, upload-time = "2025-09-26T05:28:50.328Z" }, + { url = "https://files.pythonhosted.org/packages/74/ea/10407cc1c06231079f5ee6c5e2c2255a2c3f876a7a7f13af734f9bb6ee0e/libcst-1.8.5-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a3c967725cc3e8fa5c7251188d57d48eec8835f44c6b53f7523992bec595fa0", size = 2283011, upload-time = "2025-09-26T05:28:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fc/c4e4c03b4804ac78b8209e83a3c15e449aa68ddd0e602d5c2cc4b7e1b9ed/libcst-1.8.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eed454ab77f4b18100c41d8973b57069e503943ea4e5e5bbb660404976a0fe7a", size = 2283315, upload-time = "2025-09-26T05:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/bb/39/75e07c2933b55815b71b1971e5388a24d1d1475631266251249eaed8af28/libcst-1.8.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:39130e59868b8fa49f6eeedd46f008d3456fc13ded57e1c85b211636eb6425f3", size = 2387279, upload-time = "2025-09-26T05:28:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/04/44/0315fb0f2ee8913d209a5caf57932db8efb3f562dbcdc5fb157de92fb098/libcst-1.8.5-cp313-cp313t-win_amd64.whl", hash = "sha256:a7b1cc3abfdba5ce36907f94f07e079528d4be52c07dfffa26f0e68eb1d25d45", size = 2098827, upload-time = "2025-09-26T05:28:56.877Z" }, + { url = "https://files.pythonhosted.org/packages/45/c2/1335fe9feb7d75526df454a8f9db77615460c69691c27af0a57621ca9e47/libcst-1.8.5-cp313-cp313t-win_arm64.whl", hash = "sha256:20354c4217e87afea936e9ea90c57fe0b2c5651f41b3ee59f5df8a53ab417746", size = 1979853, upload-time = "2025-09-26T05:28:58.408Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/4d961f15e7cc3f9924c4865158cf23de3cb1d9727be5bc5ec1f6b2e0e991/libcst-1.8.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f350ff2867b3075ba97a022de694f2747c469c25099216cef47b58caaee96314", size = 2196843, upload-time = "2025-09-26T05:29:00.64Z" }, + { url = "https://files.pythonhosted.org/packages/47/b5/706b51025218b31346335c8aa1e316e91dbd82b9bd60483a23842a59033b/libcst-1.8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b95db09d04d125619a63f191c9534853656c4c76c303b8b4c5f950c8e610fba", size = 2082306, upload-time = "2025-09-26T05:29:02.498Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/53816b76257d9d149f074ac0b913be1c94d54fb07b3a77f3e11333659d36/libcst-1.8.5-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:60e62e966b45b7dee6f0ec0fd7687704d29be18ae670c5bc6c9c61a12ccf589f", size = 2230603, upload-time = "2025-09-26T05:29:04.123Z" }, + { url = "https://files.pythonhosted.org/packages/a6/06/4497c456ad0ace0f60a38f0935d6e080600532bcddeaf545443d4d7c4db2/libcst-1.8.5-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7cbb330a352dde570059c73af7b7bbfaa84ae121f54d2ce46c5530351f57419d", size = 2293110, upload-time = "2025-09-26T05:29:05.685Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/9ef8cc7c0a9cca722b6f176cc82b5925dbcdfcee6e17cd6d3056d45af38e/libcst-1.8.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71b2b1ef2305cba051252342a1a4f8e94e6b8e95d7693a7c15a00ce8849ef722", size = 2296366, upload-time = "2025-09-26T05:29:07.451Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7e/799dac0cd086cc5dab3837ead9c72dd4e29a79323795dc52b2ebb3aac9a0/libcst-1.8.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0f504d06dfba909d1ba6a4acf60bfe3f22275444d6e0d07e472a5da4a209b0be", size = 2397188, upload-time = "2025-09-26T05:29:09.084Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5c/e4f32439818db04ea43b1d6de1d375dcdd5ff33b828864900c340f26436c/libcst-1.8.5-cp314-cp314-win_amd64.whl", hash = "sha256:c69d2b39e360dea5490ccb5dcf5957dcbb1067d27dc1f3f0787d4e287f7744e2", size = 2183599, upload-time = "2025-09-26T05:29:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f9/a457c3da610aef4b5f5c00f1feb67192594b77fb9dddab8f654161c1ea6f/libcst-1.8.5-cp314-cp314-win_arm64.whl", hash = "sha256:63405cb548b2d7b78531535a7819231e633b13d3dee3eb672d58f0f3322892ca", size = 2071025, upload-time = "2025-09-26T05:29:12.546Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/37abad6fc44df268cd8c2a903ddb2108bd8ac324ef000c2dfcb03d763a41/libcst-1.8.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8a5921105610f35921cc4db6fa5e68e941c6da20ce7f9f93b41b6c66b5481353", size = 2187762, upload-time = "2025-09-26T05:29:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/b4/19/d1118c0b25612a3f50fb2c4b2010562fbf7e7df30ad821bab0aae9cf7e4f/libcst-1.8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:abded10e8d92462fa982d19b064c6f24ed7ead81cf3c3b71011e9764cb12923d", size = 2072565, upload-time = "2025-09-26T05:29:16.37Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/f72515e2774234c4f92909222d762789cc4be2247ed4189bc0639ade1f8c/libcst-1.8.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dd7bdb14545c4b77a6c0eb39c86a76441fe833da800f6ca63e917e1273621029", size = 2219884, upload-time = "2025-09-26T05:29:18.118Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/b267b28cbb0cae19e8c7887cdeda72288ae1020d1c22b6c9955f065b296e/libcst-1.8.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6dc28d33ab8750a84c28b5625f7916846ecbecefd89bf75a5292a35644b6efbd", size = 2282790, upload-time = "2025-09-26T05:29:19.578Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/46f2b01bb6782dbc0f4e917ed029b1236278a5dc6d263e55ee986a83a88e/libcst-1.8.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:970b7164a71c65e13c961965f9677bbbbeb21ce2e7e6655294f7f774156391c4", size = 2283591, upload-time = "2025-09-26T05:29:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ca/3097729b5f6ab1d5e3a753492912d1d8b483a320421d3c0e9e26f1ecef0c/libcst-1.8.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd74c543770e6a61dcb8846c9689dfcce2ad686658896f77f3e21b6ce94bcb2e", size = 2386780, upload-time = "2025-09-26T05:29:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/4fc91968779b70429106797ddb2265a18b0026e17ec6ba805c34427d2fb9/libcst-1.8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3d8e80cd1ed6577166f0bab77357f819f12564c2ed82307612e2bcc93e684d72", size = 2174807, upload-time = "2025-09-26T05:29:24.799Z" }, + { url = "https://files.pythonhosted.org/packages/79/3c/db47e1cf0c98a13cbea2cb5611e7b6913ac5e63845b0e41ee7020b03f523/libcst-1.8.5-cp314-cp314t-win_arm64.whl", hash = "sha256:a026aaa19cb2acd8a4d9e2a215598b0a7e2c194bf4482eb9dec4d781ec6e10b2", size = 2059048, upload-time = "2025-09-26T05:29:28.425Z" }, +] + +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] +dependencies = [ + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -251,6 +515,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -260,6 +587,174 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -278,6 +773,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.3.8" @@ -392,6 +905,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + [[package]] name = "pytest" version = "8.4.0" @@ -434,6 +960,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -469,6 +1007,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "respx" version = "0.23.1" @@ -483,15 +1072,29 @@ wheels = [ [[package]] name = "rich" -version = "14.3.2" +version = "13.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/0e/e5aa3ab6857a16dadac7a970b2e1af21ddf23f03c99248db2c01082090a3/rich-13.6.0.tar.gz", hash = "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef", size = 220315, upload-time = "2023-09-30T14:10:38.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2a/4e62ff633612f746f88618852a626bbe24226eba5e7ac90e91dcfd6a414e/rich-13.6.0-py3-none-any.whl", hash = "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245", size = 239777, upload-time = "2023-09-30T14:10:36.484Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, + { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, ] [[package]] @@ -519,6 +1122,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -537,6 +1149,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "ty" version = "0.0.58" @@ -583,6 +1204,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "unasyncd" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "libcst", version = "1.8.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "libcst", version = "1.8.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "msgspec" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/76/7bced8385217439440e19b81d82779434dc35976aee22f5bca7ab4ebc989/unasyncd-0.10.1.tar.gz", hash = "sha256:c9bc6f9f92e2a632c86d3ff8f2cff3722d44686688d671e356b3e9823c902a11", size = 62597, upload-time = "2026-06-22T14:34:25.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/ef/8a31f1347a76568a0579cc2a3e7775ecf6fdc15c475f2f325897b3c3e9d5/unasyncd-0.10.1-py3-none-any.whl", hash = "sha256:cd88cabc0f5b55d3c43544af5ea4c08f4e9528264a8f31bde09e42afb6a0100e", size = 23434, upload-time = "2026-06-22T14:34:24.002Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "virtualenv" version = "20.31.2" @@ -596,3 +1245,27 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c wheels = [ { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, ] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]