diff --git a/docs/api/cli.md b/docs/api/cli.md index 81b34e3..98be663 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -2,18 +2,20 @@ Command-line interface for `gopro-api`. Run `gopro-api --help` to see all subcommands. -## Commands +Built with [Typer](https://typer.tiangolo.com/); the Typer application is exposed as `gopro_api.cli.app` for embedding or testing. -::: gopro_api.cli.SearchCommand +## Entry point -::: gopro_api.cli.InfoCommand +::: gopro_api.cli.main -::: gopro_api.cli.PullCommand +## Application -## Builder +::: gopro_api.cli.app -::: gopro_api.cli.CliBuilder +## Commands -## Entry point +::: gopro_api.cli.search_command -::: gopro_api.cli.main +::: gopro_api.cli.info_command + +::: gopro_api.cli.pull_command diff --git a/gopro_api/cli.py b/gopro_api/cli.py index 873886f..c4ad1a8 100644 --- a/gopro_api/cli.py +++ b/gopro_api/cli.py @@ -2,15 +2,15 @@ from __future__ import annotations -import argparse import asyncio import json import os import sys -from abc import ABC, abstractmethod -from collections.abc import Sequence from datetime import datetime -from importlib.metadata import PackageNotFoundError, version +from importlib.metadata import PackageNotFoundError, version as package_version +from typing import Optional + +import typer from gopro_api.api.models import ( DEFAULT_FIELDS, @@ -24,14 +24,47 @@ from gopro_api.exceptions import NoVariationsError from gopro_api.utils import is_video_filename, pull_assets_for_response +app = typer.Typer( + name="gopro-api", + help="CLI for the unofficial GoPro cloud API (api.gopro.com).", + no_args_is_help=True, +) + def _version() -> str: try: - return version("gopro-api") + return package_version("gopro-api") except PackageNotFoundError: return "0.0.0" +def _version_callback(value: Optional[bool]) -> None: + if value: + typer.echo(f"gopro-api {_version()}") + raise typer.Exit() + + +@app.callback() +def _main_callback( + ctx: typer.Context, + timeout: float = typer.Option( + 60.0, + "--timeout", + help="HTTP timeout in seconds (default: 60)", + ), + show_version: Optional[bool] = typer.Option( + None, + "--version", + callback=_version_callback, + is_eager=True, + help="Show version and exit.", + ), +) -> None: + del show_version # handled by eager callback (--version exits before commands) + ctx.ensure_object(dict) + ctx.obj["timeout"] = timeout + + def _parse_dt(raw: str) -> datetime: """Parse a CLI date or datetime string. @@ -50,21 +83,11 @@ def _parse_dt(raw: str) -> datetime: return datetime.fromisoformat(raw.replace("Z", "+00:00")) -def _positive_int(raw: str) -> int: - """Argparse type: strictly positive integer. - - Args: - raw: Decimal digits only. - - Returns: - Parsed integer ``>= 1``. - - Raises: - argparse.ArgumentTypeError: If ``raw`` is not a positive integer. - """ - value = int(raw) +def _validate_positive_px(value: Optional[int], flag: str) -> Optional[int]: + if value is None: + return None if value <= 0: - raise argparse.ArgumentTypeError("must be a positive integer") + raise typer.BadParameter("must be a positive integer", param_hint=flag) return value @@ -72,14 +95,15 @@ def _require_token() -> None: """Ensure ``GP_ACCESS_TOKEN`` is configured. Raises: - SystemExit: With code ``2`` if the token is missing. + typer.Exit: With code ``2`` if the token is missing. """ if not GP_ACCESS_TOKEN: - sys.stderr.write( + typer.echo( "error: GP_ACCESS_TOKEN is not set. " - "Add it to your environment or a .env file.\n", + "Add it to your environment or a .env file.", + err=True, ) - raise SystemExit(2) + raise typer.Exit(2) def _print_search_plain_header() -> None: @@ -115,281 +139,232 @@ def _print_search_plain_page( print(_format_search_item_plain(item)) -class CliSubcommand(ABC): - """Abstract CLI subcommand (parser wiring + async runner).""" - - name: str - help: str - - @abstractmethod - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - """Attach arguments to this subcommand's ``ArgumentParser``. - - Args: - parser: Subparser instance from ``add_subparsers``. - """ - - @abstractmethod - async def run(self, args: argparse.Namespace) -> None: - """Execute the subcommand. - - Args: - args: Parsed namespace including global flags such as ``timeout``. - - Raises: - SystemExit: For user-facing fatal errors (for example missing token). - """ - +async def _run_search( + *, + timeout: float, + start: str, + end: str, + page: int, + per_page: int, + all_pages: bool, + json_out: bool, +) -> None: + _require_token() + start_dt = _parse_dt(start) + end_dt = _parse_dt(end) + + async with AsyncGoProClient(timeout=timeout) as client: + if all_pages: + all_pages_payload: list[dict] = [] + first_plain_page = True + async for page_result in client.iter_nonempty_search_pages( + start_dt, + end_dt, + per_page=per_page, + start_page=page, + ): + if json_out: + all_pages_payload.append( + page_result.model_dump(by_alias=True, mode="json"), + ) + else: + _print_search_plain_page( + page_result, + print_header=first_plain_page, + ) + first_plain_page = False + if json_out: + print(json.dumps(all_pages_payload, indent=2)) + return + + params = GoProMediaSearchParams( + captured_range=CapturedRange(start=start_dt, end=end_dt), + page=page, + per_page=per_page, + ) + page_result = await client.search(params) + if json_out: + print( + json.dumps( + page_result.model_dump(by_alias=True, mode="json"), + indent=2, + ), + ) + else: + _print_search_plain_page(page_result) -class SearchCommand(CliSubcommand): - """``search`` — list cloud media in a capture window (plain TSV or JSON).""" - name = "search" - help = ( +@app.command( + "search", + help=( "List media in a capture date range (tab-separated fields; " "use --json for raw API payloads)" + ), +) +def search_command( + ctx: typer.Context, + *, + start: str = typer.Option( + ..., + "--start", + help="Range start: YYYY-MM-DD or ISO datetime", + ), + end: str = typer.Option( + ..., + "--end", + help=( + "Range end: YYYY-MM-DD or ISO datetime " + "(API treats range as in query string)" + ), + ), + page: int = typer.Option(1, "--page", help="Page number (default: 1)"), + per_page: int = typer.Option(30, "--per-page", help="Page size (default: 30)"), + all_pages: bool = typer.Option( + False, + "--all-pages", + help="Keep requesting pages until a page returns no media", + ), + json_out: bool = typer.Option( + False, + "--json", + help="Print full API JSON (with --all-pages: list of page payloads)", + ), +) -> None: + """Run search against the cloud API and print results.""" + timeout = ctx.obj["timeout"] + asyncio.run( + _run_search( + timeout=timeout, + start=start, + end=end, + page=page, + per_page=per_page, + all_pages=all_pages, + json_out=json_out, + ), ) - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--start", - required=True, - help="Range start: YYYY-MM-DD or ISO datetime", - ) - parser.add_argument( - "--end", - required=True, - help=( - "Range end: YYYY-MM-DD or ISO datetime " - "(API treats range as in query string)" + +async def _run_info( + *, + timeout: float, + media_id: str, + json_out: bool, +) -> None: + _require_token() + async with AsyncGoProClient(timeout=timeout) as client: + meta = await client.download(media_id) + if json_out: + print( + json.dumps( + meta.model_dump(by_alias=True, mode="json"), + indent=2, ), ) - parser.add_argument( - "--page", type=int, default=1, help="Page number (default: 1)" - ) - parser.add_argument( - "--per-page", - type=int, - default=30, - metavar="N", - help="Page size (default: 30)", - ) - parser.add_argument( - "--all-pages", - action="store_true", - help="Keep requesting pages until a page returns no media", + else: + print(meta.filename) + media_list = ( + meta.embedded.variations + if is_video_filename(meta.filename) + else meta.embedded.files ) - parser.add_argument( - "--json", - action="store_true", - help="Print full API JSON (with --all-pages: list of page payloads)", - ) - - async def run(self, args: argparse.Namespace) -> None: - """Run search against the cloud API and print results.""" - _require_token() - start = _parse_dt(args.start) - end = _parse_dt(args.end) - - async with AsyncGoProClient(timeout=args.timeout) as client: - if args.all_pages: - all_pages: list[dict] = [] - first_plain_page = True - async for page_result in client.iter_nonempty_search_pages( - start, end, per_page=args.per_page, start_page=args.page - ): - if args.json: - all_pages.append( - page_result.model_dump(by_alias=True, mode="json"), - ) - else: - _print_search_plain_page( - page_result, - print_header=first_plain_page, - ) - first_plain_page = False - if args.json: - print(json.dumps(all_pages, indent=2)) - return - - params = GoProMediaSearchParams( - captured_range=CapturedRange(start=start, end=end), - page=args.page, - per_page=args.per_page, - ) - page_result = await client.search(params) - if args.json: + for idx, media_item in enumerate(media_list): print( - json.dumps( - page_result.model_dump(by_alias=True, mode="json"), - indent=2, - ), + f" {idx:>3} {media_item.width}x{media_item.height} " + f"{media_item.url}", ) - else: - _print_search_plain_page(page_result) -class InfoCommand(CliSubcommand): - """``info`` — print download metadata (URLs and sizes) for one media id.""" - - name = "info" - help = "Show download metadata (URLs, sizes) for one media id" +@app.command("info", help="Show download metadata (URLs, sizes) for one media id") +def info_command( + ctx: typer.Context, + media_id: str = typer.Argument(..., help="Media id from search"), + json_out: bool = typer.Option(False, "--json", help="Print full API JSON"), +) -> None: + """Fetch and display download metadata for ``media_id``.""" + asyncio.run( + _run_info(timeout=ctx.obj["timeout"], media_id=media_id, json_out=json_out), + ) - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument("media_id", help="Media id from search") - parser.add_argument( - "--json", - action="store_true", - help="Print full API JSON", - ) - async def run(self, args: argparse.Namespace) -> None: - """Fetch and display download metadata for ``args.media_id``.""" - _require_token() - async with AsyncGoProClient(timeout=args.timeout) as client: - meta = await client.download(args.media_id) - if args.json: - print( - json.dumps( - meta.model_dump(by_alias=True, mode="json"), - indent=2, - ), - ) - else: - print(meta.filename) - media_list = ( - meta.embedded.variations - if is_video_filename(meta.filename) - else meta.embedded.files +async def _run_pull( + *, + timeout: float, + media_id: str, + destination: str, + height: Optional[int], + width: Optional[int], +) -> None: + _require_token() + async with AsyncGoProClient(timeout=timeout) as client: + meta = await client.download(media_id) + try: + assets = pull_assets_for_response( + meta, + target_height=height, + target_width=width, ) - for idx, media_item in enumerate(media_list): - print( - f" {idx:>3} {media_item.width}x{media_item.height} " - f"{media_item.url}", - ) - - -class PullCommand(CliSubcommand): - """``pull`` — download resolved assets for one media id into a directory.""" - - name = "pull" - help = "Download files from a media id" - - def add_arguments(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument("media_id", help="Media id from search") - parser.add_argument("destination", help="Path to save the file") - parser.add_argument( - "--height", - type=_positive_int, - default=None, - metavar="PX", - help=( - "For video: pick the variation whose height is closest to PX " - "(default: tallest)" - ), - ) - parser.add_argument( - "--width", - type=_positive_int, - default=None, - metavar="PX", - help=( - "For video: pick the variation whose width is closest to PX " - "(default: tallest)" - ), - ) - - async def run(self, args: argparse.Namespace) -> None: - """Download all resolved files for ``args.media_id`` into ``args.destination``. - - Raises: - SystemExit: Code ``2`` if the token is missing or no video variations exist. - """ - _require_token() - async with AsyncGoProClient(timeout=args.timeout) as client: - meta = await client.download(args.media_id) - try: - assets = pull_assets_for_response( - meta, - target_height=args.height, - target_width=args.width, - ) - except NoVariationsError as exc: - sys.stderr.write(f"error: {exc}\n") - raise SystemExit(2) from exc - - os.makedirs(args.destination, exist_ok=True) - await asyncio.gather( - *( - client.download_url_to_path( - asset.url, - os.path.join(args.destination, filename), - ) - for filename, asset in assets.items() + except NoVariationsError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(2) from exc + + os.makedirs(destination, exist_ok=True) + await asyncio.gather( + *( + client.download_url_to_path( + asset.url, + os.path.join(destination, filename), ) + for filename, asset in assets.items() ) - - -class CliBuilder: # pylint: disable=too-few-public-methods - """Build ``argparse`` CLI from a sequence of ``CliSubcommand`` implementations.""" - - def __init__(self, commands: Sequence[CliSubcommand]) -> None: - """Store commands to expose as subparsers. - - Args: - commands: Non-empty sequence of subcommand instances. - """ - self._commands = list(commands) - - def build(self) -> argparse.ArgumentParser: - """Construct the root parser with globals and subcommands. - - Returns: - Configured ``ArgumentParser`` (not yet ``parse_args``). - """ - parser = argparse.ArgumentParser( - prog="gopro-api", - description="CLI for the unofficial GoPro cloud API (api.gopro.com).", - ) - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {_version()}", - ) - parser.add_argument( - "--timeout", - type=float, - default=60.0, - help="HTTP timeout in seconds (default: 60)", ) - sub = parser.add_subparsers(dest="command", required=True) - for cmd in self._commands: - subparser = sub.add_parser(cmd.name, help=cmd.help) - cmd.add_arguments(subparser) - subparser.set_defaults(func=cmd.run) - return parser +@app.command("pull", help="Download files from a media id") +def pull_command( + ctx: typer.Context, + media_id: str = typer.Argument(..., help="Media id from search"), + destination: str = typer.Argument(..., help="Path to save the file"), + height: Optional[int] = typer.Option( + None, + "--height", + metavar="PX", + help=( + "For video: pick the variation whose height is closest to PX " + "(default: tallest)" + ), + ), + width: Optional[int] = typer.Option( + None, + "--width", + metavar="PX", + help=( + "For video: pick the variation whose width is closest to PX " + "(default: tallest)" + ), + ), +) -> None: + """Download all resolved files for ``media_id`` into ``destination``.""" + height = _validate_positive_px(height, "--height") + width = _validate_positive_px(width, "--width") + asyncio.run( + _run_pull( + timeout=ctx.obj["timeout"], + media_id=media_id, + destination=destination, + height=height, + width=width, + ), + ) -def main(argv: list[str] | None = None) -> None: - """CLI entrypoint: parse ``argv`` and run the selected async subcommand. +def main(argv: Optional[list[str]] = None) -> None: + """CLI entrypoint: parse ``argv`` and run the selected command. Args: argv: Argument list (defaults to ``sys.argv[1:]`` when ``None``). - - Raises: - SystemExit: On argparse errors or explicit ``SystemExit`` from commands. """ - builder = CliBuilder( - [ - SearchCommand(), - InfoCommand(), - PullCommand(), - ], - ) - args = builder.build().parse_args(argv) - asyncio.run(args.func(args)) + app(args=argv) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index a961956..75caf8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gopro-api" -version = "0.0.7" +version = "0.0.8" description = "Unofficial Python client for the GoPro cloud API (api.gopro.com): sync and async clients, Pydantic models, and a CLI." readme = "README.md" license = { file = "LICENSE" } @@ -30,6 +30,7 @@ dependencies = [ "pydantic~=2.10.6", "pydantic-settings~=2.14", "requests~=2.32.3", + "typer>=0.12", ] [project.urls] diff --git a/uv.lock b/uv.lock index 3b34492..6de19b6 100644 --- a/uv.lock +++ b/uv.lock @@ -111,6 +111,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -485,13 +494,14 @@ wheels = [ [[package]] name = "gopro-api" -version = "0.0.7" +version = "0.0.8" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "requests" }, + { name = "typer" }, ] [package.optional-dependencies] @@ -516,6 +526,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = "~=2.14" }, { name = "pylint", marker = "extra == 'dev'", specifier = "~=3.3.0" }, { name = "requests", specifier = "~=2.32.3" }, + { name = "typer", specifier = ">=0.12" }, ] provides-extras = ["dev", "docs"] @@ -567,6 +578,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -661,6 +684,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -1369,6 +1401,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1441,6 +1495,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"