v0.1.5 #13

Merged
pptx704 merged 56 commits from dev into main 2026-05-22 23:01:46 +00:00
50 changed files with 2035 additions and 16917 deletions
Showing only changes of commit eecf1dc65b - Show all commits

View File

@ -21,7 +21,9 @@ generate:
--use-schema-description \ --use-schema-description \
--target-python-version 3.13 \ --target-python-version 3.13 \
--use-annotated \ --use-annotated \
--openapi-scopes schemas --openapi-scopes schemas \
--formatters ruff-format ruff-check \
--input-file-type openapi
lint: lint:
uv run ruff check src/ uv run ruff check src/

View File

@ -699,11 +699,17 @@ paths:
$ref: "#/components/schemas/ExecRequest" $ref: "#/components/schemas/ExecRequest"
responses: responses:
"200": "200":
description: Command output description: Command output (foreground exec)
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ExecResponse" $ref: "#/components/schemas/ExecResponse"
"202":
description: Background process started
content:
application/json:
schema:
$ref: "#/components/schemas/BackgroundExecResponse"
"404": "404":
description: Capsule not found description: Capsule not found
content: content:
@ -717,6 +723,122 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes:
parameters:
- name: id
in: path
required: true
schema:
type: string
get:
summary: List running processes
operationId: listProcesses
tags: [capsules]
security:
- apiKeyAuth: []
description: |
Returns all running processes inside the capsule, including background
processes and any processes started by templates or init scripts.
responses:
"200":
description: Process list
content:
application/json:
schema:
$ref: "#/components/schemas/ProcessListResponse"
"404":
description: Capsule not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes/{selector}:
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: selector
in: path
required: true
description: Process PID (numeric) or tag (string)
schema:
type: string
delete:
summary: Kill a process
operationId: killProcess
tags: [capsules]
security:
- apiKeyAuth: []
parameters:
- name: signal
in: query
required: false
description: Signal to send (SIGKILL or SIGTERM, default SIGKILL)
schema:
type: string
enum: [SIGKILL, SIGTERM]
default: SIGKILL
responses:
"204":
description: Process killed
"404":
description: Capsule or process not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/capsules/{id}/processes/{selector}/stream:
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: selector
in: path
required: true
description: Process PID (numeric) or tag (string)
schema:
type: string
get:
summary: Stream process output via WebSocket
operationId: connectProcess
tags: [capsules]
security:
- apiKeyAuth: []
description: |
Opens a WebSocket connection to stream stdout/stderr from a running
background process. The selector can be a numeric PID or a string tag.
Server sends JSON messages:
- `{"type": "start", "pid": 42}` — connected to process
- `{"type": "stdout", "data": "..."}` — stdout output
- `{"type": "stderr", "data": "..."}` — stderr output
- `{"type": "exit", "exit_code": 0}` — process exited
- `{"type": "error", "data": "..."}` — error message
responses:
"101":
description: WebSocket upgrade
/v1/capsules/{id}/ping: /v1/capsules/{id}/ping:
parameters: parameters:
- name: id - name: id
@ -2153,6 +2275,56 @@ components:
timeout_sec: timeout_sec:
type: integer type: integer
default: 30 default: 30
description: Timeout in seconds (foreground exec only, default 30)
background:
type: boolean
default: false
description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)
tag:
type: string
description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true.
envs:
type: object
additionalProperties:
type: string
description: Environment variables for the process (background exec only)
cwd:
type: string
description: Working directory for the process (background exec only)
BackgroundExecResponse:
type: object
properties:
sandbox_id:
type: string
cmd:
type: string
pid:
type: integer
tag:
type: string
ProcessEntry:
type: object
properties:
pid:
type: integer
tag:
type: string
cmd:
type: string
args:
type: array
items:
type: string
ProcessListResponse:
type: object
properties:
processes:
type: array
items:
$ref: "#/components/schemas/ProcessEntry"
ExecResponse: ExecResponse:
type: object type: object

View File

@ -20,7 +20,7 @@ build-backend = "hatchling.build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"datamodel-code-generator>=0.56.0", "datamodel-code-generator[ruff]>=0.56.0",
"mypy>=1.20.0", "mypy>=1.20.0",
"pytest>=9.0.3", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",

