From cce42f5d6ed002c138730c9440de6aac54727eca Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Fri, 13 Feb 2026 10:14:38 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Allow=20to=20specify=20application?= =?UTF-8?q?=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcake-Parent: main --- pyproject.toml | 2 +- src/fastapi_cloud_cli/commands/deploy.py | 86 +++++- src/fastapi_cloud_cli/utils/cli.py | 3 +- tests/test_cli_deploy.py | 345 ++++++++++++++++++++++- tests/test_deploy_utils.py | 64 ++++- tests/utils.py | 1 + uv.lock | 8 +- 7 files changed, 482 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b552c2..82b611d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "uvicorn[standard] >= 0.15.0", "rignore >= 0.5.1", "httpx >= 0.27.0", - "rich-toolkit >= 0.14.5", + "rich-toolkit >= 0.19.4", "pydantic[email] >= 2.0", "sentry-sdk >= 2.20.0", "fastar >= 0.8.0", diff --git a/src/fastapi_cloud_cli/commands/deploy.py b/src/fastapi_cloud_cli/commands/deploy.py index f5b1f35..20c4642 100644 --- a/src/fastapi_cloud_cli/commands/deploy.py +++ b/src/fastapi_cloud_cli/commands/deploy.py @@ -1,11 +1,12 @@ import contextlib import logging +import re import subprocess import tempfile import time from enum import Enum from itertools import cycle -from pathlib import Path +from pathlib import Path, PurePosixPath from textwrap import dedent from typing import Annotated, Any, Optional, Union @@ -13,7 +14,7 @@ import rignore import typer from httpx import Client -from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError +from pydantic import AfterValidator, BaseModel, EmailStr, TypeAdapter, ValidationError from rich.text import Text from rich_toolkit import RichToolkit from rich_toolkit.menu import Option @@ -27,6 +28,39 @@ logger = logging.getLogger(__name__) +def validate_app_directory(v: Optional[str]) -> Optional[str]: + if v is None: + return None + + v = v.strip() + + if not v: + return None + + if v.startswith("~"): + raise ValueError("cannot start with '~'") + + path = PurePosixPath(v) + + if path.is_absolute(): + raise ValueError("must be a relative path, not absolute") + + if ".." in path.parts: + raise ValueError("cannot contain '..' path segments") + + normalized = path.as_posix() + + if not re.fullmatch(r"[A-Za-z0-9._/ -]+", normalized): + raise ValueError( + "contains invalid characters (allowed: letters, numbers, space, / . _ -)" + ) + + return normalized + + +AppDirectory = Annotated[Optional[str], AfterValidator(validate_app_directory)] + + def _cancel_upload(deployment_id: str) -> None: logger.debug("Cancelling upload for deployment: %s", deployment_id) @@ -113,13 +147,26 @@ def _get_teams() -> list[Team]: class AppResponse(BaseModel): id: str slug: str + directory: Optional[str] + + +def _update_app(app_id: str, directory: Optional[str]) -> AppResponse: + with APIClient() as client: + response = client.patch( + f"/apps/{app_id}", + json={"directory": directory}, + ) + + response.raise_for_status() + + return AppResponse.model_validate(response.json()) -def _create_app(team_id: str, app_name: str) -> AppResponse: +def _create_app(team_id: str, app_name: str, directory: Optional[str]) -> AppResponse: with APIClient() as client: response = client.post( "/apps/", - json={"name": app_name, "team_id": team_id}, + json={"name": app_name, "team_id": team_id, "directory": directory}, ) response.raise_for_status() @@ -332,10 +379,26 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: toolkit.print_line() + initial_directory = selected_app.directory if selected_app else "" + + directory_input = toolkit.input( + title="Path to the directory containing your app (e.g. src, backend):", + tag="dir", + value=initial_directory or "", + placeholder="[italic]Leave empty if it's the current directory[/italic]", + validator=TypeAdapter(AppDirectory), + ) + + directory: Optional[str] = directory_input if directory_input else None + + toolkit.print_line() + toolkit.print("Deployment configuration:", tag="summary") toolkit.print_line() toolkit.print(f"Team: [bold]{team.name}[/bold]") toolkit.print(f"App name: [bold]{app_name}[/bold]") + toolkit.print(f"Directory: [bold]{directory or '.'}[/bold]") + toolkit.print_line() choice = toolkit.ask( @@ -352,12 +415,21 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig: toolkit.print("Deployment cancelled.") raise typer.Exit(0) - if selected_app: # pragma: no cover - app = selected_app + if selected_app: + if directory != selected_app.directory: + with ( + toolkit.progress(title="Updating app directory...") as progress, + handle_http_errors(progress), + ): + app = _update_app(selected_app.id, directory=directory) + + progress.log(f"App directory updated to '{directory or '.'}'") + else: + app = selected_app else: with toolkit.progress(title="Creating app...") as progress: with handle_http_errors(progress): - app = _create_app(team.id, app_name) + app = _create_app(team.id, app_name, directory=directory) progress.log(f"App created successfully! App slug: {app.slug}") diff --git a/src/fastapi_cloud_cli/utils/cli.py b/src/fastapi_cloud_cli/utils/cli.py index c38360c..26c7d18 100644 --- a/src/fastapi_cloud_cli/utils/cli.py +++ b/src/fastapi_cloud_cli/utils/cli.py @@ -56,12 +56,13 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit: theme={ "tag.title": "white on #009485", "tag": "white on #007166", - "placeholder": "grey85", + "placeholder": "grey62", "text": "white", "selected": "#007166", "result": "grey85", "progress": "on #007166", "error": "red", + "cancelled": "indian_red italic", }, ) diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index c71a6bc..79121d1 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -2,7 +2,7 @@ import string from datetime import timedelta from pathlib import Path -from typing import Optional +from typing import Optional, TypedDict from unittest.mock import patch import httpx @@ -32,15 +32,32 @@ def _get_random_team() -> dict[str, str]: return {"name": name, "slug": slug, "id": id} +class RandomApp(TypedDict): + name: str + slug: str + id: str + team_id: str + directory: Optional[str] + + def _get_random_app( - *, slug: Optional[str] = None, team_id: Optional[str] = None -) -> dict[str, str]: + *, + slug: Optional[str] = None, + team_id: Optional[str] = None, + directory: Optional[str] = None, +) -> RandomApp: name = "".join(random.choices(string.ascii_lowercase, k=10)) slug = slug or "".join(random.choices(string.ascii_lowercase, k=10)) id = "".join(random.choices(string.digits, k=10)) team_id = team_id or "".join(random.choices(string.digits, k=10)) - return {"name": name, "slug": slug, "id": id, "team_id": team_id} + return { + "name": name, + "slug": slug, + "id": id, + "team_id": team_id, + "directory": directory, + } def _get_random_deployment( @@ -336,7 +353,7 @@ def test_asks_for_app_name_after_team( def test_creates_app_on_backend( logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter ) -> None: - steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER] + steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER, Keys.ENTER] team = _get_random_team() @@ -347,10 +364,50 @@ def test_creates_app_on_backend( ) ) - respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock( - return_value=Response(201, json=_get_random_app(team_id=team["id"])) + respx_mock.post( + "/apps/", json={"name": "demo", "team_id": team["id"], "directory": None} + ).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"]))) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 1 + + assert "App created successfully" in result.output + + +@pytest.mark.respx +def test_creates_app_with_directory( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + steps = [ + Keys.ENTER, + Keys.ENTER, + *"demo", + Keys.ENTER, + *"src", + Keys.ENTER, + Keys.ENTER, + ] + + team = _get_random_team() + + respx_mock.get("/teams/").mock( + return_value=Response( + 200, + json={"data": [team]}, + ) ) + respx_mock.post( + "/apps/", json={"name": "demo", "team_id": team["id"], "directory": "src"} + ).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"]))) + with ( changing_dir(tmp_path), patch("rich_toolkit.container.getchar") as mock_getchar, @@ -362,6 +419,52 @@ def test_creates_app_on_backend( assert result.exit_code == 1 assert "App created successfully" in result.output + assert "Directory: src" in result.output + + +@pytest.mark.respx +@pytest.mark.parametrize( + "directory,expected_error", + [ + ("~/src", "cannot start with '~'"), + ("/absolute/path", "must be a relative path, not absolute"), + ("src/../etc", "cannot contain '..' path segments"), + ("src/@app", "contains invalid characters"), + ], +) +def test_shows_validation_error_for_invalid_directory( + logged_in_cli: None, + tmp_path: Path, + respx_mock: respx.MockRouter, + directory: str, + expected_error: str, +) -> None: + steps = [ + Keys.ENTER, # Select team + Keys.ENTER, # Confirm new app + *"demo", + Keys.ENTER, # App name + *directory, + Keys.ENTER, # Submit invalid directory -> validation error shown + Keys.CTRL_C, # Cancel + ] + + respx_mock.get("/teams/").mock( + return_value=Response( + 200, + json={"data": [_get_random_team()]}, + ) + ) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert expected_error in result.output @pytest.mark.respx @@ -373,6 +476,7 @@ def test_cancels_deployment_when_user_selects_no( Keys.ENTER, *"demo", Keys.ENTER, + Keys.ENTER, Keys.DOWN_ARROW, Keys.ENTER, ] @@ -428,6 +532,219 @@ def test_uses_existing_app( assert app_data["slug"] in result.output +@pytest.mark.respx +def test_uses_existing_app_with_directory( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + steps = [ + Keys.ENTER, # Select team + Keys.RIGHT_ARROW, # Choose existing app (No) + Keys.ENTER, + Keys.ENTER, # Select app from list + Keys.ENTER, # Accept pre-filled directory + Keys.DOWN_ARROW, # Cancel deployment + Keys.ENTER, + ] + + team = _get_random_team() + + respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) + + app_data = _get_random_app(team_id=team["id"], directory="backend") + + respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( + return_value=Response(200, json={"data": [app_data]}) + ) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert "Directory: backend" in result.output + + +@pytest.mark.respx +def test_uses_existing_app_and_changes_directory( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + steps = [ + Keys.ENTER, # Select team + Keys.RIGHT_ARROW, # Choose existing app (No) + Keys.ENTER, + Keys.ENTER, # Select app from list + *([Keys.BACKSPACE] * len("backend")), # Clear pre-filled directory + *"src", + Keys.ENTER, # Submit new directory + Keys.DOWN_ARROW, # Cancel deployment + Keys.ENTER, + ] + + team = _get_random_team() + + respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) + + app_data = _get_random_app(team_id=team["id"], directory="backend") + + respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( + return_value=Response(200, json={"data": [app_data]}) + ) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert "Directory: src" in result.output + + +@pytest.mark.respx +def test_updates_app_directory_via_api_when_changed( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + steps = [ + Keys.ENTER, # Select team + Keys.RIGHT_ARROW, # Choose existing app (No) + Keys.ENTER, + Keys.ENTER, # Select app from list + *([Keys.BACKSPACE] * len("backend")), # Clear pre-filled directory + *"src", + Keys.ENTER, # Submit new directory + Keys.ENTER, # Confirm deployment + ] + + team = _get_random_team() + + respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) + + app_data = _get_random_app(team_id=team["id"], directory="backend") + + respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( + return_value=Response(200, json={"data": [app_data]}) + ) + + updated_app_data = {**app_data, "directory": "src"} + + patch_route = respx_mock.patch( + f"/apps/{app_data['id']}", json={"directory": "src"} + ).mock(return_value=Response(200, json=updated_app_data)) + + respx_mock.get(f"/apps/{app_data['id']}").mock( + return_value=Response(200, json=updated_app_data) + ) + + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( + return_value=Response(201, json=deployment_data) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( + return_value=Response( + 200, + json={"url": "http://test.com", "fields": {"key": "value"}}, + ) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response(200) + ) + respx_mock.post("http://test.com", data={"key": "value"}).mock( + return_value=Response(200) + ) + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( + return_value=Response( + 200, + content=build_logs_response( + {"type": "message", "message": "Building...", "id": "1"}, + {"type": "complete"}, + ), + ) + ) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + assert patch_route.called + assert "App directory updated" in result.output + + +@pytest.mark.respx +def test_does_not_update_app_directory_when_unchanged( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + steps = [ + Keys.ENTER, # Select team + Keys.RIGHT_ARROW, # Choose existing app (No) + Keys.ENTER, + Keys.ENTER, # Select app from list + Keys.ENTER, # Accept pre-filled directory (unchanged) + Keys.ENTER, # Confirm deployment + ] + + team = _get_random_team() + + respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) + + app_data = _get_random_app(team_id=team["id"], directory="backend") + + respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( + return_value=Response(200, json={"data": [app_data]}) + ) + + respx_mock.get(f"/apps/{app_data['id']}").mock( + return_value=Response(200, json=app_data) + ) + + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( + return_value=Response(201, json=deployment_data) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( + return_value=Response( + 200, + json={"url": "http://test.com", "fields": {"key": "value"}}, + ) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response(200) + ) + respx_mock.post("http://test.com", data={"key": "value"}).mock( + return_value=Response(200) + ) + respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( + return_value=Response( + 200, + content=build_logs_response( + {"type": "message", "message": "Building...", "id": "1"}, + {"type": "complete"}, + ), + ) + ) + + with ( + changing_dir(tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = steps + + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + assert "App directory updated" not in result.output + + @pytest.mark.respx def test_exits_successfully_when_deployment_is_done( logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter @@ -438,6 +755,7 @@ def test_exits_successfully_when_deployment_is_done( *"demo", Keys.ENTER, Keys.ENTER, + Keys.ENTER, ] team_data = _get_random_team() @@ -447,9 +765,9 @@ def test_exits_successfully_when_deployment_is_done( return_value=Response(200, json={"data": [team_data]}) ) - respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock( - return_value=Response(201, json=app_data) - ) + respx_mock.post( + "/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None} + ).mock(return_value=Response(201, json=app_data)) respx_mock.get(f"/apps/{app_data['id']}").mock( return_value=Response(200, json=app_data) @@ -689,6 +1007,7 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res *"demo", Keys.ENTER, Keys.ENTER, + Keys.ENTER, ] team_data = _get_random_team() @@ -702,9 +1021,9 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res ) ) - respx_mock.post("/apps/", json={"name": "demo", "team_id": team_data["id"]}).mock( - return_value=Response(201, json=app_data) - ) + respx_mock.post( + "/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None} + ).mock(return_value=Response(201, json=app_data)) respx_mock.get(f"/apps/{app_data['id']}").mock( return_value=Response(200, json=app_data) diff --git a/tests/test_deploy_utils.py b/tests/test_deploy_utils.py index 428486c..adce189 100644 --- a/tests/test_deploy_utils.py +++ b/tests/test_deploy_utils.py @@ -1,8 +1,13 @@ from pathlib import Path +from typing import Optional import pytest -from fastapi_cloud_cli.commands.deploy import DeploymentStatus, _should_exclude_entry +from fastapi_cloud_cli.commands.deploy import ( + DeploymentStatus, + _should_exclude_entry, + validate_app_directory, +) @pytest.mark.parametrize( @@ -75,3 +80,60 @@ def test_deployment_status_to_human_readable( ) -> None: """Should convert deployment status to human readable format.""" assert DeploymentStatus.to_human_readable(status) == expected + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, None), + ("", None), + (" ", None), + ("src", "src"), + ("src/app", "src/app"), + (" src/app ", "src/app"), + ("my-app", "my-app"), + ("my_app", "my_app"), + ("my.app", "my.app"), + ("src/my app", "src/my app"), + ("a/b/c", "a/b/c"), + ], +) +def test_validate_app_directory_valid( + value: Optional[str], expected: Optional[str] +) -> None: + """Should accept valid directory values and normalize them.""" + assert validate_app_directory(value) == expected + + +@pytest.mark.parametrize( + "value,expected_message", + [ + ("~/src", "cannot start with '~'"), + ("/absolute/path", "must be a relative path, not absolute"), + ("src/../etc", "cannot contain '..' path segments"), + ("..", "cannot contain '..' path segments"), + ("src/../../etc", "cannot contain '..' path segments"), + ( + "src/@app", + "contains invalid characters (allowed: letters, numbers, space, / . _ -)", + ), + ( + "src/$app", + "contains invalid characters (allowed: letters, numbers, space, / . _ -)", + ), + ( + "src/app!", + "contains invalid characters (allowed: letters, numbers, space, / . _ -)", + ), + ( + "src/app#1", + "contains invalid characters (allowed: letters, numbers, space, / . _ -)", + ), + ], +) +def test_validate_app_directory_invalid(value: str, expected_message: str) -> None: + """Should reject invalid directory values with descriptive errors.""" + with pytest.raises(ValueError) as exc_info: + validate_app_directory(value) + + assert str(exc_info.value) == expected_message diff --git a/tests/utils.py b/tests/utils.py index d2c597b..76616b4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -28,6 +28,7 @@ class Keys: ENTER = "\r" CTRL_C = "\x03" TAB = "\t" + BACKSPACE = "\x7f" def create_jwt_token(payload: dict[str, Any]) -> str: diff --git a/uv.lock b/uv.lock index f1b7518..8b7cf48 100644 --- a/uv.lock +++ b/uv.lock @@ -390,7 +390,7 @@ requires-dist = [ { name = "fastar", specifier = ">=0.8.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.0" }, - { name = "rich-toolkit", specifier = ">=0.14.5" }, + { name = "rich-toolkit", specifier = ">=0.19.4" }, { name = "rignore", specifier = ">=0.5.1" }, { name = "sentry-sdk", specifier = ">=2.20.0" }, { name = "typer", specifier = ">=0.12.3" }, @@ -1114,7 +1114,7 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.17.0" +version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -1122,9 +1122,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/d0/8f8de36e1abf8339b497ce700dd7251ca465ffca4a1976969b0eaeb596fb/rich_toolkit-0.17.0.tar.gz", hash = "sha256:17ca7a32e613001aa0945ddea27a246f6de01dfc4c12403254c057a8ee542977", size = 187955, upload-time = "2025-11-27T11:10:24.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/42/ef2ed40699567661d03b0b511ac46cf6cee736de8f3666819c12d6d20696/rich_toolkit-0.17.0-py3-none-any.whl", hash = "sha256:06fb47a5c5259d6b480287cd38aff5f551b6e1a307f90ed592453dd360e4e71e", size = 31412, upload-time = "2025-11-27T11:10:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, ] [[package]]