diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 83e364e..8703937 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -37,3 +37,27 @@ jobs: - name: Test run: pnpm run test + python_checks: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up uv package manager + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.14" + enable-cache: true + + - name: Install Python dev dependencies + run: uv sync --group dev + + - name: Ruff format + run: uv run ruff format --check + + - name: Ruff lint + run: uv run ruff check + + - name: Type check + run: uv run ty check + diff --git a/.gitignore b/.gitignore index ad08369..18c5ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ .mypy_cache node_modules/ *.tsbuildinfo +__pycache__/ +.ruff_cache/ +.ty_cache/ diff --git a/git-cliff-release/enhance_context.py b/git-cliff-release/enhance_context.py index 6bfaa0a..4c4d199 100644 --- a/git-cliff-release/enhance_context.py +++ b/git-cliff-release/enhance_context.py @@ -1,9 +1,9 @@ from __future__ import annotations -from argparse import ArgumentParser, BooleanOptionalAction import json import subprocess import sys +from argparse import ArgumentParser, BooleanOptionalAction from pathlib import Path from typing import Any @@ -11,7 +11,7 @@ def load_pr_issues(owner: str, repo: str) -> dict[int, list[int]]: output = subprocess.check_output( [ - str(Path(__file__).parent / "fetch_pr_issues.sh"), + str(Path(__file__).parent / 'fetch_pr_issues.sh'), owner, repo, ] @@ -20,7 +20,7 @@ def load_pr_issues(owner: str, repo: str) -> dict[int, list[int]]: try: pr_issues = json.loads(output) except ValueError: - print(f"fetch_pr_issues.sh output: {output}") + print(f'fetch_pr_issues.sh output: {output}') raise if pr_issues is None: @@ -29,65 +29,60 @@ def load_pr_issues(owner: str, repo: str) -> dict[int, list[int]]: return {int(key): value for key, value in pr_issues.items()} -def enhance_release( - release: dict[str, Any], is_release_notes: bool, unreleased_version: str | None -) -> None: - release["extra"] = release["extra"] or {} - release["extra"]["is_release_notes"] = is_release_notes +def enhance_release(release: dict[str, Any], *, is_release_notes: bool, unreleased_version: str | None) -> None: + release['extra'] = release['extra'] or {} + release['extra']['is_release_notes'] = is_release_notes - if release["version"]: - release["extra"]["release_link"] = ( - f"{repo_url}/releases/tag/{release['version']}" - ) + if release['version']: + release['extra']['release_link'] = f'{repo_url}/releases/tag/{release["version"]}' elif unreleased_version: - release["extra"]["unreleased_version"] = unreleased_version + release['extra']['unreleased_version'] = unreleased_version def enhance_commit(commit: dict[str, Any], pr_issues: dict[int, list[int]]) -> None: - commit_remote = commit.get("remote", {}) + commit_remote = commit.get('remote', {}) - pr_number = commit_remote.get("pr_number") - username = commit_remote.get("username") + pr_number = commit_remote.get('pr_number') + username = commit_remote.get('username') - commit["extra"] = commit["extra"] or {} - commit["extra"]["commit_link"] = f"{repo_url}/commit/{commit['id']}" + commit['extra'] = commit['extra'] or {} + commit['extra']['commit_link'] = f'{repo_url}/commit/{commit["id"]}' if username: - commit["extra"]["username"] = username + commit['extra']['username'] = username if pr_number: - commit["extra"]["closed_issues"] = pr_issues.get(pr_number, []) + commit['extra']['closed_issues'] = pr_issues.get(pr_number, []) - pr_link = f"{repo_url}/pull/{pr_number}" - commit["extra"]["pr_link"] = f"([#{pr_number}]({pr_link}))" - commit["extra"]["raw_pr_link"] = f"(#{pr_number})" + pr_link = f'{repo_url}/pull/{pr_number}' + commit['extra']['pr_link'] = f'([#{pr_number}]({pr_link}))' + commit['extra']['raw_pr_link'] = f'(#{pr_number})' - commit["extra"]["closed_issue_links"] = [ - f"[#{issue}]({repo_url}/issues/{issue})" - for issue in commit["extra"]["closed_issues"] + commit['extra']['closed_issue_links'] = [ + f'[#{issue}]({repo_url}/issues/{issue})' for issue in commit['extra']['closed_issues'] ] parser = ArgumentParser() -parser.add_argument("--repo", type=str, required=True) -parser.add_argument("--unreleased-version", nargs="?", default=None, type=str) -parser.add_argument("--release-notes", action=BooleanOptionalAction) -parser.add_argument("--no-github", default=False, action="store_true") +parser.add_argument('--repo', type=str, required=True) +parser.add_argument('--unreleased-version', nargs='?', default=None, type=str) +parser.add_argument('--release-notes', action=BooleanOptionalAction) +parser.add_argument('--no-github', default=False, action='store_true') -if __name__ == "__main__": +if __name__ == '__main__': args = parser.parse_args() - repo_url = f"https://github.com/{args.repo}" - owner, repo = args.repo.split("/") + repo_url = f'https://github.com/{args.repo}' + owner, repo = args.repo.split('/') pr_issues = load_pr_issues(owner, repo) context = json.load(sys.stdin) - if not args.no_github: + if not args.no_github: for release in context: - enhance_release(release, args.release_notes, args.unreleased_version) + enhance_release(release, is_release_notes=args.release_notes, unreleased_version=args.unreleased_version) - for commit in release["commits"]: + for commit in release['commits']: enhance_commit(commit, pr_issues) json.dump(context, sys.stdout) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97fdccf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "apify-actions" +version = "0" +description = "Lint and type-check configuration for Python scripts in apify/actions." +requires-python = ">=3.14" + +[dependency-groups] +dev = [ + "rich~=15.0.0", + "ruff~=0.15.0", + "ty~=0.0.0", + "typer~=0.25.0", +] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 120 +include = ["**/*.py"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "COM812", # May conflict with the formatter. + "D", # pydocstyle - docstring presence/formatting not enforced for this repo's small scripts. + "EM", # flake8-errmsg. + "G004", # Logging statement uses f-string. + "ISC001", # May conflict with the formatter. + "PLR0913", # Too many arguments in function definition. + "TD002", # Missing author in TODO. + "TRY003", # Avoid specifying long messages outside the exception class. +] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "single" + +[tool.ruff.lint.per-file-ignores] +"{python-package-check,git-cliff-release}/**/*.py" = [ + "INP001", # Implicit namespace package - these are script directories, not packages. + "S603", # `subprocess` call: arguments are trusted (CI-provided or constants). + "S607", # Starting a process with a partial executable path - tools resolved from PATH by design. + "T201", # `print` is the script's UX. +] + +[tool.ty.environment] +python-version = "3.14" + +[tool.ty.src] +include = ["**/*.py"] diff --git a/python-package-check/action.yaml b/python-package-check/action.yaml new file mode 100644 index 0000000..b3ed4dc --- /dev/null +++ b/python-package-check/action.yaml @@ -0,0 +1,58 @@ +name: Python package check +description: > + Verify built sdist + wheel artifacts in `dist/` install and import correctly. + Run after building the package (e.g. via `prepare-pypi-distribution`). + +inputs: + package_name: + description: Importable Python package name (e.g. `crawlee`, `apify`, `apify_client`). + required: true + python_version: + description: Python version to use for the verification venvs. + required: true + package_layout: + description: > + Source layout of the package: `src` for `src//` (default) or `flat` for `/` at the + repository root. + required: false + default: "src" + dist_dir: + description: Directory containing the built sdist + wheel. + required: false + default: "dist" + extras: + description: Optional extras to install (e.g. `all`). Empty for no extras. + required: false + default: "" + smoke_code: + description: > + Optional extra Python code to run inside the install smoke test, after `import `. + Useful for asserting that specific symbols import and construct cleanly. + required: false + default: "" + +runs: + using: composite + steps: + - name: Set up uv package manager + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ inputs.python_version }} + + - name: Verify built package + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + DIST_DIR: ${{ inputs.dist_dir }} + PYTHON_VERSION: ${{ inputs.python_version }} + EXTRAS: ${{ inputs.extras }} + PACKAGE_LAYOUT: ${{ inputs.package_layout }} + SMOKE_CODE: ${{ inputs.smoke_code }} + run: | + uv run --no-project --script "${{ github.action_path }}/verify_built_package.py" \ + --package "$PACKAGE_NAME" \ + --dist-dir "$DIST_DIR" \ + --python-version "$PYTHON_VERSION" \ + --extras "$EXTRAS" \ + --package-layout "$PACKAGE_LAYOUT" \ + --smoke-code "$SMOKE_CODE" diff --git a/python-package-check/verify_built_package.py b/python-package-check/verify_built_package.py new file mode 100755 index 0000000..8116c98 --- /dev/null +++ b/python-package-check/verify_built_package.py @@ -0,0 +1,310 @@ +#!/usr/bin/env -S uv run --script + +# /// script +# requires-python = ">=3.14" +# dependencies = [ +# "rich~=15.0.0", +# "typer~=0.25.0", +# ] +# /// + +"""Verify that built artifacts (sdist + wheel) in `dist/` install and import correctly. + +Designed to run against any Python package built via `uv build` / hatch / similar. +Used both locally and in CI by the `apify/actions/python-package-check` composite action. + +Checks performed: + +* `dist/` contains exactly one `.whl` and one `.tar.gz`. +* Sdist includes all expected source/data/metadata files and excludes `tests/`, `docs/`, `website/`, `examples/`, + `.github/`, and `uv.lock`. +* Wheel includes all expected source/data files and a `*.dist-info/METADATA` entry. +* Wheel installs into a fresh venv and the package imports. +* Sdist installs into a fresh venv (forces pip to rebuild the wheel from sdist contents) and the package imports. +""" + +from __future__ import annotations + +import subprocess +import tarfile +import tempfile +import zipfile +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Literal + +import typer +from rich.console import Console + +console = Console() + + +class PackageLayout(StrEnum): + SRC = 'src' + FLAT = 'flat' + + +REQUIRED_METADATA_FILES = ( + 'LICENSE', + 'README.md', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'pyproject.toml', +) + +FORBIDDEN_SDIST_TOPLEVEL_DIRS = ( + 'tests', + 'docs', + 'website', + 'examples', + '.github', +) + +FORBIDDEN_SDIST_FILES = ('uv.lock',) + + +def passed(msg: str) -> None: + console.print(f'[green]PASS[/green] {msg}') + + +def failed(msg: str) -> None: + console.print(f'[red]FAIL[/red] {msg}') + + +def info(msg: str) -> None: + console.print(f'[dim] {msg}[/dim]') + + +def section(title: str) -> None: + console.print(f'\n[bold]=== {title} ===[/bold]') + + +def find_artifacts(dist_dir: Path) -> tuple[Path, Path]: + if not dist_dir.is_dir(): + raise SystemExit(f'dist directory not found: {dist_dir}') + wheels = sorted(dist_dir.glob('*.whl')) + sdists = sorted(dist_dir.glob('*.tar.gz')) + if len(wheels) != 1: + raise SystemExit(f'Expected exactly one .whl in {dist_dir}, found {len(wheels)}: {wheels}') + if len(sdists) != 1: + raise SystemExit(f'Expected exactly one .tar.gz in {dist_dir}, found {len(sdists)}: {sdists}') + return wheels[0], sdists[0] + + +def list_sdist_members(sdist: Path) -> list[str]: + prefix = sdist.name.removesuffix('.tar.gz') + '/' + with tarfile.open(sdist, 'r:gz') as tar: + return [m.name.removeprefix(prefix) for m in tar.getmembers() if m.isfile() and m.name.startswith(prefix)] + + +def list_wheel_members(wheel: Path) -> list[str]: + with zipfile.ZipFile(wheel) as zf: + return [n for n in zf.namelist() if not n.endswith('/')] + + +def collect_repo_files(src_package_dir: Path) -> tuple[list[str], list[str]]: + """Return (source_files, data_files) relative to the parent of `src_package_dir`. + + The relative-to-parent layout matches both sdist (`src//...`) and wheel (`/...`). + """ + if not src_package_dir.is_dir(): + raise SystemExit(f'Source package directory not found: {src_package_dir}') + src_root = src_package_dir.parent + source: list[str] = [] + data: list[str] = [] + for path in src_package_dir.rglob('*'): + if not path.is_file(): + continue + if '__pycache__' in path.parts or path.suffix in ('.pyc', '.pyo'): + continue + rel = path.relative_to(src_root).as_posix() + if path.suffix == '.py': + source.append(rel) + else: + data.append(rel) + return sorted(source), sorted(data) + + +def _preview(items: list[str], limit: int = 5) -> str: + return ', '.join(items[:limit]) + ('...' if len(items) > limit else '') + + +def _check_files_present( + member_set: set[str], + required: list[str], + prefix: str, + label: str, + category: str, +) -> bool: + missing = [r for r in required if f'{prefix}{r}' not in member_set] + if missing: + failed(f'{label} missing {len(missing)} {category} file(s): {_preview(missing)}') + return False + passed(f'{label} has all {len(required)} {category} files') + return True + + +def check_sdist_contents( + members: list[str], + source_files: list[str], + data_files: list[str], + sdist_prefix: str, +) -> bool: + section('Checking sdist contents') + member_set = set(members) + results: list[bool] = [] + + for meta in REQUIRED_METADATA_FILES: + if meta in member_set: + passed(f'sdist has {meta}') + results.append(True) + else: + failed(f'sdist missing {meta}') + results.append(False) + + for forbidden in FORBIDDEN_SDIST_TOPLEVEL_DIRS: + leaked = [m for m in members if m.startswith(f'{forbidden}/')] + if leaked: + failed(f'sdist leaked {forbidden}/ files: {_preview(leaked, limit=3)}') + results.append(False) + else: + passed(f'sdist has no {forbidden}/ leak') + results.append(True) + + for forbidden in FORBIDDEN_SDIST_FILES: + if forbidden in member_set: + failed(f'sdist contains forbidden file {forbidden}') + results.append(False) + else: + passed(f'sdist has no {forbidden}') + results.append(True) + + results.append(_check_files_present(member_set, source_files, sdist_prefix, 'sdist', '.py source')) + if data_files: + results.append(_check_files_present(member_set, data_files, sdist_prefix, 'sdist', 'data')) + return all(results) + + +def check_wheel_contents(members: list[str], source_files: list[str], data_files: list[str]) -> bool: + section('Checking wheel contents') + member_set = set(members) + results: list[bool] = [] + + has_metadata = any(m.endswith('/METADATA') and '.dist-info/' in m for m in members) + if has_metadata: + passed('wheel has .dist-info/METADATA') + results.append(True) + else: + failed('wheel missing .dist-info/METADATA') + results.append(False) + + results.append(_check_files_present(member_set, source_files, '', 'wheel', '.py source')) + if data_files: + results.append(_check_files_present(member_set, data_files, '', 'wheel', 'data')) + return all(results) + + +def install_and_smoke_test( + artifact: Path, + kind: Literal['wheel', 'sdist'], + venv_dir: Path, + package_name: str, + python_version: str, + extras: str, + smoke_code: str, +) -> bool: + section(f'Installing {kind} into fresh venv') + subprocess.run(['uv', 'venv', '--quiet', '--python', python_version, str(venv_dir)], check=True) + python = venv_dir / 'bin' / 'python' + spec = f'{artifact}[{extras}]' if extras else str(artifact) + res = subprocess.run( + ['uv', 'pip', 'install', '--quiet', '--python', str(python), spec], + capture_output=True, + text=True, + check=False, + ) + if res.returncode != 0: + failed(f'{kind} install failed') + info(res.stderr.strip() or res.stdout.strip()) + return False + passed(f'{kind} installed into {venv_dir}') + + base_smoke = f'import {package_name}\nprint(getattr({package_name}, "__version__", ""))\n' + code = base_smoke + (smoke_code or '') + res = subprocess.run([str(python), '-c', code], capture_output=True, text=True, check=False) + if res.returncode != 0: + failed(f'{kind} import smoke test failed') + info(res.stderr.strip()) + return False + version = next(iter(res.stdout.strip().splitlines()), '') + passed(f'{kind} imports OK ({package_name}=={version})') + return True + + +def main( + package: Annotated[str, typer.Option(help='Importable Python package name (e.g. crawlee).')], + python_version: Annotated[str, typer.Option(help='Python version for verification venvs.')], + dist_dir: Annotated[Path, typer.Option(help='Directory containing built artifacts.')] = Path('dist'), + package_layout: Annotated[ + PackageLayout, + typer.Option(help='Source layout: `src` for `src//`, `flat` for `/` at the repo root.'), + ] = PackageLayout.SRC, + extras: Annotated[str, typer.Option(help='Optional install extras (e.g. all).')] = '', + smoke_code: Annotated[ + str, + typer.Option(help='Optional extra Python code to run after `import ` in the smoke test.'), + ] = '', +) -> None: + if package_layout is PackageLayout.SRC: + src_path = (Path('src') / package).resolve() + sdist_prefix = 'src/' + else: + src_path = Path(package).resolve() + sdist_prefix = '' + + wheel, sdist = find_artifacts(dist_dir.resolve()) + info(f'package: {package}') + info(f'layout: {package_layout.value}') + info(f'src dir: {src_path}') + info(f'wheel: {wheel.name}') + info(f'sdist: {sdist.name}') + + sdist_members = list_sdist_members(sdist) + wheel_members = list_wheel_members(wheel) + source_files, data_files = collect_repo_files(src_path) + info(f'sources: {len(source_files)}') + info(f'data: {len(data_files)}') + + results: list[bool] = [ + check_sdist_contents(sdist_members, source_files, data_files, sdist_prefix), + check_wheel_contents(wheel_members, source_files, data_files), + ] + + artifacts: tuple[tuple[Path, Literal['wheel', 'sdist']], ...] = ((wheel, 'wheel'), (sdist, 'sdist')) + with tempfile.TemporaryDirectory(prefix='verify-built-package-') as tmp: + tmp_path = Path(tmp) + for artifact, kind in artifacts: + results.append( + install_and_smoke_test( + artifact, + kind, + tmp_path / f'venv-{kind}', + package, + python_version, + extras, + smoke_code, + ) + ) + + section('Summary') + + if all(results): + passed('all checks passed') + return + + failed(f'{sum(1 for r in results if not r)} of {len(results)} check group(s) failed') + raise typer.Exit(code=1) + + +if __name__ == '__main__': + typer.run(main) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e9dd3fe --- /dev/null +++ b/uv.lock @@ -0,0 +1,173 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[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 = "apify-actions" +version = "0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "rich" }, + { name = "ruff" }, + { name = "ty" }, + { name = "typer" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "rich", specifier = "~=15.0.0" }, + { name = "ruff", specifier = "~=0.15.0" }, + { name = "ty", specifier = "~=0.0.0" }, + { name = "typer", specifier = "~=0.25.0" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[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 = "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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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 = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[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 = "ty" +version = "0.0.37" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/c3/60bc4829e0c1a8ff80b592067e1185a7b5ea64608acb0c676c44d5137d52/ty-0.0.37.tar.gz", hash = "sha256:f873f69627bd7f4ef8d57f716c63e5c63d7d1b7327ab3de185c7287a75223011", size = 5655422, upload-time = "2026-05-16T05:57:21.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/fe/180dd6914f9db33ad0200fbeaa429dd1fb0a4e6d98320dc1775f100a91af/ty-0.0.37-py3-none-linux_armv6l.whl", hash = "sha256:66cf7310189856e15f690559ddf37735476d2644db917d92f7cef13e5c834adf", size = 11246028, upload-time = "2026-05-16T05:57:41.744Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/fa0cfd31467ad99b2db8c81ee9e2b4574589974a3eb9723be825e15b300c/ty-0.0.37-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2048f3c44ee6c7dde6e0ca064f99c6cada8f6de8ccdcfad2d856a429f8a4ac82", size = 11001460, upload-time = "2026-05-16T05:57:35.27Z" }, + { url = "https://files.pythonhosted.org/packages/10/3f/db60ba9be8b95a464ece0ba103e534047c34b49fee12f5e101f83f8d66db/ty-0.0.37-py3-none-macosx_11_0_arm64.whl", hash = "sha256:32c7b9b5b626aacdec334b44a2698e5f7b80df55bf7338267084d00d4b9546b3", size = 10446549, upload-time = "2026-05-16T05:57:37.252Z" }, + { url = "https://files.pythonhosted.org/packages/56/6f/11dd7174b20ebcb37a3d3b68f60b3940e37e4356e0accd03e2d7f9f70690/ty-0.0.37-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9fba1bebccf1e656bc5e3787acc5a191c491041ee4d12fe8fe2eff64e7b190d", size = 10961016, upload-time = "2026-05-16T05:57:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/65/dd/3c17ce2860c525817c42c82d7075391b1f5615d36c03aa2d26647a224e8a/ty-0.0.37-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f987c5fb59aa5017ee8e8c5b57a07390f584e58e572255acd0fa44b3e0b238df", size = 11022093, upload-time = "2026-05-16T05:57:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/e7a40b0b57660921dd3482d219add963973b52ae8507abd88f48439704b5/ty-0.0.37-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4168f53146e7a3f52560ff433f238352591c9b1a9ed09397fbb776ddef4f89c", size = 11486333, upload-time = "2026-05-16T05:57:18.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/2c406b98244bc1ad42afdd35f466bcef88664210957dcbb5172254ff2462/ty-0.0.37-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e487eafdb80a48223ce68a01f9287528216ffe0126d1629ff11e4f7c1dd3cf", size = 12093526, upload-time = "2026-05-16T05:57:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3c/5c492a38e1b21a26370727dd4b77a53f05262e53e3be232047f22e7fa1b3/ty-0.0.37-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b49f388d063668676daaa7eef57385089d1b844279c0185bd84d4dbc3bcede6", size = 11725957, upload-time = "2026-05-16T05:57:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/b2/00/8a3d9ba265cd0582342c14e4980cc0351aaaa45c6305712d398c9e2446c7/ty-0.0.37-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b96bfc1cc725d9d859abef4e3aa32a6da0f7472eaaafae2d9a6cffd729c7c61", size = 11610336, upload-time = "2026-05-16T05:57:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/91/4b/6ee172935cb842f5c1553b0d37215b45e9dde05a4c74fdb47fd271907122/ty-0.0.37-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c55f39b519107cf234b794718793e11793c055e89028a282a309f690def48117", size = 11797856, upload-time = "2026-05-16T05:57:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/34/ef/75a7425bf9fe74483404ff11a8cbe3aa307354e0801697d6063384157776/ty-0.0.37-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c79204350de060a077bff7f027a1d53e216cad147d826ec9862be0af2f9c3c1e", size = 10941848, upload-time = "2026-05-16T05:57:30.653Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2c/7ea9dccd55961375067f99ed00fb8eabb491f6a06d0e5f09c797d2b900a6/ty-0.0.37-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:49a21b4dcb2cd94cd0298c96dfb71a2dd25f08bf7e6eefd0c33c519d058908c6", size = 11058248, upload-time = "2026-05-16T05:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/98/d7/848fde96c6610b2b1fd75823d44d8977a4525c4397f27332f054ccd6cf9c/ty-0.0.37-py3-none-musllinux_1_2_i686.whl", hash = "sha256:119332095c5974fe1dabfe4fd00c6759eeec5b99f7d7a80b2833feee5a58abdb", size = 11168423, upload-time = "2026-05-16T05:57:39.297Z" }, + { url = "https://files.pythonhosted.org/packages/29/11/c1613ac4b64357b9067df68bac97bcb458cc426cd468a2782847238c539b/ty-0.0.37-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ac5dc593675414f68862c2f71cc04912b0e5ec5520a9c49fc71ed79205b95c33", size = 11698565, upload-time = "2026-05-16T05:57:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/961205863903881996adb5a6f9cfe570c132882922ac226540346f15df20/ty-0.0.37-py3-none-win32.whl", hash = "sha256:33b57e4095179f06c2ae01c334833645cad94bf7d7467e073cdc3aaabea565d3", size = 10518308, upload-time = "2026-05-16T05:57:25.824Z" }, + { url = "https://files.pythonhosted.org/packages/39/cd/f308edd0cd86e402fe3a1b5c54e0a0dfa0177d80c1557c4849510bb2a147/ty-0.0.37-py3-none-win_amd64.whl", hash = "sha256:3b159351e99cf6eed7aacfb69ae8437725d15599ac4f21c8b2e909b300498b6c", size = 11607159, upload-time = "2026-05-16T05:57:06.76Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ed/5ec4b501479bc5dad55467e2fe72e797cb9c178468c0d1a514536872ebc5/ty-0.0.37-py3-none-win_arm64.whl", hash = "sha256:6c3c2b997f68c71e14242b96d48cba3c086439556af02bb4613aa458950d5c23", size = 10958817, upload-time = "2026-05-16T05:57:08.907Z" }, +] + +[[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" }, +]