diff --git a/pyproject.toml b/pyproject.toml index 218d645..033cabb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "uvicorn[standard] >= 0.17.6", "rignore >= 0.5.1", "httpx >= 0.27.0", - "rich-toolkit>=0.20.5", + "rich-toolkit>=0.20.6", "pydantic[email] >= 2.7.4; python_version < '3.13'", "pydantic[email] >= 2.8.0; python_version == '3.13'", "pydantic[email] >= 2.12.0; python_version >= '3.14'", diff --git a/src/fastapi_cloud_cli/api/_models.py b/src/fastapi_cloud_cli/api/_models.py index 0fc87e6..590abbe 100644 --- a/src/fastapi_cloud_cli/api/_models.py +++ b/src/fastapi_cloud_cli/api/_models.py @@ -172,5 +172,8 @@ class Deployment(BaseModel): DeploymentStatus.verification_failed_oom, DeploymentStatus.deploying_failed, DeploymentStatus.deploying_skipped, + DeploymentStatus.degraded, + DeploymentStatus.degraded_oom, + DeploymentStatus.expired, } TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES diff --git a/src/fastapi_cloud_cli/commands/deploy/archive.py b/src/fastapi_cloud_cli/commands/deploy/archive.py index 795dcf0..0933d29 100644 --- a/src/fastapi_cloud_cli/commands/deploy/archive.py +++ b/src/fastapi_cloud_cli/commands/deploy/archive.py @@ -57,6 +57,7 @@ def _should_exclude_entry(path: Path) -> bool: ".git", ".gitignore", ".fastapicloudignore", + ".fastapicloud", ] if any(part in path.parts for part in parts_to_exclude): @@ -80,7 +81,8 @@ def _rignore_walk(path: Path) -> rignore.Walker: ) -def archive(path: Path, tar_path: Path) -> Path: +def archive(path: Path, tar_path: Path) -> int: + """Write the files to upload into `tar_path` and return how many there were.""" logger.debug("Starting archive creation for path: %s", path) files = _rignore_walk(path) @@ -98,7 +100,7 @@ def archive(path: Path, tar_path: Path) -> Path: file_count += 1 logger.debug("Archive created successfully with %s files", file_count) - return tar_path + return file_count def _get_large_files(path: Path, threshold_mb: int) -> list[tuple[Path, int]]: diff --git a/src/fastapi_cloud_cli/commands/deploy/cloud.py b/src/fastapi_cloud_cli/commands/deploy/cloud.py index 2f28132..fabc0ac 100644 --- a/src/fastapi_cloud_cli/commands/deploy/cloud.py +++ b/src/fastapi_cloud_cli/commands/deploy/cloud.py @@ -21,6 +21,7 @@ class AppResponse(BaseModel): id: str slug: str directory: str | None + github_repository: str | None = None class CreateDeploymentResponse(BaseModel): diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index b5fecd0..52a75d3 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -99,16 +99,61 @@ def _render_linked_app_not_found( ) +def _confirm_home_directory_deploy( + toolkit: FastAPIRichToolkit, *, json_output: bool +) -> None: + if json_output or is_ci_enabled(): + toolkit.fail( + "invalid_input", + "Refusing to deploy your home directory.", + hint="Pass the directory of your app, or use --force.", + ) + + if not toolkit.confirm( + "You are deploying your home directory. Do you want to continue?", + default=False, + emoji="🚨", + ): + toolkit.print_line() + toolkit.print("Deployment cancelled.") + raise typer.Exit(0) + + toolkit.print_line() + + +def _check_app_directory( + toolkit: FastAPIRichToolkit, *, app: AppResponse, path_to_deploy: Path +) -> None: + """Stop before uploading when the app's directory isn't in the upload.""" + if app.directory is None or (path_to_deploy / app.directory).is_dir(): + return + + if app.github_repository: + hint = ( + f"This app is connected to {app.github_repository} and builds from " + f"`{app.directory}` in that repository. Run " + f"`fastapi deploy --app-id {app.id}` from the repository root, or use --force." + ) + else: + hint = ( + "Deploy from the folder that contains it, update the app directory " + "with `fastapi cloud apps update --directory`, or use --force." + ) + + toolkit.fail( + "invalid_input", + f"The app directory `{app.directory}` doesn't exist in {path_to_deploy}.", + hint=hint, + ) + + @app.command() @cloud_app.command() def deploy( path: Annotated[ Path | None, typer.Argument( - help=( - "Path to the directory with your app's pyproject.toml " - "(defaults to current directory)" - ) + help="Path to the directory to deploy (defaults to current directory)" ), ] = None, skip_wait: Annotated[ @@ -131,6 +176,13 @@ def deploy( ), ] = 10, json_output: JsonOutputOption = False, + force: Annotated[ + bool, + typer.Option( + "--force", + help="Deploy even if the app directory is missing or the path is your home directory", + ), + ] = False, ) -> Any: """ Deploy a [bold]FastAPI[/bold] app to FastAPI Cloud. @@ -212,6 +264,9 @@ def deploy( path_to_deploy = path or Path.cwd() logger.debug("Deploying from path: %s", path_to_deploy) + if not force and path_to_deploy.resolve() == Path.home().resolve(): + _confirm_home_directory_deploy(toolkit, json_output=json_output) + app_config = get_app_config(path_to_deploy) if app_config and provided_app_id and app_config.app_id != provided_app_id: @@ -230,7 +285,7 @@ def deploy( elif app_config: target_app_id = app_config.app_id else: - if json_output: + if json_output or is_ci_enabled(): toolkit.fail( "missing_required_input", "App ID is required.", @@ -281,6 +336,9 @@ def deploy( app = cast(AppResponse, app) + if not force: + _check_app_directory(toolkit, app=app, path_to_deploy=path_to_deploy) + large_files = _get_large_files( path_to_deploy, threshold_mb=large_file_threshold ) @@ -316,9 +374,19 @@ def deploy( with tempfile.TemporaryDirectory() as temp_dir: logger.debug("Creating archive for deployment") archive_path = Path(temp_dir) / "archive.tar" - archive(path_to_deploy, archive_path) + file_count = archive(path_to_deploy, archive_path) archive_size = archive_path.stat().st_size + if file_count == 0: + toolkit.fail( + "invalid_input", + f"Nothing to deploy: no files to upload in {path_to_deploy}.", + hint=( + "Check the path, and that .gitignore or " + ".fastapicloudignore don't exclude your app's files." + ), + ) + with ( toolkit.progress( title="Creating deployment", diff --git a/src/fastapi_cloud_cli/commands/deploy/wait.py b/src/fastapi_cloud_cli/commands/deploy/wait.py index 977f103..2e304f7 100644 --- a/src/fastapi_cloud_cli/commands/deploy/wait.py +++ b/src/fastapi_cloud_cli/commands/deploy/wait.py @@ -21,6 +21,10 @@ from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit from fastapi_cloud_cli.utils.execution import is_ci_enabled +# The build finished but we couldn't confirm the deployment came up. Distinct +# from 1 so scripts can tell "still verifying" from "failed". +UNCONFIRMED_EXIT_CODE = 3 + # (bullet emoji, message) — the emoji replaces the progress animation WAITING_MESSAGES = [ ("🚀", "Preparing for liftoff! Almost there..."), @@ -55,6 +59,7 @@ def _verify_deployment( deployment: CreateDeploymentResponse, ) -> None: failed_status: str | None = None + unconfirmed = False with toolkit.progress( title="Verifying deployment...", @@ -69,15 +74,18 @@ def _verify_deployment( f"Could not confirm deployment status. " f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]" ) - return - - if final_status in SUCCESSFUL_STATUSES: - progress.current_message = "Ready the chicken! 🐔" + unconfirmed = True else: - progress.metadata["done_emoji"] = "❌" - progress.current_message = "Deployment failed" + if final_status in SUCCESSFUL_STATUSES: + progress.current_message = "Ready the chicken! 🐔" + else: + progress.metadata["done_emoji"] = "❌" + progress.current_message = "Deployment failed" + + failed_status = DeploymentStatus.to_human_readable(final_status) - failed_status = DeploymentStatus.to_human_readable(final_status) + if unconfirmed: + raise typer.Exit(UNCONFIRMED_EXIT_CODE) if failed_status is not None: toolkit.print_line() diff --git a/tests/domains/test_cli.py b/tests/domains/test_cli.py index 0e1f5d4..623be3a 100644 --- a/tests/domains/test_cli.py +++ b/tests/domains/test_cli.py @@ -954,7 +954,9 @@ def test_domains_remove_warns_and_confirms( return_value=Response(200, json={"message": "Custom domain deleted"}) ) - with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): + with patch( + "rich_toolkit.container.getchar", side_effect=[Keys.LEFT_ARROW, Keys.ENTER] + ): result = runner.invoke( app, ["domains", "remove", domain["name"], "--app-id", APP_ID], @@ -967,6 +969,9 @@ def test_domains_remove_warns_and_confirms( ⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at your provider will not be changed. + Remove api.example.com? + ○ Yes ● No + Remove api.example.com? ● Yes ○ No @@ -1023,7 +1028,7 @@ def test_domains_remove_can_be_cancelled( with patch( "rich_toolkit.container.getchar", - side_effect=[Keys.RIGHT_ARROW, Keys.ENTER], + side_effect=[Keys.ENTER], # Default: "No" ): result = runner.invoke( app, @@ -1037,9 +1042,6 @@ def test_domains_remove_can_be_cancelled( ⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at your provider will not be changed. - Remove api.example.com? - ● Yes ○ No - Remove api.example.com? ○ Yes ● No diff --git a/tests/integrations/resources/test_disconnect.py b/tests/integrations/resources/test_disconnect.py index d5bd15f..2f398e7 100644 --- a/tests/integrations/resources/test_disconnect.py +++ b/tests/integrations/resources/test_disconnect.py @@ -220,7 +220,7 @@ def test_disconnect_resource_allows_interactive_selection( changing_dir(tmp_path), patch( "rich_toolkit.container.getchar", - side_effect=[Keys.ENTER, Keys.ENTER], + side_effect=[Keys.ENTER, Keys.LEFT_ARROW, Keys.ENTER], ), ): result = runner.invoke(app, ["integrations", "resources", "disconnect"]) @@ -238,6 +238,9 @@ def test_disconnect_resource_allows_interactive_selection( 💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be removed from the app. The Neon resource itself will not be deleted. + Disconnect production? + ○ Yes ● No + Disconnect production? ● Yes ○ No @@ -264,7 +267,9 @@ def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided( return_value=Response(204) ) - with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): + with patch( + "rich_toolkit.container.getchar", side_effect=[Keys.LEFT_ARROW, Keys.ENTER] + ): result = runner.invoke( app, [ @@ -284,6 +289,9 @@ def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided( 💡 Managed environment variables will be removed from the app. The Neon resource itself will not be deleted. + Disconnect production? + ○ Yes ● No + Disconnect production? ● Yes ○ No @@ -304,7 +312,7 @@ def test_disconnect_resource_can_be_cancelled( with patch( "rich_toolkit.container.getchar", - side_effect=[Keys.RIGHT_ARROW, Keys.ENTER], + side_effect=[Keys.ENTER], # Default: "No" ): result = runner.invoke( app, @@ -325,9 +333,6 @@ def test_disconnect_resource_can_be_cancelled( 💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be removed from the app. The Neon resource itself will not be deleted. - Disconnect production? - ● Yes ○ No - Disconnect production? ○ Yes ● No diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 18aad5f..2562ab5 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -41,6 +41,14 @@ assets_path = Path(__file__).parent / "assets" +@pytest.fixture(autouse=True) +def app_source(tmp_path: Path) -> Path: + # Deploy refuses to upload an empty folder; give every test an app file. + main_path = tmp_path / "main.py" + main_path.write_text("from fastapi import FastAPI\n\napp = FastAPI()\n") + return main_path + + def _get_random_team(name: str | None = None) -> dict[str, str]: name = name or "".join(random.choices(string.ascii_lowercase, k=10)) slug = "".join(random.choices(string.ascii_lowercase, k=10)) @@ -821,6 +829,7 @@ def test_updates_app_directory_via_api_when_changed( ) updated_app_data = {**app_data, "directory": "src"} + (tmp_path / "src").mkdir() patch_route = respx_mock.patch( f"/apps/{app_data['id']}", json={"directory": "src"} @@ -888,6 +897,7 @@ def test_does_not_update_app_directory_when_unchanged( Keys.ENTER, # Accept pre-filled directory (unchanged) Keys.ENTER, # Confirm deployment ] + (tmp_path / "backend").mkdir() team = _get_random_team() @@ -2412,7 +2422,7 @@ def test_polling_timeout_shows_dashboard_link( ): result = runner.invoke(app, ["deploy"]) - assert result.exit_code == 0 + assert result.exit_code == 3 assert "Could not confirm deployment status" in result.output assert deployment_data["dashboard_url"] in result.output @@ -2794,3 +2804,180 @@ def wait_for_build() -> None: 👀 Check out the logs at https://dashboard.fastapicloud.com/demo-build\ """) + + +def _mock_configured_app( + respx_mock: respx.MockRouter, + configured_app: ConfiguredApp, + **app_fields: str | None, +) -> None: + app_data = {**_get_random_app(), **app_fields, "id": configured_app.app_id} + respx_mock.get(f"/apps/{configured_app.app_id}").mock( + return_value=Response(200, json=app_data) + ) + + +@pytest.mark.respx +def test_deploy_fails_when_app_directory_is_missing( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app(respx_mock, configured_app, directory="backend") + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": ( + f"The app directory `backend` doesn't exist in {configured_app.path}." + ), + "hint": ( + "Deploy from the folder that contains it, update the app directory " + "with `fastapi cloud apps update --directory`, or use --force." + ), + } + } + + +@pytest.mark.respx +def test_deploy_points_linked_apps_to_the_repository_root( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app( + respx_mock, + configured_app, + directory="backend", + github_repository="acme/shop", + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"]["hint"] == ( + "This app is connected to acme/shop and builds from `backend` in that " + f"repository. Run `fastapi deploy --app-id {configured_app.app_id}` from " + "the repository root, or use --force." + ) + + +@pytest.mark.respx +def test_deploy_with_force_skips_the_app_directory_check( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_configured_app(respx_mock, configured_app, directory="backend") + deployment_data = _get_random_deployment(app_id=configured_app.app_id) + + respx_mock.post(f"/apps/{configured_app.app_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("http://test.com", data={"key": "value"}).mock( + return_value=Response(200) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( + return_value=Response( + 200, json={**deployment_data, "status": "ready_for_build"} + ) + ) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json", "--force"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["data"]["deployment_id"] == deployment_data["id"] + + +@pytest.mark.respx +def test_deploy_fails_when_there_is_nothing_to_upload( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, + app_source: Path, +) -> None: + # Only the local .fastapicloud link is left, and it isn't uploaded. + app_source.unlink() + _mock_configured_app(respx_mock, configured_app) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == { + "code": "invalid_input", + "message": f"Nothing to deploy: no files to upload in {configured_app.path}.", + "hint": ( + "Check the path, and that .gitignore or .fastapicloudignore don't " + "exclude your app's files." + ), + } + + +def test_deploy_on_ci_without_app_id_fails_instead_of_prompting( + logged_in_cli: None, tmp_path: Path +) -> None: + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"], env={"CI": "true"}) + + assert result.exit_code == 1 + assert "App ID is required." in result.output + + +def test_deploy_on_ci_refuses_the_home_directory( + logged_in_cli: None, tmp_path: Path +) -> None: + with changing_dir(tmp_path), patch("pathlib.Path.home", return_value=tmp_path): + result = runner.invoke(app, ["deploy"], env={"CI": "true"}) + + assert result.exit_code == 1 + assert "Refusing to deploy your home directory." in result.output + + +def test_deploy_asks_before_deploying_the_home_directory( + logged_in_cli: None, tmp_path: Path +) -> None: + with ( + changing_dir(tmp_path), + patch("pathlib.Path.home", return_value=tmp_path), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = [Keys.ENTER] # Accept the default: "No" + + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + assert "You are deploying your home directory" in result.output + assert "Deployment cancelled." in result.output + + +@pytest.mark.respx +def test_degraded_deployment_fails_verification( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + _setup_deployment_mocks( + respx_mock, app_data["id"], "some-team-id", deployment_data, tmp_path + ) + respx_mock.get(f"/deployments/{deployment_data['id']}").mock( + return_value=Response(200, json={**deployment_data, "status": "degraded"}) + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 1 + assert "Degraded" in result.output diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index c8f4bef..ae6506d 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -728,7 +728,7 @@ def test_overwrites_existing_workflow_when_confirmed( patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), patch("rich_toolkit.container.getchar") as mock_getchar, ): - mock_getchar.side_effect = [Keys.ENTER, Keys.ENTER, Keys.ENTER] + mock_getchar.side_effect = [Keys.ENTER, Keys.ENTER, Keys.LEFT_ARROW, Keys.ENTER] result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 @@ -768,8 +768,7 @@ def test_skips_writing_workflow_when_declined( mock_getchar.side_effect = [ Keys.ENTER, Keys.ENTER, - Keys.RIGHT_ARROW, - Keys.ENTER, + Keys.ENTER, # Keep the default: don't overwrite Keys.ENTER, ] result = runner.invoke(app, ["setup-ci"]) @@ -811,8 +810,7 @@ def test_renames_workflow_when_declined_and_new_name_given( mock_getchar.side_effect = [ Keys.ENTER, Keys.ENTER, - Keys.RIGHT_ARROW, - Keys.ENTER, + Keys.ENTER, # Keep the default: don't overwrite *"ci-deploy.yml", Keys.ENTER, ] diff --git a/tests/test_env_delete.py b/tests/test_env_delete.py index 3c08002..6c0a0c1 100644 --- a/tests/test_env_delete.py +++ b/tests/test_env_delete.py @@ -160,12 +160,17 @@ def test_shows_message_when_it_deletes( with ( changing_dir(configured_app), - patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]), + patch( + "rich_toolkit.container.getchar", side_effect=[Keys.LEFT_ARROW, Keys.ENTER] + ), ): result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) assert result.exit_code == 0 assert result.output == snapshot("""\ +Delete SOME_VAR? +○ Yes ● No + Delete SOME_VAR? ● Yes ○ No @@ -360,16 +365,13 @@ def test_shows_message_when_deletion_is_cancelled( changing_dir(configured_app), patch( "rich_toolkit.container.getchar", - side_effect=[Keys.RIGHT_ARROW, Keys.ENTER], + side_effect=[Keys.ENTER], # Default: "No" ), ): result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) assert result.exit_code == 0 assert result.output == snapshot("""\ -Delete SOME_VAR? -● Yes ○ No - Delete SOME_VAR? ○ Yes ● No diff --git a/tests/utils.py b/tests/utils.py index 9aef242..f966fac 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -79,6 +79,7 @@ def invoke( if sys.platform == "win32": class Keys: + LEFT_ARROW = "\xe0K" RIGHT_ARROW = "\xe0M" DOWN_ARROW = "\xe0P" ENTER = "\r" @@ -89,6 +90,7 @@ class Keys: else: class Keys: + LEFT_ARROW = "\x1b[D" RIGHT_ARROW = "\x1b[C" DOWN_ARROW = "\x1b[B" ENTER = "\r" diff --git a/uv.lock b/uv.lock index 3ca6caf..aa8f98d 100644 --- a/uv.lock +++ b/uv.lock @@ -382,7 +382,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], marker = "python_full_version < '3.13'", specifier = ">=2.7.4" }, { name = "pydantic", extras = ["email"], marker = "python_full_version == '3.13.*'", specifier = ">=2.8.0" }, { name = "pydantic", extras = ["email"], marker = "python_full_version >= '3.14'", specifier = ">=2.12.0" }, - { name = "rich-toolkit", specifier = ">=0.20.5" }, + { name = "rich-toolkit", specifier = ">=0.20.6" }, { name = "rignore", specifier = ">=0.5.1" }, { name = "sentry-sdk", specifier = ">=2.20.0" }, { name = "typer", specifier = ">=0.26.1" }, @@ -1209,16 +1209,16 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.20.5" +version = "0.20.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/1c/f134352beb393cc17e6241ecf0bf4dd41a6759e2e3971a69a6ad185b87a2/rich_toolkit-0.20.5.tar.gz", hash = "sha256:0c9e1c414ffb0720be26285d472e263d1e704b71d31a7e13274b9996db4969e1", size = 213207, upload-time = "2026-09-08T16:13:12.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/fe/e851890b72568a075a3574f760eed9014736b449fe104b16fd05a416c321/rich_toolkit-0.20.6.tar.gz", hash = "sha256:04768f19438df877c6279e6e668ab0d88b5fff5c7ef3ac302838d97f7b03472e", size = 213510, upload-time = "2026-10-08T08:51:01.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/5f/ee8c39750ed9837f54021a8a0eb96f971cd788c1a7d40931867d6d0518bd/rich_toolkit-0.20.5-py3-none-any.whl", hash = "sha256:e21fd616db9c0539d3c50f3433fb1173db95c19d3cbf215615e224182fad6c18", size = 39370, upload-time = "2026-09-08T16:13:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/79/fc/7c296a563a893b0a23a73923ce27965ae15f658dcb58c0bcdd463e1671a2/rich_toolkit-0.20.6-py3-none-any.whl", hash = "sha256:465453ba6f94f99401f59f79fb562827ba5f6b4d4759015d40019f5c67750262", size = 39466, upload-time = "2026-10-08T08:51:00.412Z" }, ] [[package]]