diff --git a/pyproject.toml b/pyproject.toml index 095716e..f20915d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - # Cap typer below 0.26: starting in 0.26.0 typer vendored click privately - # (typer._click), so `import click` no longer resolves transitively and - # external-click handles stop sharing typer's Context thread-local. Lift - # this cap only alongside a migration off direct click imports. - "typer>=0.12.0,<0.26", + "typer>=0.26.0", "rich>=13.0.0", "httpx>=0.27.0", "orjson>=3.10.0", diff --git a/src/fulfil_cli/cli/app.py b/src/fulfil_cli/cli/app.py index 30ac08f..dc77ad8 100644 --- a/src/fulfil_cli/cli/app.py +++ b/src/fulfil_cli/cli/app.py @@ -10,12 +10,13 @@ from __future__ import annotations -import click import typer import typer.core +import typer.main from rich.console import Console from fulfil_cli import __version__ +from fulfil_cli.cli import state from fulfil_cli.cli.commands import auth, config from fulfil_cli.cli.commands.api import api_cmd from fulfil_cli.cli.commands.common import handle_error @@ -29,37 +30,37 @@ console = Console(stderr=True) -class ReportGroup(click.Group): - """Click group that resolves unknown subcommands as report names.""" +class ReportGroup(typer.core.TyperGroup): + """Typer group that resolves unknown subcommands as report names.""" - def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + def get_command(self, ctx: typer.Context, cmd_name: str): rv = super().get_command(ctx, cmd_name) if rv is not None: return rv - return create_report_group(cmd_name) + return typer.main.get_command(create_report_group(cmd_name)) class FulfilGroup(typer.core.TyperGroup): - """Custom Click group that resolves unknown subcommands as model or report names.""" + """Custom Typer group that resolves unknown subcommands as model or report names.""" _dynamic_commands = ("models", "reports") - def list_commands(self, ctx: click.Context) -> list[str]: + def list_commands(self, ctx: typer.Context) -> list[str]: commands = super().list_commands(ctx) for name in self._dynamic_commands: if name not in commands: commands.append(name) return commands - def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + def get_command(self, ctx: typer.Context, cmd_name: str): rv = super().get_command(ctx, cmd_name) if rv is not None: return rv if cmd_name == "models": - return models_group + return typer.main.get_command(models_group) if cmd_name == "reports": - return reports_group - return create_model_group(cmd_name) + return typer.main.get_command(reports_group) + return typer.main.get_command(create_model_group(cmd_name)) app = typer.Typer( @@ -130,6 +131,7 @@ def main_callback( quiet=quiet, output_format=output_format, ) + state._set_current(ctx.obj) FORMAT_OPTION = typer.Option( @@ -185,7 +187,7 @@ def _flatten_model_row(row: dict) -> dict: def _list_models(fmt: str, search: str | None = None) -> None: """Fetch and display all available models.""" - app_ctx: AppContext = click.get_current_context().obj + app_ctx: AppContext = state._get_current() try: client = app_ctx.get_client() result = client.call("system.list_models") @@ -211,11 +213,17 @@ def _list_models(fmt: str, search: str | None = None) -> None: output(result, fmt=fmt, title="Available Models") -@click.group(name="models", help="List available models.", invoke_without_command=True) -@click.option("--search", "-s", default=None, help="Filter by name, description, or category.") -@format_option -@click.pass_context -def models_group(ctx: click.Context, search: str | None, output_format: str | None) -> None: +models_group = typer.Typer(name="models", help="List available models.") + + +@models_group.callback(invoke_without_command=True) +def _models_callback( + ctx: typer.Context, + search: str | None = typer.Option( + None, "--search", "-s", help="Filter by name, description, or category." + ), + output_format: str | None = format_option, +) -> None: """List all available models.""" if ctx.invoked_subcommand is None: app_ctx: AppContext = ctx.obj @@ -223,10 +231,13 @@ def models_group(ctx: click.Context, search: str | None, output_format: str | No @models_group.command("list") -@click.option("--search", "-s", default=None, help="Filter by name, description, or category.") -@format_option -@click.pass_context -def models_list_cmd(ctx: click.Context, search: str | None, output_format: str | None) -> None: +def models_list_cmd( + ctx: typer.Context, + search: str | None = typer.Option( + None, "--search", "-s", help="Filter by name, description, or category." + ), + output_format: str | None = format_option, +) -> None: """List all available models.""" app_ctx: AppContext = ctx.obj _list_models(app_ctx.get_effective_format(output_format), search=search) @@ -234,7 +245,7 @@ def models_list_cmd(ctx: click.Context, search: str | None, output_format: str | def _list_reports(fmt: str) -> None: """Fetch and display all available reports.""" - app_ctx: AppContext = click.get_current_context().obj + app_ctx: AppContext = state._get_current() try: client = app_ctx.get_client() result = client.call("system.list_reports") @@ -244,15 +255,18 @@ def _list_reports(fmt: str) -> None: output(result, fmt=fmt, title="Available Reports") -@click.group( +reports_group = typer.Typer( name="reports", cls=ReportGroup, help="Interact with reports.", - invoke_without_command=True, ) -@format_option -@click.pass_context -def reports_group(ctx: click.Context, output_format: str | None) -> None: + + +@reports_group.callback(invoke_without_command=True) +def _reports_callback( + ctx: typer.Context, + output_format: str | None = format_option, +) -> None: """List all available reports.""" if ctx.invoked_subcommand is None: app_ctx: AppContext = ctx.obj @@ -260,9 +274,10 @@ def reports_group(ctx: click.Context, output_format: str | None) -> None: @reports_group.command("list") -@format_option -@click.pass_context -def reports_list_cmd(ctx: click.Context, output_format: str | None) -> None: +def reports_list_cmd( + ctx: typer.Context, + output_format: str | None = format_option, +) -> None: """List all available reports.""" app_ctx: AppContext = ctx.obj _list_reports(app_ctx.get_effective_format(output_format)) diff --git a/src/fulfil_cli/cli/commands/common.py b/src/fulfil_cli/cli/commands/common.py index f9b625b..a5ed0ac 100644 --- a/src/fulfil_cli/cli/commands/common.py +++ b/src/fulfil_cli/cli/commands/common.py @@ -6,11 +6,10 @@ import sys from typing import Any, NoReturn -import click import typer from rich.console import Console -from fulfil_cli.cli.state import AppContext +from fulfil_cli.cli import state from fulfil_cli.client.errors import EXIT_VALIDATION, FulfilError, ValidationError from fulfil_cli.output.json_output import print_json @@ -20,12 +19,11 @@ def handle_error(exc: FulfilError, *, context: str | None = None) -> NoReturn: """Format a FulfilError for the user and exit. - Uses the current Click context to determine output format. + Uses the current app context to determine output format. When *context* is provided (e.g. a model or report name), it is included in the output for orientation. """ - ctx = click.get_current_context(silent=True) - app_ctx: AppContext | None = ctx.obj if ctx else None + app_ctx = state._get_current() if app_ctx and app_ctx.get_effective_format() != "table": err = exc.to_dict() diff --git a/src/fulfil_cli/cli/commands/model.py b/src/fulfil_cli/cli/commands/model.py index 829de26..85b8686 100644 --- a/src/fulfil_cli/cli/commands/model.py +++ b/src/fulfil_cli/cli/commands/model.py @@ -4,9 +4,8 @@ import difflib import sys -from typing import IO, Any +from typing import Any -import click import typer from rich.console import Console @@ -52,59 +51,48 @@ def _parse_order(value: str) -> dict[str, str]: return result -def create_model_group(model_name: str) -> click.Group: - """Create a Click group for a model with all standard actions.""" +def create_model_group(model_name: str) -> typer.Typer: + """Create a Typer app for a model with all standard actions.""" - @click.group(name=model_name, help=f"Interact with {model_name} records.") - @click.pass_context - def model_group(ctx: click.Context) -> None: - pass + model_group = typer.Typer(name=model_name, help=f"Interact with {model_name} records.") @model_group.command("list") - @click.option( - "--where", - default=None, - help=( - "MongoDB-style JSON filter. " - 'Equality: \'{"state": "confirmed"}\'. ' - "Operators (gt, gte, lt, lte, ne, in, not_in, contains, startswith, endswith): " - '\'{"total_amount": {"gte": 100}}\'. ' - 'OR logic: \'{"or": [{"state": "draft"}, {"state": "confirmed"}]}\'' + def list_cmd( + ctx: typer.Context, + where: str | None = typer.Option( + None, + "--where", + help=( + "MongoDB-style JSON filter. " + 'Equality: \'{"state": "confirmed"}\'. ' + "Operators (gt, gte, lt, lte, ne, in, not_in, contains, startswith, endswith): " + '\'{"total_amount": {"gte": 100}}\'. ' + 'OR logic: \'{"or": [{"state": "draft"}, {"state": "confirmed"}]}\'' + ), ), - ) - @click.option( - "--fields", - "fields_str", - default=None, - help="Comma-separated field names, e.g. name,state,sale_date", - ) - @click.option( - "--order", - default=None, - help=( - "Sort order as field:direction pairs, comma-separated. " - "Direction is ASC or DESC (default: ASC). " - "Examples: sale_date:desc or sale_date:desc,name:asc or name" + fields_str: str | None = typer.Option( + None, + "--fields", + help="Comma-separated field names, e.g. name,state,sale_date", ), - ) - @click.option( - "--cursor", - default=None, - help="Opaque cursor for fetching the next page (from previous response).", - ) - @click.option( - "--page-size", "--limit", default=20, type=int, help="Records per page (default: 20)" - ) - @format_option - @click.pass_context - def list_cmd( - ctx: click.Context, - where: str | None, - fields_str: str | None, - order: str | None, - cursor: str | None, - page_size: int, - output_format: str | None, + order: str | None = typer.Option( + None, + "--order", + help=( + "Sort order as field:direction pairs, comma-separated. " + "Direction is ASC or DESC (default: ASC). " + "Examples: sale_date:desc or sale_date:desc,name:asc or name" + ), + ), + cursor: str | None = typer.Option( + None, + "--cursor", + help="Opaque cursor for fetching the next page (from previous response).", + ), + page_size: int = typer.Option( + 20, "--page-size", "--limit", help="Records per page (default: 20)" + ), + output_format: str | None = format_option, ) -> None: """List records matching filters. @@ -163,10 +151,11 @@ def list_cmd( output(result, fmt=fmt, title=model_name) @model_group.command("get") - @click.argument("ids", type=str) - @format_option - @click.pass_context - def get_cmd(ctx: click.Context, ids: str, output_format: str | None) -> None: + def get_cmd( + ctx: typer.Context, + ids: str = typer.Argument(...), + output_format: str | None = format_option, + ) -> None: """Get records by ID(s). IDS is one or more comma-separated integers (e.g. 123 or 1,2,3).""" app_ctx: AppContext = ctx.obj parsed_ids = _parse_ids(ids) @@ -183,10 +172,11 @@ def get_cmd(ctx: click.Context, ids: str, output_format: str | None) -> None: output(result, fmt=app_ctx.get_effective_format(output_format), title=model_name) @model_group.command("create") - @click.argument("data", type=click.File("r"), default="-") - @format_option - @click.pass_context - def create_cmd(ctx: click.Context, data: IO[str], output_format: str | None) -> None: + def create_cmd( + ctx: typer.Context, + data: typer.FileText = typer.Argument("-"), + output_format: str | None = format_option, + ) -> None: """Create record(s) from JSON. Accepts a single object or an array. \b @@ -211,11 +201,12 @@ def create_cmd(ctx: click.Context, data: IO[str], output_format: str | None) -> output(result, fmt=app_ctx.get_effective_format(output_format)) @model_group.command("update") - @click.argument("ids", type=str) - @click.argument("data", type=click.File("r"), default="-") - @format_option - @click.pass_context - def update_cmd(ctx: click.Context, ids: str, data: IO[str], output_format: str | None) -> None: + def update_cmd( + ctx: typer.Context, + ids: str = typer.Argument(...), + data: typer.FileText = typer.Argument("-"), + output_format: str | None = format_option, + ) -> None: """Update record(s) by ID. \b @@ -239,10 +230,11 @@ def update_cmd(ctx: click.Context, ids: str, data: IO[str], output_format: str | output(result, fmt=app_ctx.get_effective_format(output_format)) @model_group.command("delete") - @click.argument("ids", type=str) - @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") - @click.pass_context - def delete_cmd(ctx: click.Context, ids: str, yes: bool) -> None: + def delete_cmd( + ctx: typer.Context, + ids: str = typer.Argument(...), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), + ) -> None: """Permanently delete record(s) by ID. This cannot be undone. IDS is one or more comma-separated integers. @@ -258,7 +250,7 @@ def delete_cmd(ctx: click.Context, ids: str, yes: bool) -> None: ) raise typer.Exit(code=EXIT_USAGE) id_list = ", ".join(str(i) for i in parsed_ids) - if not click.confirm( + if not typer.confirm( f"Delete {len(parsed_ids)} record(s) from {model_name} ({id_list})?" ): console.print("[dim]Aborted.[/dim]") @@ -274,17 +266,18 @@ def delete_cmd(ctx: click.Context, ids: str, yes: bool) -> None: console.print(f"[green]Deleted {len(parsed_ids)} record(s).[/green]") @model_group.command("count") - @click.option( - "--where", - default=None, - help=( - "MongoDB-style JSON filter (same syntax as list --where). " - 'Example: \'{"state": "confirmed"}\'' + def count_cmd( + ctx: typer.Context, + where: str | None = typer.Option( + None, + "--where", + help=( + "MongoDB-style JSON filter (same syntax as list --where). " + 'Example: \'{"state": "confirmed"}\'' + ), ), - ) - @format_option - @click.pass_context - def count_cmd(ctx: click.Context, where: str | None, output_format: str | None) -> None: + output_format: str | None = format_option, + ) -> None: """Count records matching filters. Returns a single integer.""" app_ctx: AppContext = ctx.obj params: dict[str, Any] = {} @@ -304,25 +297,20 @@ def count_cmd(ctx: click.Context, where: str | None, output_format: str | None) console.print(str(result)) @model_group.command("call") - @click.argument("method_name", type=str) - @click.option( - "--ids", - default=None, - help="Comma-separated record IDs to pass to the method, e.g. 1,2,3", - ) - @click.option( - "--data", - default=None, - help=("Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'"), - ) - @format_option - @click.pass_context def call_cmd( - ctx: click.Context, - method_name: str, - ids: str | None, - data: str | None, - output_format: str | None, + ctx: typer.Context, + method_name: str = typer.Argument(...), + ids: str | None = typer.Option( + None, + "--ids", + help="Comma-separated record IDs to pass to the method, e.g. 1,2,3", + ), + data: str | None = typer.Option( + None, + "--data", + help=("Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'"), + ), + output_format: str | None = format_option, ) -> None: """Call a custom method on the model. @@ -352,11 +340,10 @@ def call_cmd( output(result, fmt=app_ctx.get_effective_format(output_format)) @model_group.command("describe") - @click.argument("endpoint_name", required=False, default=None) - @format_option - @click.pass_context def describe_cmd( - ctx: click.Context, endpoint_name: str | None, output_format: str | None + ctx: typer.Context, + endpoint_name: str | None = typer.Argument(None), + output_format: str | None = format_option, ) -> None: """Describe the model, or a specific endpoint. @@ -379,10 +366,9 @@ def describe_cmd( output_model_describe(result, fmt=fmt) @model_group.command("fields") - @click.pass_context - def fields_cmd(ctx: click.Context) -> None: + def fields_cmd(ctx: typer.Context) -> None: """Alias for 'describe'.""" - ctx.invoke(describe_cmd, endpoint_name=None) + describe_cmd(ctx, endpoint_name=None, output_format=None) return model_group diff --git a/src/fulfil_cli/cli/commands/report.py b/src/fulfil_cli/cli/commands/report.py index afbc054..76a028b 100644 --- a/src/fulfil_cli/cli/commands/report.py +++ b/src/fulfil_cli/cli/commands/report.py @@ -5,7 +5,7 @@ import sys from typing import Any -import click +import typer from rich.console import Console from rich.prompt import Confirm, IntPrompt, Prompt @@ -81,71 +81,61 @@ def _prompt_params( return result -def create_report_group(report_name: str) -> click.Group: - """Create a Click group for a report with execute and describe actions.""" +def create_report_group(report_name: str) -> typer.Typer: + """Create a Typer app for a report with execute and describe actions.""" - @click.group( + report_group = typer.Typer( name=report_name, help=f"Interact with {report_name} report.", - invoke_without_command=True, ) - @click.option( - "--params", - default=None, - help=( - "Report parameters as a JSON object. " - """Example: '{"date_from": "2024-01-01", "date_to": "2024-12-31", "warehouse": 1}'""" + + @report_group.callback(invoke_without_command=True) + def report_callback( + ctx: typer.Context, + params: str | None = typer.Option( + None, + "--params", + help=( + "Report parameters as a JSON object. " + 'Example: \'{"date_from": "2024-01-01", "date_to": "2024-12-31", ' + '"warehouse": 1}\'' + ), ), - ) - @click.option( - "-i", - "--interactive", - is_flag=True, - default=False, - help=( - "Fetch the report schema and interactively prompt for each parameter. " - "Use 'describe' subcommand to see the schema without executing." + interactive: bool = typer.Option( + False, + "-i", + "--interactive", + help=( + "Fetch the report schema and interactively prompt for each parameter. " + "Use 'describe' subcommand to see the schema without executing." + ), ), - ) - @format_option - @click.pass_context - def report_group( - ctx: click.Context, - params: str | None, - interactive: bool, - output_format: str | None, + output_format: str | None = format_option, ) -> None: if ctx.invoked_subcommand is None: - ctx.invoke( - execute_cmd, params=params, interactive=interactive, output_format=output_format - ) + execute_cmd(ctx, params=params, interactive=interactive, output_format=output_format) @report_group.command("execute") - @click.option( - "--params", - default=None, - help=( - "Report parameters as a JSON object. " - """Example: '{"date_from": "2024-01-01", "date_to": "2024-12-31"}'""" + def execute_cmd( + ctx: typer.Context, + params: str | None = typer.Option( + None, + "--params", + help=( + "Report parameters as a JSON object. " + """Example: '{"date_from": "2024-01-01", "date_to": "2024-12-31"}'""" + ), ), - ) - @click.option( - "-i", - "--interactive", - is_flag=True, - default=False, - help=( - "Fetch the report schema and interactively prompt for each parameter. " - "Use 'describe' subcommand to see the schema without executing." + interactive: bool = typer.Option( + False, + "-i", + "--interactive", + help=( + "Fetch the report schema and interactively prompt for each parameter. " + "Use 'describe' subcommand to see the schema without executing." + ), ), - ) - @format_option - @click.pass_context - def execute_cmd( - ctx: click.Context, - params: str | None, - interactive: bool, - output_format: str | None, + output_format: str | None = format_option, ) -> None: """Execute the report with given parameters.""" app_ctx: AppContext = ctx.obj @@ -193,9 +183,10 @@ def execute_cmd( output_report(result, fmt=app_ctx.get_effective_format(output_format)) @report_group.command("describe") - @format_option - @click.pass_context - def describe_cmd(ctx: click.Context, output_format: str | None) -> None: + def describe_cmd( + ctx: typer.Context, + output_format: str | None = format_option, + ) -> None: """Show the report's parameter description.""" app_ctx: AppContext = ctx.obj diff --git a/src/fulfil_cli/cli/state.py b/src/fulfil_cli/cli/state.py index 5442cc1..cc11d74 100644 --- a/src/fulfil_cli/cli/state.py +++ b/src/fulfil_cli/cli/state.py @@ -6,7 +6,7 @@ import sys from dataclasses import dataclass, field -import click +import typer from fulfil_cli.auth.api_key import resolve_credentials, resolve_workspace from fulfil_cli.auth.keyring_store import get_oauth_tokens, store_oauth_tokens @@ -88,19 +88,35 @@ def _refresh() -> str: return self._client -def get_app_ctx() -> AppContext: - """Get AppContext from the current Click context.""" - return click.get_current_context().obj +_current: AppContext | None = None + + +def _set_current(ctx: AppContext) -> None: + global _current + _current = ctx + + +def _get_current() -> AppContext | None: + return _current VALID_FORMATS = ("table", "json", "csv", "ndjson") -format_option = click.option( + +def _validate_format(value: str | None) -> str | None: + if value is None: + return None + lowered = value.lower() + if lowered not in VALID_FORMATS: + raise typer.BadParameter( + f"Invalid format '{value}'. Choose from: {', '.join(VALID_FORMATS)}" + ) + return lowered + + +format_option = typer.Option( + None, "--format", - "output_format", - type=click.Choice(VALID_FORMATS, case_sensitive=False), - default=None, - expose_value=True, - is_eager=False, - help="Output format (default: table for TTY, json when piped)", + callback=_validate_format, + help="Output format: table, json, csv, ndjson (default: table for TTY, json when piped)", ) diff --git a/tests/test_cli/test_model_commands.py b/tests/test_cli/test_model_commands.py index c03f2df..13170ce 100644 --- a/tests/test_cli/test_model_commands.py +++ b/tests/test_cli/test_model_commands.py @@ -4,8 +4,8 @@ import json -import click import pytest +import typer from typer.testing import CliRunner from fulfil_cli.cli.app import app @@ -41,7 +41,7 @@ def test_multiple_ids(self): assert _parse_ids("1,2,3") == [1, 2, 3] def test_invalid_raises_exit(self): - with pytest.raises(click.exceptions.Exit) as exc_info: + with pytest.raises(typer.Exit) as exc_info: _parse_ids("abc") assert exc_info.value.exit_code == 2 diff --git a/uv.lock b/uv.lock index 31e2b0c..f748731 100644 --- a/uv.lock +++ b/uv.lock @@ -105,18 +105,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -357,7 +345,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.0.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tomli-w", specifier = ">=1.0.0" }, - { name = "typer", specifier = ">=0.12.0,<0.26" }, + { name = "typer", specifier = ">=0.26.0" }, ] [package.metadata.requires-dev] @@ -946,17 +934,17 @@ wheels = [ [[package]] name = "typer" -version = "0.24.0" +version = "0.26.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/b6/3e681d3b6bb22647509bdbfdd18055d5adc0dce5c5585359fa46ff805fdc/typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504", size = 118380, upload-time = "2026-02-16T22:08:48.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/1a/2cf40b65b1d9c254fe5814bb0519f9b8f2ac38059df0810f9b866300c04a/typer-0.26.5.tar.gz", hash = "sha256:9b9b39e35c3afc9e1e51a06f21155246e457c0911279b09b35d8210ca74b935c", size = 201494, upload-time = "2026-06-01T14:42:49.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/d0/4da85c2a45054bb661993c93524138ace4956cb075a7ae0c9d1deadc331b/typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8", size = 56441, upload-time = "2026-02-16T22:08:47.535Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d6/baac76fc04a6532883de3d8722c7f921dae94d10965e7ffba9e38e42a251/typer-0.26.5-py3-none-any.whl", hash = "sha256:4bfd901d564e41608920134aa5d4481200f4ba76d98e982d9f9d32dcb7b84da0", size = 122451, upload-time = "2026-06-01T14:42:51.021Z" }, ] [[package]]