View File

@ -1,13 +1,11 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-04-12T20:56:29+00:00 # timestamp: 2026-04-15T08:37:41+00:00
from __future__ import annotations from __future__ import annotations
from enum import StrEnum
from typing import Annotated
from pydantic import AwareDatetime, BaseModel, EmailStr, Field from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated
from enum import StrEnum
class SignupRequest(BaseModel): class SignupRequest(BaseModel):
@ -22,7 +20,7 @@ class LoginRequest(BaseModel):
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
token: Annotated[str | None, Field(description='JWT token (valid for 6 hours)')] = ( token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
None None
) )
user_id: str | None = None user_id: str | None = None
@ -32,7 +30,7 @@ class AuthResponse(BaseModel):
class CreateAPIKeyRequest(BaseModel): class CreateAPIKeyRequest(BaseModel):
name: str | None = 'Unnamed API Key' name: str | None = "Unnamed API Key"
class APIKeyResponse(BaseModel): class APIKeyResponse(BaseModel):
@ -47,29 +45,29 @@ class APIKeyResponse(BaseModel):
key: Annotated[ key: Annotated[
str | None, str | None,
Field( Field(
description='Full plaintext key. Only returned on creation, never again.' description="Full plaintext key. Only returned on creation, never again."
), ),
] = None ] = None
class CreateCapsuleRequest(BaseModel): class CreateCapsuleRequest(BaseModel):
template: str | None = 'minimal' template: str | None = "minimal"
vcpus: int | None = 1 vcpus: int | None = 1
memory_mb: int | None = 512 memory_mb: int | None = 512
timeout_sec: Annotated[ timeout_sec: Annotated[
int | None, int | None,
Field( Field(
description='Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n' description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n"
), ),
] = 0 ] = 0
class Range(StrEnum): class Range(StrEnum):
field_5m = '5m' field_5m = "5m"
field_1h = '1h' field_1h = "1h"
field_6h = '6h' field_6h = "6h"
field_24h = '24h' field_24h = "24h"
field_30d = '30d' field_30d = "30d"
class Current(BaseModel): class Current(BaseModel):
@ -104,22 +102,22 @@ class CapsuleStats(BaseModel):
range: Range | None = None range: Range | None = None
current: Current | None = None current: Current | None = None
peaks: Annotated[ peaks: Annotated[
Peaks | None, Field(description='Maximum values over the last 30 days.') Peaks | None, Field(description="Maximum values over the last 30 days.")
] = None ] = None
series: Annotated[ series: Annotated[
Series | None, Field(description='Parallel arrays for chart rendering.') Series | None, Field(description="Parallel arrays for chart rendering.")
] = None ] = None
class Status(StrEnum): class Status(StrEnum):
pending = 'pending' pending = "pending"
starting = 'starting' starting = "starting"
running = 'running' running = "running"
paused = 'paused' paused = "paused"
hibernated = 'hibernated' hibernated = "hibernated"
stopped = 'stopped' stopped = "stopped"
missing = 'missing' missing = "missing"
error = 'error' error = "error"
class Capsule(BaseModel): class Capsule(BaseModel):
@ -139,17 +137,17 @@ class Capsule(BaseModel):
class CreateSnapshotRequest(BaseModel): class CreateSnapshotRequest(BaseModel):
sandbox_id: Annotated[ sandbox_id: Annotated[
str, Field(description='ID of the running capsule to snapshot.') str, Field(description="ID of the running capsule to snapshot.")
] ]
name: Annotated[ name: Annotated[
str | None, str | None,
Field(description='Name for the snapshot template. Auto-generated if omitted.'), Field(description="Name for the snapshot template. Auto-generated if omitted."),
] = None ] = None
class Type(StrEnum): class Type(StrEnum):
base = 'base' base = "base"
snapshot = 'snapshot' snapshot = "snapshot"
class Template(BaseModel): class Template(BaseModel):
@ -164,7 +162,50 @@ class Template(BaseModel):
class ExecRequest(BaseModel): class ExecRequest(BaseModel):
cmd: str cmd: str
args: list[str] | None = None args: list[str] | None = None
timeout_sec: int | None = 30 timeout_sec: Annotated[
int | None,
Field(description="Timeout in seconds (foreground exec only, default 30)"),
] = 30
background: Annotated[
bool | None,
Field(
description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)"
),
] = False
tag: Annotated[
str | None,
Field(
description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true."
),
] = None
envs: Annotated[
dict[str, str] | None,
Field(
description="Environment variables for the process (background exec only)"
),
] = None
cwd: Annotated[
str | None,
Field(description="Working directory for the process (background exec only)"),
] = None
class BackgroundExecResponse(BaseModel):
sandbox_id: str | None = None
cmd: str | None = None
pid: int | None = None
tag: str | None = None
class ProcessEntry(BaseModel):
pid: int | None = None
tag: str | None = None
cmd: str | None = None
args: list[str] | None = None
class ProcessListResponse(BaseModel):
processes: list[ProcessEntry] | None = None
class Encoding(StrEnum): class Encoding(StrEnum):
@ -172,8 +213,8 @@ class Encoding(StrEnum):
Output encoding. "base64" when stdout/stderr contain binary data. Output encoding. "base64" when stdout/stderr contain binary data.
""" """
utf_8 = 'utf-8' utf_8 = "utf-8"
base64 = 'base64' base64 = "base64"
class ExecResponse(BaseModel): class ExecResponse(BaseModel):
@ -192,23 +233,23 @@ class ExecResponse(BaseModel):
class ReadFileRequest(BaseModel): class ReadFileRequest(BaseModel):
path: Annotated[str, Field(description='Absolute file path inside the capsule')] path: Annotated[str, Field(description="Absolute file path inside the capsule")]
class ListDirRequest(BaseModel): class ListDirRequest(BaseModel):
path: Annotated[str, Field(description='Directory path inside the capsule')] path: Annotated[str, Field(description="Directory path inside the capsule")]
depth: Annotated[ depth: Annotated[
int | None, int | None,
Field( Field(
description='Recursion depth (0 = non-recursive, 1 = immediate children)' description="Recursion depth (0 = non-recursive, 1 = immediate children)"
), ),
] = 1 ] = 1
class Type1(StrEnum): class Type1(StrEnum):
file = 'file' file = "file"
directory = 'directory' directory = "directory"
symlink = 'symlink' symlink = "symlink"
class FileEntry(BaseModel): class FileEntry(BaseModel):
@ -223,14 +264,14 @@ class FileEntry(BaseModel):
owner: str | None = None owner: str | None = None
group: str | None = None group: str | None = None
modified_at: Annotated[ modified_at: Annotated[
int | None, Field(description='Unix timestamp (seconds)') int | None, Field(description="Unix timestamp (seconds)")
] = None ] = None
symlink_target: str | None = None symlink_target: str | None = None
class MakeDirRequest(BaseModel): class MakeDirRequest(BaseModel):
path: Annotated[ path: Annotated[
str, Field(description='Directory path to create inside the capsule') str, Field(description="Directory path to create inside the capsule")
] ]
@ -239,7 +280,7 @@ class MakeDirResponse(BaseModel):
class RemoveRequest(BaseModel): class RemoveRequest(BaseModel):
path: Annotated[str, Field(description='Path to remove inside the capsule')] path: Annotated[str, Field(description="Path to remove inside the capsule")]
class Type2(StrEnum): class Type2(StrEnum):
@ -247,51 +288,51 @@ class Type2(StrEnum):
Host type. Regular hosts are shared; BYOC hosts belong to a team. Host type. Regular hosts are shared; BYOC hosts belong to a team.
""" """
regular = 'regular' regular = "regular"
byoc = 'byoc' byoc = "byoc"
class CreateHostRequest(BaseModel): class CreateHostRequest(BaseModel):
type: Annotated[ type: Annotated[
Type2, Type2,
Field( Field(
description='Host type. Regular hosts are shared; BYOC hosts belong to a team.' description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
), ),
] ]
team_id: Annotated[str | None, Field(description='Required for BYOC hosts.')] = None team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
provider: Annotated[ provider: Annotated[
str | None, str | None,
Field(description='Cloud provider (e.g. aws, gcp, hetzner, bare-metal).'), Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
] = None ] = None
availability_zone: Annotated[ availability_zone: Annotated[
str | None, Field(description='Availability zone (e.g. us-east, eu-west).') str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
] = None ] = None
class RegisterHostRequest(BaseModel): class RegisterHostRequest(BaseModel):
token: Annotated[ token: Annotated[
str, Field(description='One-time registration token from POST /v1/hosts.') str, Field(description="One-time registration token from POST /v1/hosts.")
] ]
arch: Annotated[ arch: Annotated[
str | None, Field(description='CPU architecture (e.g. x86_64, aarch64).') str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
] = None ] = None
cpu_cores: int | None = None cpu_cores: int | None = None
memory_mb: int | None = None memory_mb: int | None = None
disk_gb: int | None = None disk_gb: int | None = None
address: Annotated[str, Field(description='Host agent address (ip:port).')] address: Annotated[str, Field(description="Host agent address (ip:port).")]
class Type3(StrEnum): class Type3(StrEnum):
regular = 'regular' regular = "regular"
byoc = 'byoc' byoc = "byoc"
class Status1(StrEnum): class Status1(StrEnum):
pending = 'pending' pending = "pending"
online = 'online' online = "online"
offline = 'offline' offline = "offline"
draining = 'draining' draining = "draining"
unreachable = 'unreachable' unreachable = "unreachable"
class Host(BaseModel): class Host(BaseModel):
@ -316,7 +357,7 @@ class RefreshHostTokenRequest(BaseModel):
refresh_token: Annotated[ refresh_token: Annotated[
str, str,
Field( Field(
description='Refresh token obtained from registration or a previous refresh.' description="Refresh token obtained from registration or a previous refresh."
), ),
] ]
@ -324,12 +365,12 @@ class RefreshHostTokenRequest(BaseModel):
class RefreshHostTokenResponse(BaseModel): class RefreshHostTokenResponse(BaseModel):
host: Host | None = None host: Host | None = None
token: Annotated[ token: Annotated[
str | None, Field(description='New host JWT. Valid for 7 days.') str | None, Field(description="New host JWT. Valid for 7 days.")
] = None ] = None
refresh_token: Annotated[ refresh_token: Annotated[
str | None, str | None,
Field( Field(
description='New refresh token. Valid for 60 days; old token is revoked.' description="New refresh token. Valid for 60 days; old token is revoked."
), ),
] = None ] = None
@ -338,16 +379,16 @@ class HostDeletePreview(BaseModel):
host: Host | None = None host: Host | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None,
Field(description='IDs of capsulees that would be destroyed on force-delete.'), Field(description="IDs of capsulees that would be destroyed on force-delete."),
] = None ] = None
class Error(BaseModel): class Error(BaseModel):
code: Annotated[str | None, Field(examples=['host_has_sandboxes'])] = None code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
message: str | None = None message: str | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None,
Field(description='IDs of active capsulees blocking deletion.'), Field(description="IDs of active capsulees blocking deletion."),
] = None ] = None
@ -368,15 +409,15 @@ class Team(BaseModel):
id: str | None = None id: str | None = None
name: str | None = None name: str | None = None
slug: Annotated[ slug: Annotated[
str | None, Field(description='Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)') str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
] = None ] = None
created_at: AwareDatetime | None = None created_at: AwareDatetime | None = None
class Role(StrEnum): class Role(StrEnum):
owner = 'owner' owner = "owner"
admin = 'admin' admin = "admin"
member = 'member' member = "member"
class TeamWithRole(Team): class TeamWithRole(Team):
@ -396,13 +437,13 @@ class TeamDetail(BaseModel):
class Range1(StrEnum): class Range1(StrEnum):
field_5m = '5m' field_5m = "5m"
field_10m = '10m' field_10m = "10m"
field_1h = '1h' field_1h = "1h"
field_2h = '2h' field_2h = "2h"
field_6h = '6h' field_6h = "6h"
field_12h = '12h' field_12h = "12h"
field_24h = '24h' field_24h = "24h"
class MetricPoint(BaseModel): class MetricPoint(BaseModel):
@ -410,41 +451,41 @@ class MetricPoint(BaseModel):
cpu_pct: Annotated[ cpu_pct: Annotated[
float | None, float | None,
Field( Field(
description='CPU utilization percentage (0-100), normalized to vCPU count' description="CPU utilization percentage (0-100), normalized to vCPU count"
), ),
] = None ] = None
mem_bytes: Annotated[ mem_bytes: Annotated[
int | None, int | None,
Field(description='Resident memory in bytes (VmRSS of Firecracker process)'), Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
] = None ] = None
disk_bytes: Annotated[ disk_bytes: Annotated[
int | None, Field(description='Allocated disk bytes for the CoW sparse file') int | None, Field(description="Allocated disk bytes for the CoW sparse file")
] = None ] = None
class Provider(StrEnum): class Provider(StrEnum):
discord = 'discord' discord = "discord"
slack = 'slack' slack = "slack"
teams = 'teams' teams = "teams"
googlechat = 'googlechat' googlechat = "googlechat"
telegram = 'telegram' telegram = "telegram"
matrix = 'matrix' matrix = "matrix"
webhook = 'webhook' webhook = "webhook"
class Event(StrEnum): class Event(StrEnum):
capsule_created = 'capsule.created' capsule_created = "capsule.created"
capsule_running = 'capsule.running' capsule_running = "capsule.running"
capsule_paused = 'capsule.paused' capsule_paused = "capsule.paused"
capsule_destroyed = 'capsule.destroyed' capsule_destroyed = "capsule.destroyed"
template_snapshot_created = 'template.snapshot.created' template_snapshot_created = "template.snapshot.created"
template_snapshot_deleted = 'template.snapshot.deleted' template_snapshot_deleted = "template.snapshot.deleted"
host_up = 'host.up' host_up = "host.up"
host_down = 'host.down' host_down = "host.down"
class CreateChannelRequest(BaseModel): class CreateChannelRequest(BaseModel):
name: Annotated[str, Field(description='Unique channel name within the team.')] name: Annotated[str, Field(description="Unique channel name within the team.")]
provider: Provider provider: Provider
config: Annotated[ config: Annotated[
dict[str, str], dict[str, str],
@ -460,7 +501,7 @@ class TestChannelRequest(BaseModel):
config: Annotated[ config: Annotated[
dict[str, str], dict[str, str],
Field( Field(
description='Provider-specific configuration fields (same as CreateChannelRequest.config).' description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
), ),
] ]
@ -489,7 +530,7 @@ class ChannelResponse(BaseModel):
updated_at: AwareDatetime | None = None updated_at: AwareDatetime | None = None
secret: Annotated[ secret: Annotated[
str | None, str | None,
Field(description='Webhook secret. Only returned on creation, never again.'), Field(description="Webhook secret. Only returned on creation, never again."),
] = None ] = None
@ -511,7 +552,7 @@ class CreateHostResponse(BaseModel):
registration_token: Annotated[ registration_token: Annotated[
str | None, str | None,
Field( Field(
description='One-time registration token for the host agent. Expires in 1 hour.' description="One-time registration token for the host agent. Expires in 1 hour."
), ),
] = None ] = None
@ -520,12 +561,12 @@ class RegisterHostResponse(BaseModel):
host: Host | None = None host: Host | None = None
token: Annotated[ token: Annotated[
str | None, str | None,
Field(description='Host JWT for X-Host-Token header. Valid for 7 days.'), Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
] = None ] = None
refresh_token: Annotated[ refresh_token: Annotated[
str | None, str | None,
Field( Field(
description='Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.' description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
), ),
] = None ] = None

11
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14'", "python_full_version >= '3.14'",
@ -112,6 +112,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" },
] ]
[package.optional-dependencies]
ruff = [
{ name = "ruff" },
]
[[package]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.8.0" version = "2.8.0"
@ -684,7 +689,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "datamodel-code-generator" }, { name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
@ -702,7 +707,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "datamodel-code-generator", specifier = ">=0.56.0" }, { name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.56.0" },
{ name = "mypy", specifier = ">=1.20.0" }, { name = "mypy", specifier = ">=1.20.0" },
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },