diff --git a/Makefile b/Makefile index 51a46e4..5176e72 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,20 @@ .PHONY: help test test-unit test-integration build dev-up dev-down pre-deploy clean VERBOSE ?= 0 -PYTEST_ARGS := $(if $(filter 1,$(VERBOSE)),-v,-q) # Default target help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' # Testing -test: test-unit ## Run all tests (unit only by default) +test: ## Run all tests (unit + integration) with rich progress display + @uv run python scripts/test-runner.py $(if $(filter 1,$(VERBOSE)),-v) -test-unit: ## Run unit tests - uv run pytest tests/unit/ $(PYTEST_ARGS) +test-unit: ## Run unit tests only + @uv run python scripts/test-runner.py --unit-only $(if $(filter 1,$(VERBOSE)),-v) -test-integration: ## Run integration tests (starts/stops containers) - ./scripts/run-integration-tests.sh +test-integration: ## Run integration tests only (starts/stops containers) + @uv run python scripts/test-runner.py --integration-only $(if $(filter 1,$(VERBOSE)),-v) # Docker build: ## Build Docker image @@ -27,7 +27,7 @@ dev-down: ## Stop development environment cd deploy/dev && docker compose down # Pre-deployment -pre-deploy: test-unit test-integration ## Full pre-deployment pipeline +pre-deploy: test ## Full pre-deployment pipeline @echo "Pre-deployment checks passed!" # Cleanup diff --git a/pyproject.toml b/pyproject.toml index cafd3a9..fc06444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "pytest-httpx>=0.32.0", + "pytest-timeout>=2.0.0", + "rich>=13.0.0", ] [build-system] diff --git a/scripts/test-runner.py b/scripts/test-runner.py new file mode 100755 index 0000000..97058c5 --- /dev/null +++ b/scripts/test-runner.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Rich test runner with progress display and fail-fast behavior. + +Runs unit tests, then integration tests with real-time progress indication. +""" + +import argparse +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from rich.console import Console +from rich.live import Live +from rich.table import Table +from rich.text import Text + + +class Status(Enum): + PENDING = "pending" + RUNNING = "running" + PASSED = "passed" + FAILED = "failed" + + +@dataclass +class TestStage: + name: str + command: list[str] + status: Status = Status.PENDING + progress: int = 0 + total: int = 0 + passed: int = 0 + failed: int = 0 + current_test: str = "" + duration: float = 0.0 + output: list[str] = field(default_factory=list) + + +# Regex patterns for parsing pytest output +PYTEST_PROGRESS = re.compile(r"\[\s*(\d+)%\]") +PYTEST_COLLECTING = re.compile(r"collected (\d+) items?") +PYTEST_RESULT = re.compile(r"(\d+) passed") +PYTEST_FAILED = re.compile(r"(\d+) failed") +PYTEST_DURATION = re.compile(r"in ([\d.]+)s") +PYTEST_TEST_LINE = re.compile(r"(tests/\S+::\S+)") + + +class TestRunner: + def __init__(self, verbose: bool = False): + self.console = Console() + self.verbose = verbose + self.project_root = Path(__file__).parent.parent + self.stages: list[TestStage] = [] + self.all_passed = True + + def add_stage(self, name: str, command: list[str]) -> None: + self.stages.append(TestStage(name=name, command=command)) + + def render_table(self) -> Table: + table = Table(show_header=False, box=None, padding=(0, 1)) + table.add_column("Status", width=3) + table.add_column("Name", width=20) + table.add_column("Progress", width=30) + table.add_column("Time", width=8) + + for stage in self.stages: + # Status icon + if stage.status == Status.PENDING: + icon = Text("○", style="dim") + elif stage.status == Status.RUNNING: + icon = Text("●", style="yellow") + elif stage.status == Status.PASSED: + icon = Text("✓", style="green") + else: + icon = Text("✗", style="red") + + # Progress display + if stage.status == Status.PENDING: + progress = Text("pending", style="dim") + elif stage.status == Status.RUNNING: + if stage.total > 0: + bar_width = 20 + filled = int(bar_width * stage.progress / 100) + bar = "━" * filled + "░" * (bar_width - filled) + progress = Text(f"{bar} {stage.progress:3d}% {stage.passed}/{stage.total}") + if stage.current_test: + progress.append(f"\n → {stage.current_test[:40]}", style="dim") + else: + progress = Text("collecting...", style="yellow") + elif stage.status == Status.PASSED: + progress = Text(f"{stage.passed}/{stage.total}", style="green") + else: + progress = Text(f"{stage.passed}/{stage.total} ({stage.failed} failed)", style="red") + + # Duration + if stage.duration > 0: + duration = Text(f"{stage.duration:.1f}s", style="dim") + else: + duration = Text("") + + table.add_row(icon, stage.name, progress, duration) + + return table + + def parse_output(self, stage: TestStage, line: str) -> None: + """Parse pytest output line and update stage state.""" + stage.output.append(line) + + # Check for collected count + match = PYTEST_COLLECTING.search(line) + if match: + stage.total = int(match.group(1)) + + # Check for progress percentage + match = PYTEST_PROGRESS.search(line) + if match: + stage.progress = int(match.group(1)) + # Estimate passed based on progress + if stage.total > 0: + stage.passed = int(stage.total * stage.progress / 100) + + # Check for current test + match = PYTEST_TEST_LINE.search(line) + if match: + stage.current_test = match.group(1) + + # Check for final results + match = PYTEST_RESULT.search(line) + if match: + stage.passed = int(match.group(1)) + + match = PYTEST_FAILED.search(line) + if match: + stage.failed = int(match.group(1)) + + match = PYTEST_DURATION.search(line) + if match: + stage.duration = float(match.group(1)) + + def run_stage(self, stage: TestStage, live: Live) -> bool: + """Run a single test stage and return True if passed.""" + stage.status = Status.RUNNING + live.update(self.render_table()) + + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + + try: + process = subprocess.Popen( + stage.command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=self.project_root, + env=env, + ) + + for line in process.stdout: + line = line.rstrip() + self.parse_output(stage, line) + live.update(self.render_table()) + + if self.verbose: + self.console.print(line) + + process.wait() + + if process.returncode == 0: + stage.status = Status.PASSED + stage.progress = 100 + return True + else: + stage.status = Status.FAILED + self.all_passed = False + return False + + except Exception as e: + stage.status = Status.FAILED + stage.output.append(str(e)) + self.all_passed = False + return False + finally: + live.update(self.render_table()) + + def run_all(self) -> bool: + """Run all test stages with fail-fast behavior.""" + self.console.print() + + with Live(self.render_table(), console=self.console, refresh_per_second=4) as live: + for stage in self.stages: + if not self.run_stage(stage, live): + # Fail fast - don't run remaining stages + break + + self.console.print() + + # Print summary + if self.all_passed: + self.console.print("[green]All tests passed![/green]") + else: + self.console.print("[red]Tests failed![/red]") + # Print failure details + for stage in self.stages: + if stage.status == Status.FAILED: + self.console.print(f"\n[red]Failures in {stage.name}:[/red]") + # Print last 20 lines of output for context + for line in stage.output[-20:]: + self.console.print(f" {line}") + + return self.all_passed + + +def main(): + parser = argparse.ArgumentParser(description="Run tests with rich progress display") + parser.add_argument("-v", "--verbose", action="store_true", help="Show full test output") + parser.add_argument("--unit-only", action="store_true", help="Run only unit tests") + parser.add_argument("--integration-only", action="store_true", help="Run only integration tests") + args = parser.parse_args() + + runner = TestRunner(verbose=args.verbose) + + # Determine which stages to run + run_unit = not args.integration_only + run_integration = not args.unit_only + + if run_unit: + runner.add_stage( + "Unit Tests", + ["uv", "run", "pytest", "tests/unit/", "-v", "--tb=short"], + ) + + if run_integration: + # Use the integration test script which handles containers + runner.add_stage( + "Integration Tests", + ["bash", "./scripts/run-integration-tests.sh"], + ) + + success = runner.run_all() + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 2fd59f7..a09f2f5 100644 --- a/uv.lock +++ b/uv.lock @@ -169,6 +169,8 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, + { name = "pytest-timeout" }, + { name = "rich" }, ] [package.metadata] @@ -178,7 +180,9 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.32.0" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", marker = "extra == 'dev'", specifier = ">=13.0.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "starlette", specifier = ">=0.41.0" }, { name = "uvicorn", specifier = ">=0.32.0" }, @@ -276,9 +280,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "mcp" -version = "1.23.1" +version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -296,9 +312,18 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/42/10c0c09ca27aceacd8c428956cfabdd67e3d328fe55c4abc16589285d294/mcp-1.23.1.tar.gz", hash = "sha256:7403e053e8e2283b1e6ae631423cb54736933fea70b32422152e6064556cd298", size = 596519, upload-time = "2025-12-02T18:41:12.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl", hash = "sha256:3ce897fcc20a41bd50b4c58d3aa88085f11f505dcc0eaed48930012d34c731d8", size = 231433, upload-time = "2025-12-02T18:41:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -421,7 +446,7 @@ crypto = [ [[package]] name = "pytest" -version = "9.0.1" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -430,9 +455,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -460,6 +485,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -471,11 +508,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] [[package]] @@ -527,6 +564,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -566,14 +616,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.0.3" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306, upload-time = "2025-12-26T15:22:53.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442, upload-time = "2025-12-26T15:22:52.911Z" }, ] [[package]] @@ -611,13 +662,13 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ]