From 29805e1eb76001b56752322cf12720dbe1b5f613 Mon Sep 17 00:00:00 2001 From: David Poblador i Garcia Date: Thu, 2 Apr 2026 12:45:40 +0200 Subject: [PATCH] fix: replace python-dxf with direct httpx for correct Content-Type on manifest push python-dxf's set_manifest doesn't pass through the Content-Type header, causing 400 errors when pushing OCI image index manifests upstream. Switch back to direct httpx calls with proper Content-Type handling. Also fixes HTTP redirect safety (follow_redirects only on GET/HEAD to prevent POST-to-GET downgrade) and adds upper bound to uv_build version. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 7 +- src/switchyard/upstream.py | 177 +++++++++++++++++++------------------ tests/test_integration.py | 163 +++++++++++++++++----------------- tests/test_sync_worker.py | 49 +++++----- tests/test_upstream.py | 123 +++++++++++++++----------- uv.lock | 147 +++--------------------------- 6 files changed, 279 insertions(+), 387 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b61adcb..f06d762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,17 +6,16 @@ requires-python = ">=3.14" dependencies = [ "starlette>=1.0.0", "granian[uvloop]>=2.7.2", + "httpx>=0.28.1", "loguru>=0.7.3", "pydantic-settings>=2.13.1", - "python-dxf>=12.1.1", ] [dependency-groups] dev = [ - "httpx>=0.28.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", - "responses>=0.26.0", + "respx>=0.22.0", "ruff>=0.15.8", ] @@ -27,7 +26,7 @@ asyncio_mode = "auto" switchyard = "switchyard.__main__:main" [build-system] -requires = ["uv_build>=0.7"] +requires = ["uv_build>=0.11.3,<0.12"] build-backend = "uv_build" [tool.ruff] diff --git a/src/switchyard/upstream.py b/src/switchyard/upstream.py index 46e5b89..c6cee9d 100644 --- a/src/switchyard/upstream.py +++ b/src/switchyard/upstream.py @@ -1,128 +1,131 @@ # ABOUTME: Client for communicating with the upstream Docker registry. -# ABOUTME: Uses python-dxf for registry v2 operations, wrapped with asyncio.to_thread. +# ABOUTME: Uses httpx for async registry v2 operations with correct Content-Type handling. from __future__ import annotations -import asyncio -import json from collections.abc import AsyncIterator -import requests.exceptions -from dxf import DXF +import httpx from loguru import logger log = logger.bind(component="upstream") CHUNK_SIZE = 1024 * 1024 # 1MB -_SENTINEL = object() + +# Accept header for manifest pulls, covering both Docker and OCI formats. +_MANIFEST_ACCEPT = ", ".join([ + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", +]) class UpstreamClient: def __init__(self, base_url: str) -> None: self._base_url = base_url.rstrip("/") - if "://" in self._base_url: - self._insecure = self._base_url.startswith("http://") - self._host = self._base_url.split("://", 1)[1] - else: - self._host = self._base_url - self._insecure = False - self._dxf_cache: dict[str, DXF] = {} - - def _get_dxf(self, repo: str) -> DXF: - """Get or create a DXF instance for the given repo.""" - if repo not in self._dxf_cache: - dxf = DXF( - host=self._host, - repo=repo, - insecure=self._insecure, - timeout=300, - ) - dxf.__enter__() - self._dxf_cache[repo] = dxf - return self._dxf_cache[repo] + # Don't set follow_redirects globally: HTTP spec allows 301/302 + # to change POST/PUT to GET, which breaks upload sessions. + self._client = httpx.AsyncClient( + base_url=self._base_url, + timeout=httpx.Timeout(connect=10, read=300, write=300, pool=10), + ) async def close(self) -> None: - for dxf in self._dxf_cache.values(): - dxf.__exit__(None, None, None) - self._dxf_cache.clear() + await self._client.aclose() # -- Blob operations -- async def check_blob(self, name: str, digest: str) -> bool: - dxf = self._get_dxf(name) - - def _check() -> bool: - try: - dxf.blob_size(digest) - return True - except requests.exceptions.HTTPError: - return False - - return await asyncio.to_thread(_check) + resp = await self._client.head( + f"/v2/{name}/blobs/{digest}", follow_redirects=True + ) + return resp.status_code == 200 async def pull_blob(self, name: str, digest: str) -> AsyncIterator[bytes]: - dxf = self._get_dxf(name) - chunks = await asyncio.to_thread(dxf.pull_blob, digest, chunk_size=CHUNK_SIZE) - while True: - chunk = await asyncio.to_thread(next, chunks, _SENTINEL) - if chunk is _SENTINEL: - break - yield chunk + async with self._client.stream( + "GET", f"/v2/{name}/blobs/{digest}", follow_redirects=True + ) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(chunk_size=CHUNK_SIZE): + yield chunk async def push_blob(self, name: str, digest: str, data: bytes) -> None: - """Push a blob using monolithic upload.""" - dxf = self._get_dxf(name) - await asyncio.to_thread(dxf.push_blob, data=iter([data]), digest=digest) + """Push a blob using monolithic upload (POST + PUT).""" + if await self.check_blob(name, digest): + log.debug("Blob {} already exists upstream, skipping", digest[:19]) + return + + resp = await self._client.post(f"/v2/{name}/blobs/uploads/") + resp.raise_for_status() + location = resp.headers["Location"] + + resp = await self._client.put( + location, + content=data, + params={"digest": digest}, + headers={"Content-Type": "application/octet-stream"}, + ) + resp.raise_for_status() log.debug("Pushed blob {} upstream", digest[:19]) async def push_blob_streaming( self, name: str, digest: str, stream: AsyncIterator[bytes] ) -> None: - """Push a blob by collecting the stream and uploading.""" - chunks = [chunk async for chunk in stream] - dxf = self._get_dxf(name) - await asyncio.to_thread(dxf.push_blob, data=iter(chunks), digest=digest) + """Push a blob by streaming from local storage.""" + if await self.check_blob(name, digest): + log.debug("Blob {} already exists upstream, skipping", digest[:19]) + return + + resp = await self._client.post(f"/v2/{name}/blobs/uploads/") + resp.raise_for_status() + location = resp.headers["Location"] + + async def _body() -> AsyncIterator[bytes]: + async for chunk in stream: + yield chunk + + resp = await self._client.put( + location, + content=_body(), + params={"digest": digest}, + headers={"Content-Type": "application/octet-stream"}, + ) + resp.raise_for_status() log.debug("Pushed blob {} upstream (streamed)", digest[:19]) # -- Manifest operations -- async def check_manifest(self, name: str, reference: str) -> bool: - dxf = self._get_dxf(name) - - def _check() -> bool: - try: - dxf.head_manifest_and_response(reference) - return True - except requests.exceptions.HTTPError: - return False - - return await asyncio.to_thread(_check) - - async def pull_manifest(self, name: str, reference: str) -> tuple[bytes, str, str] | None: + resp = await self._client.head( + f"/v2/{name}/manifests/{reference}", follow_redirects=True + ) + return resp.status_code == 200 + + async def pull_manifest( + self, name: str, reference: str + ) -> tuple[bytes, str, str] | None: """Pull a manifest. Returns (body, content_type, digest) or None.""" - dxf = self._get_dxf(name) - - def _pull() -> tuple[bytes, str, str] | None: - try: - manifest_str, resp = dxf.get_manifest_and_response(reference) - body = manifest_str.encode() if isinstance(manifest_str, str) else manifest_str - content_type = resp.headers.get("content-type", "application/json") - digest = resp.headers.get("docker-content-digest", "") - return body, content_type, digest - except requests.exceptions.HTTPError as exc: - if exc.response is not None and exc.response.status_code == 404: - return None - raise - - return await asyncio.to_thread(_pull) + resp = await self._client.get( + f"/v2/{name}/manifests/{reference}", + headers={"Accept": _MANIFEST_ACCEPT}, + follow_redirects=True, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + + body = resp.content + content_type = resp.headers.get("content-type", "application/json") + digest = resp.headers.get("docker-content-digest", "") + return body, content_type, digest async def push_manifest( self, name: str, reference: str, data: bytes, content_type: str ) -> None: - dxf = self._get_dxf(name) - manifest_json = data.decode() if isinstance(data, bytes) else data - parsed = json.loads(manifest_json) - if "mediaType" not in parsed: - parsed["mediaType"] = content_type - manifest_json = json.dumps(parsed) - await asyncio.to_thread(dxf.set_manifest, reference, manifest_json) + resp = await self._client.put( + f"/v2/{name}/manifests/{reference}", + content=data, + headers={"Content-Type": content_type}, + ) + resp.raise_for_status() log.debug("Pushed manifest {name}:{ref} upstream", name=name, ref=reference) diff --git a/tests/test_integration.py b/tests/test_integration.py index f9ea59c..4ee50dc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,7 +9,8 @@ from contextlib import asynccontextmanager from pathlib import Path -import responses +import respx +from httpx import Response from starlette.applications import Starlette from starlette.routing import Route from starlette.testclient import TestClient @@ -78,15 +79,17 @@ def _make_manifest(config_digest: str, layer_digests: list[str]) -> bytes: ).encode() +BASE = "https://central:5000" + + class TestFullPushSyncCycle: """Push an image locally, sync it to a mock upstream, verify everything.""" - @responses.activate def test_push_and_sync(self, tmp_path: Path) -> None: storage = Storage(str(tmp_path)) queue = SyncQueue(str(tmp_path)) - settings = Settings(data_dir=str(tmp_path), upstream="https://central:5000") - upstream = UpstreamClient("https://central:5000") + settings = Settings(data_dir=str(tmp_path), upstream=BASE) + upstream = UpstreamClient(BASE) app = _make_app_with_upstream(storage, queue, settings, upstream) with TestClient(app) as client: @@ -127,59 +130,54 @@ def test_push_and_sync(self, tmp_path: Path) -> None: assert len(markers) == 1 # 5. Mock upstream and run sync - # HEAD checks for both blobs (config + layer) - responses.add( - responses.HEAD, - url="https://central:5000/v2/myapp/blobs/" + config_digest, - status=404, - ) - responses.add( - responses.HEAD, - url="https://central:5000/v2/myapp/blobs/" + layer_digest, - status=404, - ) - # POST to initiate upload for each blob - responses.add( - responses.POST, - url="https://central:5000/v2/myapp/blobs/uploads/", - status=202, - headers={"Location": "https://central:5000/v2/myapp/blobs/uploads/u1"}, - ) - responses.add( - responses.POST, - url="https://central:5000/v2/myapp/blobs/uploads/", - status=202, - headers={"Location": "https://central:5000/v2/myapp/blobs/uploads/u2"}, - ) - # PUT to complete each blob upload - responses.add( - responses.PUT, url="https://central:5000/v2/myapp/blobs/uploads/u1", status=201 - ) - responses.add( - responses.PUT, url="https://central:5000/v2/myapp/blobs/uploads/u2", status=201 - ) - # PUT manifest - responses.add( - responses.PUT, url="https://central:5000/v2/myapp/manifests/latest", status=201 - ) - - async def _run_sync() -> None: - await storage.init() - await queue.init() - pending = await queue.list_pending() - assert len(pending) == 1 - await sync_one(pending[0], storage, queue, upstream) - await upstream.close() - - asyncio.run(_run_sync()) - - # Verify: 2 blob HEAD checks + 2 blob uploads (POST+PUT each) + 1 manifest PUT - head_calls = [c for c in responses.calls if c.request.method == "HEAD"] - post_calls = [c for c in responses.calls if c.request.method == "POST"] - put_calls = [c for c in responses.calls if c.request.method == "PUT"] - assert len(head_calls) == 2 # config + layer - assert len(post_calls) == 2 # config + layer upload initiation - assert len(put_calls) == 3 # config + layer upload completion + manifest + with respx.mock: + # HEAD checks for both blobs (config + layer) + respx.head(f"{BASE}/v2/myapp/blobs/{config_digest}").mock( + return_value=Response(404) + ) + respx.head(f"{BASE}/v2/myapp/blobs/{layer_digest}").mock( + return_value=Response(404) + ) + # POST to initiate upload for each blob + respx.post(f"{BASE}/v2/myapp/blobs/uploads/").mock( + side_effect=[ + Response( + 202, headers={"Location": f"{BASE}/v2/myapp/blobs/uploads/u1"} + ), + Response( + 202, headers={"Location": f"{BASE}/v2/myapp/blobs/uploads/u2"} + ), + ] + ) + # PUT to complete each blob upload + respx.put(f"{BASE}/v2/myapp/blobs/uploads/u1").mock( + return_value=Response(201) + ) + respx.put(f"{BASE}/v2/myapp/blobs/uploads/u2").mock( + return_value=Response(201) + ) + # PUT manifest + respx.put(f"{BASE}/v2/myapp/manifests/latest").mock( + return_value=Response(201) + ) + + async def _run_sync() -> None: + await storage.init() + await queue.init() + pending = await queue.list_pending() + assert len(pending) == 1 + await sync_one(pending[0], storage, queue, upstream) + await upstream.close() + + asyncio.run(_run_sync()) + + # Verify: 2 blob HEAD checks + 2 blob uploads (POST+PUT each) + 1 manifest PUT + head_calls = [c for c in respx.calls if c.request.method == "HEAD"] + post_calls = [c for c in respx.calls if c.request.method == "POST"] + put_calls = [c for c in respx.calls if c.request.method == "PUT"] + assert len(head_calls) == 2 # config + layer + assert len(post_calls) == 2 # config + layer upload initiation + assert len(put_calls) == 3 # config + layer upload completion + manifest # Marker should be cleared markers = list(pending_dir.glob("*.json")) @@ -189,38 +187,39 @@ async def _run_sync() -> None: class TestPullProxyFromUpstream: """Pull an image that only exists on the upstream registry.""" - @responses.activate def test_pull_manifest_from_upstream(self, tmp_path: Path) -> None: storage = Storage(str(tmp_path)) queue = SyncQueue(str(tmp_path)) - settings = Settings(data_dir=str(tmp_path), upstream="https://central:5000") - upstream = UpstreamClient("https://central:5000") + settings = Settings(data_dir=str(tmp_path), upstream=BASE) + upstream = UpstreamClient(BASE) app = _make_app_with_upstream(storage, queue, settings, upstream) manifest_body = b'{"schemaVersion": 2}' manifest_ct = "application/vnd.docker.distribution.manifest.v2+json" manifest_digest = f"sha256:{hashlib.sha256(manifest_body).hexdigest()}" - responses.get( - "https://central:5000/v2/remote-app/manifests/latest", - body=manifest_body, - status=200, - headers={ - "Content-Type": manifest_ct, - "Docker-Content-Digest": manifest_digest, - }, - ) - - with TestClient(app) as client: - # Pull manifest that doesn't exist locally - resp = client.get("/v2/remote-app/manifests/latest") - assert resp.status_code == 200 - assert resp.content == manifest_body - assert resp.headers["Docker-Content-Digest"] == manifest_digest + with respx.mock: + respx.get(f"{BASE}/v2/remote-app/manifests/latest").mock( + return_value=Response( + 200, + content=manifest_body, + headers={ + "Content-Type": manifest_ct, + "Docker-Content-Digest": manifest_digest, + }, + ) + ) - # Second pull should be served from local cache (no more upstream calls) - resp = client.get("/v2/remote-app/manifests/latest") - assert resp.status_code == 200 - assert resp.content == manifest_body - # Only 1 call to upstream (first pull), second served from cache - assert len(responses.calls) == 1 + with TestClient(app) as client: + # Pull manifest that doesn't exist locally + resp = client.get("/v2/remote-app/manifests/latest") + assert resp.status_code == 200 + assert resp.content == manifest_body + assert resp.headers["Docker-Content-Digest"] == manifest_digest + + # Second pull should be served from local cache (no more upstream calls) + resp = client.get("/v2/remote-app/manifests/latest") + assert resp.status_code == 200 + assert resp.content == manifest_body + # Only 1 call to upstream (first pull), second served from cache + assert len(respx.calls) == 1 diff --git a/tests/test_sync_worker.py b/tests/test_sync_worker.py index cdbc943..eb42426 100644 --- a/tests/test_sync_worker.py +++ b/tests/test_sync_worker.py @@ -6,13 +6,16 @@ import json from pathlib import Path -import responses +import respx +from httpx import Response from switchyard.storage import Storage from switchyard.sync_queue import SyncQueue from switchyard.sync_worker import _extract_blob_digests, sync_one from switchyard.upstream import UpstreamClient +BASE = "https://central:5000" + def _make_manifest(layer_digests: list[str], config_digest: str = "") -> bytes: manifest: dict[str, object] = {"schemaVersion": 2} @@ -43,7 +46,7 @@ def test_extract_blob_digests_invalid_json() -> None: assert _extract_blob_digests(b"not json") == [] -@responses.activate +@respx.mock async def test_sync_one_pushes_blobs_and_manifest(tmp_path: Path) -> None: storage = Storage(str(tmp_path)) await storage.init() @@ -68,19 +71,20 @@ async def test_sync_one_pushes_blobs_and_manifest(tmp_path: Path) -> None: assert len(pending) == 1 # Mock upstream: HEAD returns 404 (blob not there), POST+PUT for upload, PUT for manifest - responses.add( - responses.HEAD, url="https://central:5000/v2/myapp/blobs/" + blob_digest, status=404 + respx.head(f"{BASE}/v2/myapp/blobs/{blob_digest}").mock( + return_value=Response(404) + ) + respx.post(f"{BASE}/v2/myapp/blobs/uploads/").mock( + return_value=Response(202, headers={"Location": f"{BASE}/v2/myapp/blobs/uploads/u1"}) ) - responses.add( - responses.POST, - url="https://central:5000/v2/myapp/blobs/uploads/", - status=202, - headers={"Location": "https://central:5000/v2/myapp/blobs/uploads/u1"}, + respx.put(f"{BASE}/v2/myapp/blobs/uploads/u1").mock( + return_value=Response(201) + ) + respx.put(f"{BASE}/v2/myapp/manifests/latest").mock( + return_value=Response(201) ) - responses.add(responses.PUT, url="https://central:5000/v2/myapp/blobs/uploads/u1", status=201) - responses.add(responses.PUT, url="https://central:5000/v2/myapp/manifests/latest", status=201) - upstream = UpstreamClient("https://central:5000") + upstream = UpstreamClient(BASE) await sync_one(pending[0], storage, queue, upstream) await upstream.close() @@ -89,7 +93,7 @@ async def test_sync_one_pushes_blobs_and_manifest(tmp_path: Path) -> None: assert len(remaining) == 0 -@responses.activate +@respx.mock async def test_sync_one_skips_existing_blobs(tmp_path: Path) -> None: storage = Storage(str(tmp_path)) await storage.init() @@ -108,25 +112,24 @@ async def test_sync_one_skips_existing_blobs(tmp_path: Path) -> None: await queue.enqueue("myapp", "v1") # Blob already exists upstream - responses.add( - responses.HEAD, - url="https://central:5000/v2/myapp/blobs/" + blob_digest, - status=200, - headers={"Content-Length": str(len(blob_data))}, + respx.head(f"{BASE}/v2/myapp/blobs/{blob_digest}").mock( + return_value=Response(200, headers={"Content-Length": str(len(blob_data))}) + ) + respx.put(f"{BASE}/v2/myapp/manifests/v1").mock( + return_value=Response(201) ) - responses.add(responses.PUT, url="https://central:5000/v2/myapp/manifests/v1", status=201) - upstream = UpstreamClient("https://central:5000") + upstream = UpstreamClient(BASE) pending = await queue.list_pending() await sync_one(pending[0], storage, queue, upstream) await upstream.close() # Should not have attempted POST for upload (blob exists) - post_calls = [c for c in responses.calls if c.request.method == "POST"] + post_calls = [c for c in respx.calls if c.request.method == "POST"] assert len(post_calls) == 0 -@responses.activate +@respx.mock async def test_sync_one_missing_manifest(tmp_path: Path) -> None: storage = Storage(str(tmp_path)) await storage.init() @@ -136,7 +139,7 @@ async def test_sync_one_missing_manifest(tmp_path: Path) -> None: await queue.enqueue("ghost", "latest") pending = await queue.list_pending() - upstream = UpstreamClient("https://central:5000") + upstream = UpstreamClient(BASE) await sync_one(pending[0], storage, queue, upstream) await upstream.close() diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 28b123d..a2a4997 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -1,107 +1,123 @@ # ABOUTME: Tests for the upstream registry client. -# ABOUTME: Uses responses to mock HTTP calls made by the python-dxf library. +# ABOUTME: Uses respx to mock httpx calls to the Docker registry API. from __future__ import annotations import json -import responses +import respx +from httpx import Response from switchyard.upstream import UpstreamClient +BASE = "https://central:5000" -@responses.activate + +@respx.mock async def test_check_blob_exists() -> None: - responses.head( - "https://central:5000/v2/myapp/blobs/sha256:abc123", - status=200, - headers={"Content-Length": "42"}, + respx.head(f"{BASE}/v2/myapp/blobs/sha256:abc123").mock( + return_value=Response(200, headers={"Content-Length": "42"}) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) assert await client.check_blob("myapp", "sha256:abc123") await client.close() -@responses.activate +@respx.mock async def test_check_blob_missing() -> None: - responses.head( - "https://central:5000/v2/myapp/blobs/sha256:abc123", - status=404, + respx.head(f"{BASE}/v2/myapp/blobs/sha256:abc123").mock( + return_value=Response(404) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) assert not await client.check_blob("myapp", "sha256:abc123") await client.close() -@responses.activate +@respx.mock async def test_push_blob_skips_existing() -> None: - responses.head( - "https://central:5000/v2/myapp/blobs/sha256:abc123", - status=200, - headers={"Content-Length": "42"}, + route = respx.head(f"{BASE}/v2/myapp/blobs/sha256:abc123").mock( + return_value=Response(200, headers={"Content-Length": "42"}) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) await client.push_blob("myapp", "sha256:abc123", b"data") # Should only HEAD, no POST/PUT - assert len(responses.calls) == 1 + assert route.call_count == 1 await client.close() -@responses.activate +@respx.mock async def test_push_blob_uploads() -> None: - responses.head( - "https://central:5000/v2/myapp/blobs/sha256:abc123", - status=404, + respx.head(f"{BASE}/v2/myapp/blobs/sha256:abc123").mock( + return_value=Response(404) ) - responses.post( - "https://central:5000/v2/myapp/blobs/uploads/", - status=202, - headers={"Location": "https://central:5000/v2/myapp/blobs/uploads/uuid-1"}, + respx.post(f"{BASE}/v2/myapp/blobs/uploads/").mock( + return_value=Response(202, headers={"Location": f"{BASE}/v2/myapp/blobs/uploads/uuid-1"}) ) - responses.put( - url="https://central:5000/v2/myapp/blobs/uploads/uuid-1", - status=201, + respx.put(f"{BASE}/v2/myapp/blobs/uploads/uuid-1").mock( + return_value=Response(201) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) await client.push_blob("myapp", "sha256:abc123", b"blob data") - assert len(responses.calls) == 3 # HEAD + POST + PUT + assert respx.calls.call_count == 3 # HEAD + POST + PUT await client.close() -@responses.activate +@respx.mock async def test_push_manifest() -> None: manifest = json.dumps({ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", }) - responses.put( - "https://central:5000/v2/myapp/manifests/latest", - status=201, + route = respx.put(f"{BASE}/v2/myapp/manifests/latest").mock( + return_value=Response(201) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) await client.push_manifest( "myapp", "latest", manifest.encode(), "application/vnd.docker.distribution.manifest.v2+json", ) - assert len(responses.calls) == 1 + assert route.call_count == 1 + # Verify Content-Type was set correctly + assert route.calls[0].request.headers["content-type"] == ( + "application/vnd.docker.distribution.manifest.v2+json" + ) + await client.close() + + +@respx.mock +async def test_push_manifest_oci_index() -> None: + """Verify OCI image index content type is passed through correctly.""" + ct = "application/vnd.oci.image.index.v1+json" + manifest = json.dumps({ + "schemaVersion": 2, + "mediaType": ct, + "manifests": [], + }) + route = respx.put(f"{BASE}/v2/myapp/manifests/latest").mock( + return_value=Response(201) + ) + client = UpstreamClient(BASE) + await client.push_manifest("myapp", "latest", manifest.encode(), ct) + assert route.calls[0].request.headers["content-type"] == ct await client.close() -@responses.activate +@respx.mock async def test_pull_manifest() -> None: body = json.dumps({"schemaVersion": 2}) - responses.get( - "https://central:5000/v2/myapp/manifests/latest", - body=body, - status=200, - headers={ - "Content-Type": "application/vnd.docker.distribution.manifest.v2+json", - "Docker-Content-Digest": "sha256:abc", - }, + respx.get(f"{BASE}/v2/myapp/manifests/latest").mock( + return_value=Response( + 200, + content=body.encode(), + headers={ + "Content-Type": "application/vnd.docker.distribution.manifest.v2+json", + "Docker-Content-Digest": "sha256:abc", + }, + ) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) result = await client.pull_manifest("myapp", "latest") assert result is not None manifest_body, ct, digest = result @@ -111,13 +127,12 @@ async def test_pull_manifest() -> None: await client.close() -@responses.activate +@respx.mock async def test_pull_manifest_not_found() -> None: - responses.get( - "https://central:5000/v2/myapp/manifests/missing", - status=404, + respx.get(f"{BASE}/v2/myapp/manifests/missing").mock( + return_value=Response(404) ) - client = UpstreamClient("https://central:5000") + client = UpstreamClient(BASE) result = await client.pull_manifest("myapp", "missing") assert result is None await client.close() diff --git a/uv.lock b/uv.lock index 5c31a1a..e3ae552 100644 --- a/uv.lock +++ b/uv.lock @@ -32,47 +32,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - [[package]] name = "click" version = "8.3.1" @@ -331,72 +290,15 @@ wheels = [ ] [[package]] -name = "python-dxf" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "tqdm" }, - { name = "www-authenticate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/55/4b4fd31f5e5f7dc559a36c75e98b37670d6ddbd2b6b259e3a2bc8694cc2f/python_dxf-12.1.1.tar.gz", hash = "sha256:2f5fd883599f8553872e1f7a67b8d278744ab574f55feb9790c93a347520dd9d", size = 26075, upload-time = "2025-05-05T14:18:57.02Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/df/dcf709dcda1d91161cf61aa1135f96edeffe85cb1824f4df71b1fd6b3333/python_dxf-12.1.1-py3-none-any.whl", hash = "sha256:9bcf81cdac5600102fc5280b51323c35732dbb8913ae97ee4c04b2a66e543943", size = 18024, upload-time = "2025-05-05T14:18:55.203Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "requests" -version = "2.33.1" +name = "respx" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "responses" -version = "0.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, - { name = "requests" }, - { name = "urllib3" }, + { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, ] [[package]] @@ -438,55 +340,41 @@ wheels = [ [[package]] name = "switchyard" -version = "0.1.4" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "granian", extra = ["uvloop"] }, + { name = "httpx" }, { name = "loguru" }, { name = "pydantic-settings" }, - { name = "python-dxf" }, { name = "starlette" }, ] [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "responses" }, + { name = "respx" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "granian", extras = ["uvloop"], specifier = ">=2.7.2" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, - { name = "python-dxf", specifier = ">=12.1.1" }, { name = "starlette", specifier = ">=1.0.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "responses", specifier = ">=0.26.0" }, + { name = "respx", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.15.8" }, ] -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -508,15 +396,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - [[package]] name = "uvloop" version = "0.22.1" @@ -545,9 +424,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] - -[[package]] -name = "www-authenticate" -version = "0.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/2d/5567291a8274ef5d9b6495a1ec341394ab68933e2396936755b157f87b43/www-authenticate-0.9.2.tar.gz", hash = "sha256:cf75fc2ea5effb0f9342d7de7619b736f2a7d4b223331a53e296863a286e9dcb", size = 2414, upload-time = "2015-08-05T08:18:38.686Z" }