From b0f37c0781c77c3749fc33c5d66a00a6b2aa5269 Mon Sep 17 00:00:00 2001 From: falamarcao Date: Tue, 12 Aug 2025 01:16:13 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=99=20chore:=20enhance=20CI/CD,=20vers?= =?UTF-8?q?ioning,=20and=20testing=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit โ€ข use importlib.metadata for version and description in Settings โ€ข add CI/CD pipeline on main branch for testing and auto-release pypi and docker โ€ข add new test for UVX functionality to ensure reliability --- .github/workflows/1_tests.yml | 4 +- .github/workflows/2_release.yml | 30 +++--- .github/workflows/3_ci-cd.yaml | 24 +++++ .github/workflows/README.md | 10 ++ config.yaml | 1 + pyproject.toml | 3 +- src/app/common/toml.py | 55 ----------- src/app/core/settings.py | 23 +++-- src/app/main.py | 27 +++--- src/app/models.py | 7 +- .../services/selenium_hub/_selenium_hub.py | 11 +++ src/tests/conftest.py | 14 ++- src/tests/e2e/test_browser_workflow.py | 4 +- .../integration/test_build_and_run_package.py | 91 +++++++++++++++++++ .../integration/test_health_and_stats.py | 3 +- src/tests/unit/test_selenium_hub.py | 1 - src/tests/unit/test_settings.py | 4 - uv.lock | 18 +++- 18 files changed, 221 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/3_ci-cd.yaml delete mode 100644 src/app/common/toml.py create mode 100644 src/tests/integration/test_build_and_run_package.py diff --git a/.github/workflows/1_tests.yml b/.github/workflows/1_tests.yml index 4caac86..f4c6b58 100644 --- a/.github/workflows/1_tests.yml +++ b/.github/workflows/1_tests.yml @@ -1,13 +1,11 @@ name: Tests on: - push: - branches: - - main pull_request: types: - opened - synchronize + workflow_call: permissions: {} # deny all by default diff --git a/.github/workflows/2_release.yml b/.github/workflows/2_release.yml index d2990b2..883794e 100644 --- a/.github/workflows/2_release.yml +++ b/.github/workflows/2_release.yml @@ -1,6 +1,13 @@ name: Release Python Package and Docker Image on: + workflow_call: + inputs: + publish: + description: 'Whether to publish the Python package and Docker image (true/false)' + required: false + default: 'false' + type: string workflow_dispatch: inputs: publish: @@ -25,18 +32,6 @@ jobs: run: echo "$GITHUB_CONTEXT" - id: lower run: echo "github_repository_lowercase=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT - - build-python-package: - needs: params - uses: ./.github/workflows/2.1_build-python-package.yml - permissions: - id-token: write - contents: read - with: - publish: ${{ github.event.inputs.publish }} - upload-github-release: 'false' - secrets: inherit - build-docker-image: needs: [params, build-python-package] uses: ./.github/workflows/2.2_build-docker-image.yml @@ -50,7 +45,16 @@ jobs: dockerfile: Dockerfile push: ${{ github.event.inputs.publish }} secrets: inherit - + build-python-package: + needs: params + uses: ./.github/workflows/2.1_build-python-package.yml + permissions: + id-token: write + contents: read + with: + publish: ${{ github.event.inputs.publish }} + upload-github-release: 'false' + secrets: inherit create-github-release: needs: [params, build-python-package, build-docker-image] if: ${{ github.event.inputs.publish == 'true' && needs.build-python-package.result == 'success' && needs.build-docker-image.result == 'success' }} diff --git a/.github/workflows/3_ci-cd.yaml b/.github/workflows/3_ci-cd.yaml new file mode 100644 index 0000000..33ce2df --- /dev/null +++ b/.github/workflows/3_ci-cd.yaml @@ -0,0 +1,24 @@ +name: CI/CD + +on: + push: + branches: + - main + +permissions: {} + +jobs: + tests: + name: Run full test suite + uses: ./.github/workflows/1_tests.yml + permissions: + contents: read + + release: + name: Release Python Package and Docker Image + needs: tests + if: needs.tests.result == 'success' + uses: ./.github/workflows/2_release.yml + with: + publish: 'true' # change to 'false' if you only want to build without publishing + secrets: inherit diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 46e4147..d686562 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -3,6 +3,7 @@ ## ๐Ÿ“‚ Workflow Overview This repository uses modular, clearly named workflows for CI, integration tests, packaging, Docker, and releases. +A top-level **`0_ci-cd.yaml`** orchestrates the process for pushes to `main`, running **Tests** first and triggering the **Release** workflow only if they pass. 1. ๐Ÿงช **Tests** โ€” lint, types checks, unit, integration and e2e tests. - ๐Ÿงฉ **Unit Tests** โ€” Run unit tests @@ -12,6 +13,7 @@ This repository uses modular, clearly named workflows for CI, integration tests, - ๐Ÿ“ฆ **Build & Publish Python Package** โ€” Build and (optionally) publish the Python package - ๐Ÿ‹ **Build & Push Docker Image** โ€” Build and (optionally) push the Docker image - ๐Ÿ“ **Create GitHub Release Only** โ€” Create a GitHub Release from already published artifacts +3. ๐Ÿ”„ **CI/CD Orchestration** (`3_ci-cd.yaml`) โ€” Runs Tests โ†’ Release when pushing to `main`. ## โšก Quick Start @@ -101,6 +103,14 @@ Create a GitHub Release from already published artifacts: act -W .github/workflows/2.3_create-github-release.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest --rm ``` +## 3. ๐Ÿ”„ CI/CD Orchestration + +Run the combined CI + Release process (push to main simulation): + +```sh +act -W .github/workflows/3_ci-cd.yaml -P ubuntu-latest=catthehacker/ubuntu:act-latest --rm +``` + ## ๐Ÿ’ก Notes - ๐Ÿณ You need Docker running. diff --git a/config.yaml b/config.yaml index 2afe914..86382e2 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,4 @@ +package_name: mcp-selenium-grid # pyproject.toml project_name: MCP Selenium Grid deployment_mode: docker # one of: docker, kubernetes (DeploymentMode enum values) api_v1_str: /api/v1 diff --git a/pyproject.toml b/pyproject.toml index 2e8731c..ca48056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ [project] name = "mcp-selenium-grid" -version = "0.1.0.dev5" +version = "0.1.0.dev6" description = "MCP Server for managing Selenium Grid" readme = "README.md" license = { file = "LICENSE" } @@ -58,6 +58,7 @@ test = [ "pytest-asyncio>=1.0.0", # Parallel test execution "pytest-sugar>=1.0.0", "coverage>=7.10.2", + "pytest-timeout>=2.4.0", ] [build-system] diff --git a/src/app/common/toml.py b/src/app/common/toml.py deleted file mode 100644 index b6fa3c5..0000000 --- a/src/app/common/toml.py +++ /dev/null @@ -1,55 +0,0 @@ -from contextlib import contextmanager -from importlib.resources import as_file, files -from pathlib import Path -from tomllib import load -from typing import Any, Iterator - - -@contextmanager -def find_pyproject_toml() -> Iterator[Path]: - """ - Finds the pyproject.toml file and yields its Path. - Uses a context manager to handle temporary files created by as_file(). - """ - try: - # Get the Traversable object for the pyproject.toml file. - resource_path = files(__package__).joinpath("pyproject.toml") - if resource_path.is_file(): - # Use as_file() to get a Path object to the resource. - # This is done within a context manager for proper cleanup. - with as_file(resource_path) as file_path: - yield file_path - return - except (ImportError, TypeError, AttributeError): - # Fallback for non-packaged or single-script scenarios. - pass - - # Fallback logic - current_dir = Path(__file__).resolve() - for parent in current_dir.parents: - potential_path = parent / "pyproject.toml" - if potential_path.is_file(): - yield potential_path - return - - raise FileNotFoundError("Could not find pyproject.toml") - - -def load_value_from_toml(keys: list[str], default: Any = None) -> Any: - """ - Load a nested value from a TOML file. - """ - with find_pyproject_toml() as file_path: - with file_path.open("rb") as f: - data = load(f) - - try: - for key in keys: - data = data[key] - return data - except KeyError: - if default is not None: - return default - raise ValueError(f"Keys {'.'.join(keys)} not found in {file_path}") - except Exception as e: - raise ValueError(f"Error reading {file_path}: {e}") from e diff --git a/src/app/core/settings.py b/src/app/core/settings.py index 428b15e..8dc6407 100644 --- a/src/app/core/settings.py +++ b/src/app/core/settings.py @@ -1,26 +1,29 @@ """Core settings for MCP Server.""" -from pydantic import Field, SecretStr, field_validator +from importlib.metadata import metadata, version + +from pydantic import Field, SecretStr -from app.common.toml import load_value_from_toml from app.services.selenium_hub.models.general_settings import SeleniumHubGeneralSettings class Settings(SeleniumHubGeneralSettings): """MCP Server settings.""" - # API Settings + # Project Settings + PACKAGE_NAME: str = "mcp-selenium-grid" PROJECT_NAME: str = "MCP Selenium Grid" - VERSION: str = "" - @field_validator("VERSION", mode="before") - @classmethod - def load_version_from_pyproject(cls, v: str) -> str: - return v or load_value_from_toml(["project", "version"]) + @property + def VERSION(self) -> str: + return version(self.PACKAGE_NAME) - API_V1_STR: str = "/api/v1" + @property + def DESCRIPTION(self) -> str: + return metadata(self.PACKAGE_NAME).get("Summary", "").strip() - # API Token + # API Settings + API_V1_STR: str = "/api/v1" API_TOKEN: SecretStr = SecretStr("CHANGE_ME") # Security Settings diff --git a/src/app/main.py b/src/app/main.py index 007438f..b514ca6 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -15,14 +15,12 @@ from app.common.fastapi_mcp import handle_fastapi_request from app.common.logger import logger -from app.common.toml import load_value_from_toml from app.dependencies import get_settings, verify_token from app.models import HealthCheckResponse, HealthStatus, HubStatusResponse from app.routers.browsers import router as browsers_router from app.routers.selenium_proxy import router as selenium_proxy_router from app.services.selenium_hub import SeleniumHub -DESCRIPTION = load_value_from_toml(["project", "description"]) SETTINGS = get_settings() MCP_HTTP_PATH = "/mcp" MCP_SSE_PATH = "/sse" @@ -65,7 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]: app = FastAPI( title=SETTINGS.PROJECT_NAME, version=SETTINGS.VERSION, - description=DESCRIPTION, + description=SETTINGS.DESCRIPTION, lifespan=lifespan, ) @@ -119,15 +117,14 @@ async def get_hub_stats( # Get app_state.browsers_instances using lock to ensure thread safety app_state = request.app.state async with app_state.browsers_instances_lock: - browsers = [browser.model_dump() for browser in app_state.browsers_instances.values()] - - return HubStatusResponse( - hub_running=is_running, - hub_healthy=is_healthy, - deployment_mode=SETTINGS.DEPLOYMENT_MODE, - max_instances=SETTINGS.selenium_grid.MAX_BROWSER_INSTANCES, - browsers=browsers, - ) + return HubStatusResponse( + hub_running=is_running, + hub_healthy=is_healthy, + deployment_mode=SETTINGS.DEPLOYMENT_MODE, + max_instances=SETTINGS.selenium_grid.MAX_BROWSER_INSTANCES, + browsers=app_state.browsers_instances, + webdriver_remote_url=hub.WEBDRIVER_REMOTE_URL, + ) # Include browser management endpoints app.include_router(browsers_router, prefix=SETTINGS.API_V1_STR) @@ -137,8 +134,8 @@ async def get_hub_stats( # --- MCP Integration --- mcp = FastApiMCP( app, - name="MCP Selenium Grid", - description=DESCRIPTION, + name=SETTINGS.PROJECT_NAME, + description=SETTINGS.DESCRIPTION, describe_full_response_schema=True, describe_all_responses=True, auth_config=AuthConfig( @@ -148,7 +145,7 @@ async def get_hub_stats( mcp.mount_http(mount_path=MCP_HTTP_PATH) mcp.mount_sse(mount_path=MCP_SSE_PATH) - @app.api_route("/", methods=["GET", "POST"]) + @app.api_route("/", methods=["GET", "POST"], include_in_schema=False) async def root_proxy( request: Request, credentials: HTTPAuthorizationCredentials = Depends(verify_token), diff --git a/src/app/models.py b/src/app/models.py index 22a2068..b5fa8c0 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1,11 +1,11 @@ """Response models for MCP Server.""" from enum import Enum -from typing import Any from pydantic import BaseModel, Field from app.services.selenium_hub.models import DeploymentMode +from app.services.selenium_hub.models.browser import BrowserInstance class HealthStatus(str, Enum): @@ -38,4 +38,7 @@ class HubStatusResponse(BaseModel): examples=[DeploymentMode.DOCKER, DeploymentMode.KUBERNETES], ) max_instances: int = Field(description="Maximum allowed browser instances") - browsers: list[dict[str, Any]] = Field(description="List of current browser instances") + browsers: dict[str, BrowserInstance] = Field( + description="Dict of current browser instances with id as dict key" + ) + webdriver_remote_url: str = Field(description="URL to connect to the Grid's Hub or Router") diff --git a/src/app/services/selenium_hub/_selenium_hub.py b/src/app/services/selenium_hub/_selenium_hub.py index dfd1250..1ab37bc 100644 --- a/src/app/services/selenium_hub/_selenium_hub.py +++ b/src/app/services/selenium_hub/_selenium_hub.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from urllib.parse import urljoin from app.services.metrics import track_browser_metrics, track_hub_metrics # TODO: refactor and test @@ -97,6 +98,16 @@ def URL(self) -> str: """ return self._manager.URL + @property + def WEBDRIVER_REMOTE_URL(self) -> str: + """ + Get the URL to connect to the Grid's Hub or Router + + Returns: + str: The URL to Remote WebDriver + """ + return urljoin(self.URL, "/wd/hub") + @track_hub_metrics() async def check_hub_health(self) -> bool: """ diff --git a/src/tests/conftest.py b/src/tests/conftest.py index b76b305..38682ff 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,6 +1,7 @@ """Pytest configuration file.""" -from typing import Any, Generator, cast +from shutil import which +from typing import Any, Callable, Generator, cast from unittest.mock import MagicMock import pytest @@ -248,6 +249,17 @@ def selenium_hub_basic_auth_headers() -> BasicAuth: ) +def create_cmd_fixture(name: str) -> Callable[[], str]: + @pytest.fixture(name=name + "_path") + def _fixture() -> str: + path: str | None = which(name) + if path is None: + pytest.skip(f"Executable '{name}' not found in PATH") + return path + + return _fixture + + # ============================================================================== # E2E TEST FIXTURES # ============================================================================== diff --git a/src/tests/e2e/test_browser_workflow.py b/src/tests/e2e/test_browser_workflow.py index 3866c49..47cdcbc 100644 --- a/src/tests/e2e/test_browser_workflow.py +++ b/src/tests/e2e/test_browser_workflow.py @@ -40,6 +40,7 @@ def test_complete_browser_lifecycle( assert create_response.status_code == status.HTTP_201_CREATED response_data = create_response.json() assert "browsers" in response_data + assert isinstance(response_data["browsers"], list) assert "hub_url" in response_data assert response_data["status"] == BrowserResponseStatus.CREATED @@ -56,7 +57,8 @@ def test_complete_browser_lifecycle( # Get the value of the 'client' fixture's current parameter (DeploymentMode) current_mode = request.node.callspec.params["client"] assert stats_data["deployment_mode"] == current_mode.value - stats_browsers_ids = [b["id"] for b in stats_data["browsers"]] + assert isinstance(stats_data["browsers"], dict) + stats_browsers_ids = [key for key in stats_data["browsers"].keys()] # Check if all created browsers are in stats (stats might contain more from previous runs) for browser_id in created_browsers_ids_list: assert browser_id in stats_browsers_ids diff --git a/src/tests/integration/test_build_and_run_package.py b/src/tests/integration/test_build_and_run_package.py new file mode 100644 index 0000000..54c3a3d --- /dev/null +++ b/src/tests/integration/test_build_and_run_package.py @@ -0,0 +1,91 @@ +from os import listdir, path +from socket import AF_INET, SOCK_STREAM, socket, timeout +from subprocess import PIPE, Popen, run +from tempfile import TemporaryDirectory +from time import sleep + +import pytest +from app.core.settings import Settings +from httpx import Client, codes + +from tests.conftest import create_cmd_fixture + +uv_path = create_cmd_fixture("uv") +uvx_path = create_cmd_fixture("uvx") + + +def is_port_open(host: str, port: int) -> bool: + with socket(AF_INET, SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, port)) + return True + except (ConnectionRefusedError, timeout): + return False + + +@pytest.mark.integration +@pytest.mark.timeout(70) +def test_uvx_with_built_package(uv_path: str, uvx_path: str, auth_headers: dict[str, str]) -> None: + HOST = "127.0.0.1" + PORT = 7777 + + settings = Settings() + + with TemporaryDirectory() as temp_build_dir: + # Step 1: Build the package + run( # noqa: S603 + [uv_path, "build", "--out-dir", temp_build_dir], + shell=False, + check=True, + ) + + # Step 2: Locate the built wheel file + wheel_files = [f for f in listdir(temp_build_dir) if f.endswith(".whl")] + print(f"Wheel files found: {wheel_files}") + assert wheel_files, "No wheel file found after uv build" + wheel_path = path.join(temp_build_dir, wheel_files[0]) + + # Step 3: Run uvx with the built package + proc = Popen( # noqa: S603 + [ + uvx_path, + "--from", + f"file://{wheel_path}", + settings.PACKAGE_NAME, + "server", + "run", + "--host", + HOST, + "--port", + str(PORT), + ], + shell=False, + stdout=PIPE, + stderr=PIPE, + text=True, + ) + + try: + # Wait for server to start listening on port 7777 + timeout_seconds = 60 + for _ in range(timeout_seconds): + if is_port_open(HOST, PORT): + break + sleep(1) + else: + proc.terminate() + proc.wait(timeout=5) + pytest.fail(f"Server {HOST} did not start listening on port {PORT} within timeout.") + + # Step 4: Test server HTTP response with httpx + with Client() as client: + response = client.get(f"http://{HOST}:{PORT}/health", headers=auth_headers) + assert response.status_code == codes.OK # HTTP 200 + finally: + proc.terminate() + proc.wait(timeout=5) + + stdout, stderr = proc.communicate() + print("uvx stdout:", stdout) + print("uvx stderr:", stderr) diff --git a/src/tests/integration/test_health_and_stats.py b/src/tests/integration/test_health_and_stats.py index ee39137..dfd0ac6 100644 --- a/src/tests/integration/test_health_and_stats.py +++ b/src/tests/integration/test_health_and_stats.py @@ -52,4 +52,5 @@ def test_hub_stats_endpoint( assert "max_instances" in data assert "browsers" in data - assert isinstance(data["browsers"], list) + assert "webdriver_remote_url" in data + assert isinstance(data["browsers"], dict) diff --git a/src/tests/unit/test_selenium_hub.py b/src/tests/unit/test_selenium_hub.py index db5ce6b..e2d151b 100644 --- a/src/tests/unit/test_selenium_hub.py +++ b/src/tests/unit/test_selenium_hub.py @@ -385,7 +385,6 @@ async def test_singleton_behavior() -> None: # Create initial settings settings = Settings( PROJECT_NAME="Test Project", - VERSION="0.1.0", DEPLOYMENT_MODE=DeploymentMode.DOCKER, API_V1_STR="/api/v1", API_TOKEN=SecretStr("test-token"), diff --git a/src/tests/unit/test_settings.py b/src/tests/unit/test_settings.py index ff10743..b5c4a1d 100644 --- a/src/tests/unit/test_settings.py +++ b/src/tests/unit/test_settings.py @@ -14,11 +14,9 @@ @pytest.mark.unit def test_settings_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PROJECT_NAME", "Env Project") - monkeypatch.setenv("VERSION", "3.0.0") monkeypatch.setenv("API_V1_STR", "/env/api") settings = Settings() assert settings.PROJECT_NAME == "Env Project" - assert settings.VERSION == "3.0.0" assert settings.API_V1_STR == "/env/api" @@ -47,7 +45,6 @@ def test_deployment_mode_override_by_env(monkeypatch: pytest.MonkeyPatch) -> Non def test_settings_loads_from_yaml(tmp_path: Path) -> None: yaml_content = textwrap.dedent(f""" project_name: YAML Project - version: 9.9.9 selenium_grid: hub_image: selenium/hub:latest max_browser_instances: {MAX_BROWSER_INSTANCES} @@ -70,7 +67,6 @@ def test_settings_loads_from_yaml(tmp_path: Path) -> None: try: settings = Settings() assert settings.PROJECT_NAME == "YAML Project" - assert settings.VERSION == "9.9.9" assert settings.selenium_grid.MAX_BROWSER_INSTANCES == MAX_BROWSER_INSTANCES assert settings.kubernetes.NAMESPACE == "yaml-namespace" expected_kubeconfig = os.path.expanduser("~/fake-kubeconfig") diff --git a/uv.lock b/uv.lock index 28303f9..7bfe41b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -583,7 +583,7 @@ wheels = [ [[package]] name = "mcp-selenium-grid" -version = "0.1.0.dev5" +version = "0.1.0.dev6" source = { editable = "." } dependencies = [ { name = "docker" }, @@ -608,6 +608,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-sugar" }, + { name = "pytest-timeout" }, ] [package.dev-dependencies] @@ -639,6 +640,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.0.0" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.14.1" }, { name = "pytest-sugar", marker = "extra == 'test'", specifier = ">=1.0.0" }, + { name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.4.0" }, { name = "typer", specifier = ">=0.16.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, ] @@ -972,6 +974,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171, upload-time = "2024-02-01T18:30:29.395Z" }, ] +[[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-dateutil" version = "2.9.0.post0"