feat: release proxmoxer-compatible 0.1.0 slice
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ RUN pip install --upgrade "pip>=25.1,<26" && pip install .
|
|||||||
|
|
||||||
FROM python:3.13-slim-bookworm AS runtime
|
FROM python:3.13-slim-bookworm AS runtime
|
||||||
|
|
||||||
ARG APP_VERSION=0.0.1
|
ARG APP_VERSION=0.1.0
|
||||||
LABEL org.opencontainers.image.title="proxmox-api-simulator" \
|
LABEL org.opencontainers.image.title="proxmox-api-simulator" \
|
||||||
org.opencontainers.image.version="$APP_VERSION" \
|
org.opencontainers.image.version="$APP_VERSION" \
|
||||||
org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator"
|
org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator"
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
Stateful asynchronous Proxmox VE API simulator for testing API clients and
|
Stateful asynchronous Proxmox VE API simulator for testing API clients and
|
||||||
infrastructure tooling without a real hypervisor cluster.
|
infrastructure tooling without a real hypervisor cluster.
|
||||||
|
|
||||||
The runnable foundation and authoritative contract toolchain are implemented.
|
Release 0.1.0 provides a deliberately narrow, stateful vertical slice backed by
|
||||||
Imported methods can be registered dynamically, but no stateful Proxmox method
|
the authoritative imported PVE 9.2.3 contract. Compatibility claims and known
|
||||||
is claimed as compatible yet; the vertical slice is tracked in
|
limits are recorded in [the 0.1.0 compatibility report](docs/compatibility-0.1.0.md).
|
||||||
[the implementation plan](docs/implementation-plan.md).
|
|
||||||
|
|
||||||
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
|
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
|
||||||
Implemented semantics currently include version, ticket login, node listing and
|
Implemented semantics currently include version, ticket login, node listing and
|
||||||
@@ -24,7 +23,9 @@ make install
|
|||||||
make ci
|
make ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Local services use plain HTTP at this stage:
|
Local services expose internal HTTP on port 8006 and a development-only HTTPS
|
||||||
|
gateway on port 8007. The checked-in certificate and key are disposable local
|
||||||
|
test credentials and must never be used in production:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -38,6 +39,27 @@ curl -X POST -d 'username=root@pam&password=secret' \
|
|||||||
http://localhost:8006/api2/json/access/ticket
|
http://localhost:8006/api2/json/access/ticket
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Unmodified proxmoxer 2.3 clients use the HTTPS gateway:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from proxmoxer import ProxmoxAPI
|
||||||
|
|
||||||
|
proxmox = ProxmoxAPI(
|
||||||
|
"localhost",
|
||||||
|
port=8007,
|
||||||
|
user="root@pam",
|
||||||
|
password="secret",
|
||||||
|
verify_ssl=False, # local self-signed development certificate
|
||||||
|
)
|
||||||
|
print(proxmox.version.get())
|
||||||
|
print(proxmox.nodes("pve1").qemu.get())
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the external-client smoke flow against the Compose network with
|
||||||
|
`PROXMOXER_HOST=tls-gateway`, `PROXMOXER_PORT=8443`, and pytest marker
|
||||||
|
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
|
||||||
|
completion.
|
||||||
|
|
||||||
Database migrations are ordered SQL files applied transactionally and recorded
|
Database migrations are ordered SQL files applied transactionally and recorded
|
||||||
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
||||||
applied migration is rejected instead of silently drifting the schema.
|
applied migration is rejected instead of silently drifting the schema.
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ def create_app(
|
|||||||
resolved_workers = (task_worker,)
|
resolved_workers = (task_worker,)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=resolved.app_name,
|
title=resolved.app_name,
|
||||||
version="0.0.1",
|
version="0.1.0",
|
||||||
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
|
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
|
||||||
)
|
)
|
||||||
app.state.settings = resolved
|
app.state.settings = resolved
|
||||||
|
|||||||
+10
-1
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -10,6 +11,7 @@ from typing import Any
|
|||||||
from app.tasks.repository import Task, TaskRepository
|
from app.tasks.repository import Task, TaskRepository
|
||||||
|
|
||||||
TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]]
|
TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]]
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -31,7 +33,14 @@ class TaskWorker:
|
|||||||
if len(self._running) >= self.concurrency:
|
if len(self._running) >= self.concurrency:
|
||||||
await asyncio.sleep(self.poll_seconds)
|
await asyncio.sleep(self.poll_seconds)
|
||||||
continue
|
continue
|
||||||
task = await self.repository.claim(self.worker_id, self.lease_seconds)
|
try:
|
||||||
|
task = await self.repository.claim(self.worker_id, self.lease_seconds)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("task claim failed; polling will retry")
|
||||||
|
await asyncio.sleep(self.poll_seconds)
|
||||||
|
continue
|
||||||
if task is None:
|
if task is None:
|
||||||
await asyncio.sleep(self.poll_seconds)
|
await asyncio.sleep(self.poll_seconds)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -41,5 +41,24 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
tls-gateway:
|
||||||
|
image: nginx:1.28.0-alpine
|
||||||
|
depends_on:
|
||||||
|
simulator:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8007:8443"
|
||||||
|
volumes:
|
||||||
|
- ./docker/tls/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
|
||||||
|
- ./docker/tls/server.key:/etc/nginx/tls/server.key:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
events {}
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 8443 ssl;
|
||||||
|
server_name localhost;
|
||||||
|
ssl_certificate /etc/nginx/tls/server.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/server.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://simulator:8006;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Request-ID $request_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICyTCCAbGgAwIBAgIJAIbJhnhVx8uWMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||||
|
BAMMCWxvY2FsaG9zdDAeFw0yNjA3MTIyMTQyNTFaFw0zNjA3MDkyMTQyNTFaMBQx
|
||||||
|
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
|
||||||
|
ggEBALscj7/1WDybjz8x01EvUFVemov6zkezOwfsOXKVyEOnOTxPjWruzDYnB8y6
|
||||||
|
NH/5PojUns7GB1kuRhZWUXGY0FG/sSgF0X9nwEHoby8ekju2F55NUzzpu9BfM2AU
|
||||||
|
S17S8h5Oxc4Qi6d9RoeRG25YmMywPCyp2SMnuu14w55KTAt7Ir7mbTAv8ZIMbVhq
|
||||||
|
34tH45ONQvGftN4JNvwZr7Uf+EuupWsnILfkz1Cw1cj88adDZHwxE7Hkx7TiQP6o
|
||||||
|
DPDeg+XYH0vB2HR25JSP9z0uyeeF6n6cExgfwVZy2una7jQp887N5xLgTUGlnmFM
|
||||||
|
y1z2AO2+Mw1Lh2UC/OQrp9T1ztMCAwEAAaMeMBwwGgYDVR0RBBMwEYIJbG9jYWxo
|
||||||
|
b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCICPRCT+m+EKHkaWG2eY2AqQ7a
|
||||||
|
24Bd60ZsZxJNAloXAd1X8cedz5yq0rm9pqF5Fq883dysgCVSDylwqy4YzllhTWsy
|
||||||
|
+M3TE85ZyKKi6S7kR7Z0Exf0I4S7G9zTtrzEXn9kco1q5g/jE7aQi2E2z5poaIg+
|
||||||
|
TlUCq5IePsS6gZCvzXPgU1mJ5dQFlqsOW6Lk1mOCjmKT2SaF4eL2hleatqHv667c
|
||||||
|
fJWYLotjAJoVQKrjItGeHXPosZEW5g17gFD88XZRMlUx5xN5/ioaKmLiyI28aNF3
|
||||||
|
nNaukGC/N5fPsgghb0wkYmPHh+dFE/1uMIq0cOzNcyG6ZQkQzikiYhytWsOu
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7HI+/9Vg8m48/
|
||||||
|
MdNRL1BVXpqL+s5HszsH7DlylchDpzk8T41q7sw2JwfMujR/+T6I1J7OxgdZLkYW
|
||||||
|
VlFxmNBRv7EoBdF/Z8BB6G8vHpI7theeTVM86bvQXzNgFEte0vIeTsXOEIunfUaH
|
||||||
|
kRtuWJjMsDwsqdkjJ7rteMOeSkwLeyK+5m0wL/GSDG1Yat+LR+OTjULxn7TeCTb8
|
||||||
|
Ga+1H/hLrqVrJyC35M9QsNXI/PGnQ2R8MROx5Me04kD+qAzw3oPl2B9Lwdh0duSU
|
||||||
|
j/c9Lsnnhep+nBMYH8FWctrp2u40KfPOzecS4E1BpZ5hTMtc9gDtvjMNS4dlAvzk
|
||||||
|
K6fU9c7TAgMBAAECggEBAINmo2zjF3w4onh2vTgeSgQp087J62Ne8u21bwKRPXqF
|
||||||
|
TSSVmXKnELJW5ptXiNb2anwdFQmQ+EggvwegxsFH18QRIpBAxcb7TYD7gllM1tUo
|
||||||
|
I54AH5x/aG4E7Udj+So2aeHu3+q+o9STnZxGw0TS4zub6CZVgS+3DwcF8BqRgqXs
|
||||||
|
NuDIIJWosuchbb3DdlPygRajiN2teJtNfw9rcLfC4BY5i4y/H7RMpklM5VkTXiGc
|
||||||
|
NxyG4qkdHP0jlL9Z9wRa859uYeb7kVm+vhfgUXMbiRn9FcxrROOpPIwTAv76Y5nu
|
||||||
|
4EF/s0TPC+ei7hjCpN1WK2/n6dgiVYBVBUpWHalZBIECgYEA7+E2T5PlKuT7Kugs
|
||||||
|
qx+CHvZXm2hZ9NVDYS6gNAZt6kr5enbb9rzCF/U+jx14COPyGcoJeDOUW1yZGTgH
|
||||||
|
98JkEEHB6fgSPAU3pp2aMslMRNTZqfM0vL+BRpJT+fPbI9y8WpMzm/NpmnZZK8rh
|
||||||
|
xLbg+xAa7iMltscCcY2uD8NmhKkCgYEAx6+Ma5WQ0Enmju+XUANrOuKDN9aTXXc6
|
||||||
|
iqlqtXfadc/Lc6E+lzSxRm5t95t+6AX2mYsNOWsuWRCBqHMFDxg31moYEeBQvQW9
|
||||||
|
kwJQ5JsmOSCzMfDPUrQHihaq9xwxhoBxJXIRs3JlYm/nty8LO959R1V0IrHsPznH
|
||||||
|
BVs7pbAo6RsCgYEAyvK4t3UCI1tdoPyTpiffOADlN+d+jCTOf+8pvTpfTiUmk1Ty
|
||||||
|
XvtuH0TvK7gb8TGhh+4mOtswvmdGZE7CdvyxGgv4WtH142/qmH2okyU58NZAXYgV
|
||||||
|
a0d+wU1V3RhSpDHB7cOym1PCWdudL+7TOlIbYG5MyoNUCiKvT5E13cJM/xkCgYAC
|
||||||
|
WWNahKjuemAXAGSUUWX6jF2k04ZqTBPJO9MAjYdpaWdoVdZJqxoGzRfIGPE2Q5Oy
|
||||||
|
HLusGEG0VIhh9fByTAOkJx1fYHcyshWX3CgdeGHLvEG/bajSvUF1c2zReWhvv6UV
|
||||||
|
HrFsngTpUo20Tv5f1u88Xpn+Kn+wArr/qiIagecJTwKBgDPEa71fqt7WyjHCNuIm
|
||||||
|
hJeBCIjTZ8N1Jk0GUHyucbFPARWxYcn0zRTwHOXnXt+Z6GvAfuS7YElSXYVjw2Uy
|
||||||
|
wUVD+7zh0ydkWC1HJPnjalmHHVpv1RFNEJGAYQ8Vxd6G2EoY4ZFByZomWTxfXrvq
|
||||||
|
Dr9hsGtmZu1knNwfrOu2kyB5
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Compatibility report — 0.1.0
|
||||||
|
|
||||||
|
This report records evidence for simulator release 0.1.0 against the bundled
|
||||||
|
Proxmox VE 9.2.3 API contract. It is a limitation matrix, not a claim of general
|
||||||
|
Proxmox compatibility.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Level | Methods | Contract share | Evidence |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| Declared and dynamically routed | 675 | 100% | Imported immutable API Viewer artifact |
|
||||||
|
| Stateful semantics implemented | 13 | 1.93% | Handler registry and unit/integration tests |
|
||||||
|
| Schema-only or explicitly unsupported | 662 | 98.07% | Default 501 fallback |
|
||||||
|
| proxmoxer smoke exercised | 9 | 1.33% | Unmodified proxmoxer 2.3 compatibility test |
|
||||||
|
|
||||||
|
The smoke set is `POST /access/ticket`, `GET /version`, `GET /nodes`,
|
||||||
|
`GET /nodes/{node}/qemu`, `GET /nodes/{node}/qemu/{vmid}/status/current`, one of
|
||||||
|
the two state mutations (`start` or `stop`), and repeated
|
||||||
|
`GET /nodes/{node}/tasks/{upid}/status`. Both mutations have independent API and
|
||||||
|
worker tests; a single smoke run chooses the transition valid for current state.
|
||||||
|
|
||||||
|
## Implemented surface
|
||||||
|
|
||||||
|
- Core: version, ticket login, node list/status, and cluster resources.
|
||||||
|
- QEMU: list, configuration, current status, start, and stop.
|
||||||
|
- Tasks: node task list, status, and append-only log.
|
||||||
|
- Authentication: ticket cookie and ticket-bound CSRF validation for mutations.
|
||||||
|
- Persistence: PostgreSQL resources, durable leased tasks, and deterministic
|
||||||
|
`small` seed data.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
| Area | 0.1.0 behavior |
|
||||||
|
|---|---|
|
||||||
|
| Other imported endpoints | Registered, but return explicit unsupported errors |
|
||||||
|
| API tokens and broad ACL administration | Primitives exist; public management surface is incomplete |
|
||||||
|
| QEMU lifecycle | No create, update, delete, snapshots, clone, or migration |
|
||||||
|
| LXC, storage, pools, backup, HA | Contract-only; no stateful semantics yet |
|
||||||
|
| Observation parity | Responses are contract-tested, but no sanitized real-PVE observation corpus exists |
|
||||||
|
| TLS | Local nginx gateway with a checked-in self-signed development key only |
|
||||||
|
| Client certification | proxmoxer 2.3 smoke only; Terraform and other clients are not certified |
|
||||||
|
|
||||||
|
The live `/admin/compatibility` endpoint is the machine-readable source for
|
||||||
|
declared and implemented counts. Unsupported methods remain failures by default
|
||||||
|
so the simulator cannot silently overstate compatibility.
|
||||||
+4
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "proxmox-api-simulator"
|
name = "proxmox-api-simulator"
|
||||||
version = "0.0.1"
|
version = "0.1.0"
|
||||||
description = "Stateful asynchronous Proxmox VE API simulator"
|
description = "Stateful asynchronous Proxmox VE API simulator"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13,<3.14"
|
requires-python = ">=3.13,<3.14"
|
||||||
@@ -26,6 +26,8 @@ dev = [
|
|||||||
"pytest>=8.4,<9",
|
"pytest>=8.4,<9",
|
||||||
"pytest-asyncio>=1.1,<2",
|
"pytest-asyncio>=1.1,<2",
|
||||||
"pytest-cov>=6.2,<7",
|
"pytest-cov>=6.2,<7",
|
||||||
|
"proxmoxer>=2.3,<2.4",
|
||||||
|
"requests>=2.32,<3",
|
||||||
"ruff>=0.12,<0.13",
|
"ruff>=0.12,<0.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ testpaths = ["tests"]
|
|||||||
markers = [
|
markers = [
|
||||||
"integration: requires PostgreSQL or another external service",
|
"integration: requires PostgreSQL or another external service",
|
||||||
"contract: validates imported API contracts",
|
"contract: validates imported API contracts",
|
||||||
|
"compatibility: exercises an external client against a running simulator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""External client compatibility tests."""
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Unmodified proxmoxer HTTPS smoke flow."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.compatibility,
|
||||||
|
pytest.mark.skipif(not os.getenv("PROXMOXER_HOST"), reason="running TLS simulator required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||||
|
proxmox = ProxmoxAPI(
|
||||||
|
os.environ["PROXMOXER_HOST"],
|
||||||
|
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||||
|
user="root@pam",
|
||||||
|
password=os.getenv("PROXMOXER_PASSWORD", "secret"),
|
||||||
|
verify_ssl=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert proxmox.version.get()["version"] == "9.2.3"
|
||||||
|
assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
|
||||||
|
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
|
||||||
|
|
||||||
|
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
|
||||||
|
status = proxmox.nodes("pve1").qemu("101").status.current.get()
|
||||||
|
operation = "start" if status["status"] == "stopped" else "stop"
|
||||||
|
endpoint = proxmox.nodes("pve1").qemu("101").status(operation)
|
||||||
|
upid = endpoint.post()
|
||||||
|
for _attempt in range(100):
|
||||||
|
task = proxmox.nodes("pve1").tasks(upid).status.get()
|
||||||
|
if task["status"] == "stopped":
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert task["status"] == "stopped"
|
||||||
|
assert task["exitstatus"] == "OK"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Bounded task worker outcome tests."""
|
"""Bounded task worker outcome tests."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
@@ -72,3 +73,28 @@ async def test_worker_honors_persisted_cancellation() -> None:
|
|||||||
|
|
||||||
assert not called
|
assert not called
|
||||||
assert repository.finishes == [("cancelled", None)]
|
assert repository.finishes == [("cancelled", None)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_worker_retries_after_claim_failure() -> None:
|
||||||
|
class RecoveringRepository:
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
|
||||||
|
self.attempts += 1
|
||||||
|
if self.attempts == 1:
|
||||||
|
raise RuntimeError("database schema is not ready")
|
||||||
|
return None
|
||||||
|
|
||||||
|
repository = RecoveringRepository()
|
||||||
|
worker = TaskWorker(
|
||||||
|
cast(TaskRepository, repository),
|
||||||
|
"worker",
|
||||||
|
{},
|
||||||
|
poll_seconds=0.001,
|
||||||
|
)
|
||||||
|
running = asyncio.create_task(worker.run())
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
worker.stop()
|
||||||
|
await running
|
||||||
|
|
||||||
|
assert repository.attempts > 1
|
||||||
|
|||||||
Reference in New Issue
Block a user