diff --git a/AGENTS.md b/AGENTS.md index f865a07..4825042 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,23 @@ - **Reference:** Use the markdown files within the `docs/` directory as a knowledge base and source of truth for project requirements, dependency - choices, and architectural decisions. + choices, and architectural decisions. Start with + [documentation contents](docs/contents.md) and + [repository layout](docs/repository-layout.md) when orienting within the + project. - **Update:** When new decisions are made, requirements change, libraries are added/removed, or architectural patterns evolve, **proactively update** the relevant file(s) in the `docs/` directory to reflect the latest state. Ensure the documentation remains accurate and current. +- **Design decisions:** Record design decisions in the relevant design + document. When a decision is substantive, capture it in an Architectural + Decision Record (ADR) following the documentation style guide, and reference + that ADR from the design document. +- **User-facing behaviour:** Update [users' guide](docs/users-guide.md) for + behaviour or user-interface changes that users should know about. +- **Internal interfaces:** Document internally facing interfaces in the + relevant component architecture document. Document internally facing + conventions and practices in [developers' guide](docs/developers-guide.md). - **Style:** All documentation must adhere to the [documentation style guide](docs/documentation-style-guide.md). @@ -36,9 +48,22 @@ When implementing changes, adhere to the following testing procedures: - **New Functionality:** - Implement unit tests covering all new code units (functions, components, - classes). Implement tests **before** implementing the unit. - - Implement behavioral tests that verify the end-to-end behavior of the new - feature from a user interaction perspective. + classes) using `pytest`. Implement tests **before** implementing the unit. + - Implement behavioural tests using `pytest-bdd` that verify the + end-to-end behaviour of the new feature from a user interaction + perspective. + - Add snapshot tests using `syrupy` where output format consistency is + relevant to the requirements. + - Add end-to-end tests when the change affects externally observable + workflows, integration contracts, persistence, command-line behaviour, + network boundaries, UI flows, or other system-level behaviour. + - Add property tests using `hypothesis`, or a bounded model checker such as + CrossHair, when the change introduces an invariant over a range of inputs, + states, orderings, or transitions. + - For introduced axioms or contractual business logic, prefer provable code + in a Rust extension when an exhaustive proof, for example using Verus, is + suitable. Any proof must be substantive, rigorous, and well-founded, not + merely a restatement of the assumed property. - Ensure both unit and behavioral tests pass before considering the functionality complete. - Ensure that new functionality is clearly documented in the @@ -136,8 +161,6 @@ When implementing changes, adhere to the following testing procedures: pass before and after, unit tests added for new units). - Ensure the refactoring commit itself passes all quality gates. - - ## Markdown Guidance - Validate Markdown files using `make markdownlint`. diff --git a/README.md b/README.md index 93212a8..4ba8bcf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,43 @@ # create-labels -Example package generated from this Copier template. +`create-labels` creates and updates GitHub repository labels from a TOML +configuration file or from the built-in df12 label set. It is a small Python +command-line tool for making label setup repeatable across repositories without +shelling out to the GitHub CLI. +## Why use this? +- Apply a consistent default label set to new repositories. +- Keep repository labels in sync from reviewed TOML configuration. +- Support GitHub Enterprise and local API simulators through `--api-url`. +- Avoid deleting labels that are outwith the desired configuration. + +## Quick start + +Install the package, provide a token, and choose a target repository: + +```bash +create-labels --repository owner/repo --token "$GITHUB_TOKEN" +``` + +To use a TOML configuration file: + +```bash +create-labels --config labels.toml --repository owner/repo +``` + +When no labels are provided in TOML, the built-in default labels are applied. + +## Documentation + +Read the [users' guide](docs/users-guide.md) for command-line options, +configuration format, GitHub Enterprise usage, and local validation commands. + +Read the [developers' guide](docs/developers-guide.md) for package boundaries, +data flow, normalization rules, and testing strategy. + +## Compatibility note + +This package is no longer the Copier template placeholder project. The old +sample `hello` function has been removed; use the `create-labels` console +script or the public exports from `create_labels` instead. diff --git a/create_labels/__init__.py b/create_labels/__init__.py index df59c69..1a1b75d 100644 --- a/create_labels/__init__.py +++ b/create_labels/__init__.py @@ -1,19 +1,28 @@ -"""create-labels package.""" +"""Public API for create-labels. -from __future__ import annotations - -import importlib -import typing as typ +The package root is the stable import boundary for library consumers. It +re-exports typed configuration values from ``create_labels.config``, the +imported default label set from ``create_labels.defaults``, and the pure label +synchronization function and result type from ``create_labels.sync``. -if typ.TYPE_CHECKING: - import collections.abc as cabc +Use these exports when embedding create-labels in tests, scripts, or other +tools. The submodules keep parsing, default data, GitHub integration, and sync +decisions separate, while this module provides the small public surface that +callers should depend on. +""" -PACKAGE_NAME = "create_labels" +from __future__ import annotations -try: # pragma: no cover - Rust optional - rust = importlib.import_module(f"._{PACKAGE_NAME}_rs", package=__name__) - hello = typ.cast("cabc.Callable[[], str]", rust.hello) -except ModuleNotFoundError: # pragma: no cover - Python fallback - from .pure import hello +from .config import LabelConfig, LabelSpec, RepositorySpec, load_config +from .defaults import DEFAULT_LABELS +from .sync import LabelSyncResult, sync_labels -__all__ = ["hello"] +__all__ = [ + "DEFAULT_LABELS", + "LabelConfig", + "LabelSpec", + "LabelSyncResult", + "RepositorySpec", + "load_config", + "sync_labels", +] diff --git a/create_labels/cli.py b/create_labels/cli.py new file mode 100644 index 0000000..47e143d --- /dev/null +++ b/create_labels/cli.py @@ -0,0 +1,105 @@ +"""Cyclopts command-line interface for create-labels. + +This module provides the console entrypoint that wires together argument +parsing, TOML configuration loading, github3.py integration, and result +formatting. ``main`` is the Cyclopts-decorated command used by the +``create-labels`` project script. + +Repository resolution prefers ``--repository``, then ``[repository]`` from the +TOML file, then ``GITHUB_REPOSITORY``. Tokens prefer ``--token`` and then +``GITHUB_TOKEN``. API URLs prefer ``--api-url``, then ``github.api_url`` from +TOML, then ``https://api.github.com``. + +Examples +-------- +Apply the imported default labels:: + + create-labels --repository owner/repo + +Apply labels from TOML:: + + create-labels --config labels.toml + +Target GitHub Enterprise or a simulator:: + + create-labels --repository owner/repo --api-url https://github.example/api/v3 + +""" + +from __future__ import annotations + +import pathlib + +from cyclopts import App + +from .config import LabelConfig, RepositorySpec, load_config +from .github import sync_repository_labels + +app: App = App(help="Create or update GitHub repository labels from TOML.") + + +@app.default +def main( + *, + config: str | None = None, + repository: str | None = None, + token: str | None = None, + api_url: str | None = None, +) -> None: + """Create or update labels in a GitHub repository. + + Parameters + ---------- + config + TOML configuration path. When omitted, the default imported labels are + used. + repository + Repository in ``owner/name`` form. Overrides ``[repository]`` in the + TOML file and ``GITHUB_REPOSITORY``. + token + GitHub token. When omitted, ``GITHUB_TOKEN`` is used. + api_url + GitHub API URL. Use this for GitHub Enterprise or local simulators. + + """ + label_config = ( + load_config(pathlib.Path(config)) + if config is not None + else LabelConfig(None, ()) + ) + results = sync_repository_labels( + config=label_config, + repository=_parse_repository_argument(repository), + token=token, + api_url=api_url, + ) + + for result in results: + print(f"{result.action}: {result.name}") + + +def _parse_repository_argument(repository: str | None) -> RepositorySpec | None: + """Parse an ``owner/name`` repository argument into a RepositorySpec.""" + if repository is None: + return None + + owner, separator, name = repository.partition("/") + if not all((separator, owner, name)) or "/" in name: + msg = "repository must use the owner/name format" + raise ValueError(msg) + return RepositorySpec(owner=owner, name=name) + + +def run() -> None: + """Execute the Cyclopts application. + + This thin wrapper delegates to ``app`` so packaging can expose a stable + project-script entrypoint. + + Raises + ------ + SystemExit + Raised by Cyclopts when argument parsing or command execution fails. + + """ + app() diff --git a/create_labels/config.py b/create_labels/config.py new file mode 100644 index 0000000..47b5312 --- /dev/null +++ b/create_labels/config.py @@ -0,0 +1,302 @@ +"""TOML configuration loading for GitHub label synchronisation. + +This module owns the typed configuration boundary for the package. It loads +TOML data into ``RepositorySpec``, ``LabelSpec``, and ``LabelConfig`` values, +normalises six-digit hex colours, validates label names, and rejects duplicate +label definitions before any GitHub API calls are made. + +Example usage +------------- +Load a TOML file from disk:: + + config = load_config(Path("labels.toml")) + +Parse data that has already been decoded from TOML:: + + config = parse_config({"labels": [{"name": "risk: low"}]}) + +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import re +import tomllib +import typing as typ + +if typ.TYPE_CHECKING: + import pathlib + +DEFAULT_COLOR = "EDEDED" +_HEX_COLOR = re.compile(r"\A[0-9A-F]{6}\Z") + + +class ConfigError(ValueError): + """Raised when a label configuration file is invalid. + + Parameters + ---------- + *args : object + Error message values passed to ``ValueError``. + + """ + + +@dataclasses.dataclass(frozen=True, slots=True) +class RepositorySpec: + """GitHub repository coordinates. + + Attributes + ---------- + owner : str + GitHub account or organisation that owns the repository. + name : str + Repository name within ``owner``. + full_name : str + Property returning the canonical ``owner/name`` repository name. + + """ + + owner: str + name: str + + @property + def full_name(self) -> str: + """Return the canonical ``owner/name`` repository name.""" + return f"{self.owner}/{self.name}" + + +@dataclasses.dataclass(frozen=True, slots=True) +class LabelSpec: + """Desired state for a GitHub repository label. + + Attributes + ---------- + name : str + Label name shown in GitHub. + color : str + Six-character hexadecimal label colour without a leading ``#``. + description : str | None + Optional label description shown in GitHub. + + Raises + ------ + ConfigError + Raised by ``__post_init__`` when ``name`` is empty or ``color`` is not + a valid six-character hexadecimal colour. + + Notes + ----- + ``__post_init__`` normalises ``name``, ``color``, and ``description`` by + trimming surrounding whitespace and converting colours to uppercase + six-character hexadecimal values. + + """ + + name: str + color: str = DEFAULT_COLOR + description: str | None = None + + def __post_init__(self) -> None: + """Normalise and validate colour format; reject malformed labels.""" + name = self.name.strip() + if not name: + msg = "Label names must not be empty" + raise ConfigError(msg) + + color = self.color.removeprefix("#").upper() + description = self.description.strip() if self.description is not None else None + if _HEX_COLOR.fullmatch(color) is None: + msg = f"Label {name!r} has invalid colour {self.color!r}" + raise ConfigError(msg) + + object.__setattr__(self, "name", name) + object.__setattr__(self, "color", color) + object.__setattr__(self, "description", description) + + +@dataclasses.dataclass(frozen=True, slots=True) +class LabelConfig: + """Complete label synchronisation configuration. + + Attributes + ---------- + repository : RepositorySpec | None + Optional repository coordinates from configuration. + labels : tuple[LabelSpec, ...] + Desired labels to create or update. + api_url : str | None + Optional GitHub API base URL. + + """ + + repository: RepositorySpec | None + labels: tuple[LabelSpec, ...] + api_url: str | None = None + + +def load_config(path: pathlib.Path) -> LabelConfig: + """Load and validate a TOML label configuration file. + + Parameters + ---------- + path + Path to the TOML configuration file. + + Returns + ------- + LabelConfig + Parsed repository, label, and optional GitHub API URL configuration. + + Raises + ------ + ConfigError + Raised when the file cannot be read, TOML decoding fails, or the + decoded configuration is invalid. + + """ + try: + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + msg = f"Invalid TOML in {path}: {exc}" + raise ConfigError(msg) from exc + except OSError as exc: + msg = f"Could not read label configuration {path}: {exc}" + raise ConfigError(msg) from exc + + return parse_config(parsed) + + +def parse_config(config: cabc.Mapping[str, typ.Any]) -> LabelConfig: + """Parse raw TOML data into a typed label configuration. + + Parameters + ---------- + config + Mapping decoded from TOML. + + Returns + ------- + LabelConfig + Parsed configuration containing repository coordinates, label specs, + and an optional GitHub API URL. + + Raises + ------ + ConfigError + Raised when repository, label, or GitHub API URL fields fail + validation. + + Notes + ----- + The parser delegates to ``_parse_repository``, ``_parse_labels``, and + ``_parse_api_url`` so each section has one validation boundary. + + """ + return LabelConfig( + repository=_parse_repository(config.get("repository")), + labels=_parse_labels(config.get("labels")), + api_url=_parse_api_url(config.get("github")), + ) + + +def _parse_repository(raw_repository: object) -> RepositorySpec | None: + """Parse an optional repository table.""" + if raw_repository is None: + return None + + repository_table = _required_table(raw_repository, "[repository]") + owner = _required_string(repository_table, "owner", "[repository]") + name = _required_string(repository_table, "name", "[repository]") + return RepositorySpec(owner=owner, name=name) + + +def _parse_api_url(raw_github: object) -> str | None: + """Parse an optional GitHub API URL table.""" + if raw_github is None: + return None + + github_table = _required_table(raw_github, "[github]") + raw_api_url = github_table.get("api_url") + if raw_api_url is None: + return None + if not isinstance(raw_api_url, str): + msg = "github.api_url must be a non-empty string when provided" + raise ConfigError(msg) + normalized = raw_api_url.strip().rstrip("/") + if not normalized: + msg = "github.api_url must be a non-empty string when provided" + raise ConfigError(msg) + return normalized + + +def _parse_labels(raw_labels: object) -> tuple[LabelSpec, ...]: + """Parse optional label tables into label specs.""" + if raw_labels is None: + return () + if not isinstance(raw_labels, list): + msg = "The labels key must be an array of TOML tables" + raise ConfigError(msg) + + labels = tuple(_parse_label(raw_label) for raw_label in raw_labels) + _reject_duplicate_labels(labels) + return labels + + +def _parse_label(raw_label: object) -> LabelSpec: + """Parse one label table into a label spec.""" + label_table = _required_table(raw_label, "[[labels]]") + + description = label_table.get("description") + if description is not None and not isinstance(description, str): + msg = "label.description must be a string when provided" + raise ConfigError(msg) + + raw_color = label_table.get("color", DEFAULT_COLOR) + if not isinstance(raw_color, str): + msg = "label.color must be a string when provided" + raise ConfigError(msg) + + return LabelSpec( + name=_required_string(label_table, "name", "[[labels]]"), + color=raw_color, + description=description, + ) + + +def _required_table(value: object, context: str) -> cabc.Mapping[str, typ.Any]: + """Return ``value`` as a TOML table or raise ``ConfigError``.""" + if not isinstance(value, cabc.Mapping): + msg = f"The {context} table must be a TOML table" + raise ConfigError(msg) + return typ.cast("cabc.Mapping[str, typ.Any]", value) + + +def _required_string( + values: cabc.Mapping[str, typ.Any], + key: str, + context: str, +) -> str: + """Return a stripped required string field or raise ``ConfigError``.""" + raw_value = values.get(key) + if not isinstance(raw_value, str) or not raw_value.strip(): + msg = f"{context}.{key} must be a non-empty string" + raise ConfigError(msg) + return raw_value.strip() + + +def _reject_duplicate_labels(labels: tuple[LabelSpec, ...]) -> None: + """Reject duplicate label names case-insensitively.""" + seen: set[str] = set() + duplicates: set[str] = set() + for label in labels: + key = label.name.casefold() + if key in seen: + duplicates.add(label.name) + seen.add(key) + + if duplicates: + duplicate_names = ", ".join(sorted(duplicates)) + msg = f"Duplicate label definitions: {duplicate_names}" + raise ConfigError(msg) diff --git a/create_labels/defaults.py b/create_labels/defaults.py new file mode 100644 index 0000000..b80e57a --- /dev/null +++ b/create_labels/defaults.py @@ -0,0 +1,74 @@ +"""Default labels imported from the original shell script. + +``DEFAULT_LABELS`` is the complete label set applied when a TOML configuration +does not provide any ``[[labels]]`` entries. The imported groups cover pull +request size, risk, code scope, workflow exceptions, and contributor history. +""" + +from __future__ import annotations + +from .config import LabelSpec + +DEFAULT_LABELS: tuple[LabelSpec, ...] = ( + LabelSpec("size: XS", "F9D0C4", "< 10 changed lines (excluding docs)"), + LabelSpec("size: S", "F5A3A3", "10-49 changed lines"), + LabelSpec("size: M", "E57373", "50-199 changed lines"), + LabelSpec("size: L", "D32F2F", "200-499 changed lines"), + LabelSpec("size: XL", "B71C1C", "500+ changed lines"), + LabelSpec("risk: low", "4CAF50", "Changes to docs, tests, or low-risk modules"), + LabelSpec( + "risk: medium", "FFC107", "Business logic, config, or moderate-risk modules" + ), + LabelSpec( + "risk: high", "F44336", "Safety, secrets, auth, or critical infrastructure" + ), + LabelSpec( + "risk: manual", "9E9E9E", "Risk level set manually (sticky, not overwritten)" + ), + LabelSpec("scope: agent", "006B75", "Agent core (agent loop, router, scheduler)"), + LabelSpec("scope: channel", "00838F", "Channel infrastructure"), + LabelSpec("scope: channel/cli", "00897B", "TUI / CLI channel"), + LabelSpec("scope: channel/web", "00796B", "Web gateway channel"), + LabelSpec("scope: channel/wasm", "00695C", "WASM channel runtime"), + LabelSpec("scope: tool", "1565C0", "Tool infrastructure"), + LabelSpec("scope: tool/builtin", "1976D2", "Built-in tools"), + LabelSpec("scope: tool/wasm", "1E88E5", "WASM tool sandbox"), + LabelSpec("scope: tool/mcp", "2196F3", "MCP client"), + LabelSpec("scope: tool/builder", "42A5F5", "Dynamic tool builder"), + LabelSpec("scope: db", "4A148C", "Database trait / abstraction"), + LabelSpec("scope: db/postgres", "6A1B9A", "PostgreSQL backend"), + LabelSpec("scope: db/libsql", "7B1FA2", "libSQL / Turso backend"), + LabelSpec("scope: safety", "880E4F", "Prompt injection defense"), + LabelSpec("scope: llm", "4527A0", "LLM integration"), + LabelSpec("scope: workspace", "283593", "Persistent memory / workspace"), + LabelSpec("scope: orchestrator", "0D47A1", "Container orchestrator"), + LabelSpec("scope: worker", "01579B", "Container worker"), + LabelSpec("scope: secrets", "BF360C", "Secrets management"), + LabelSpec("scope: config", "E65100", "Configuration"), + LabelSpec("scope: extensions", "33691E", "Extension management"), + LabelSpec("scope: setup", "827717", "Onboarding / setup"), + LabelSpec("scope: evaluation", "558B2F", "Success evaluation"), + LabelSpec("scope: estimation", "9E9D24", "Cost/time estimation"), + LabelSpec("scope: sandbox", "00BFA5", "Docker sandbox"), + LabelSpec("scope: hooks", "6D4C41", "Git/event hooks"), + LabelSpec("scope: pairing", "4E342E", "Pairing mode"), + LabelSpec("scope: ci", "546E7A", "CI/CD workflows"), + LabelSpec("scope: docs", "78909C", "Documentation"), + LabelSpec("scope: dependencies", "90A4AE", "Dependency updates"), + LabelSpec( + "dependencies", + "0366D6", + "Dependency updates and dependency manager pull requests", + ), + LabelSpec("github-actions", "2088FF", "GitHub Actions workflow updates"), + LabelSpec("cargo", "DEA584", "Rust Cargo package and lockfile updates"), + LabelSpec( + "skip-regression-check", + "9E9E9E", + "Acknowledged: fix without regression test", + ), + LabelSpec("contributor: new", "FFF9C4", "First-time contributor"), + LabelSpec("contributor: regular", "FFE082", "2-5 merged PRs"), + LabelSpec("contributor: experienced", "FFB74D", "6-19 merged PRs"), + LabelSpec("contributor: core", "FF8A65", "20+ merged PRs"), +) diff --git a/create_labels/github.py b/create_labels/github.py new file mode 100644 index 0000000..50262aa --- /dev/null +++ b/create_labels/github.py @@ -0,0 +1,189 @@ +"""github3.py adapter for repository label synchronisation. + +This module connects the pure label synchronisation logic to GitHub through +github3.py. ``sync_repository_labels`` resolves repository coordinates, +authentication, and API URL fallbacks before fetching the repository object and +delegating label creation or update work to ``sync_labels``. + +Resolution order is explicit argument first, then TOML configuration, then the +environment where applicable: repository uses ``RepositorySpec`` or +``LabelConfig.repository`` or ``GITHUB_REPOSITORY``; token uses ``--token`` or +``GITHUB_TOKEN``; API URL uses the explicit argument or ``LabelConfig.api_url`` +or ``https://api.github.com``. + +Example +------- +Synchronise labels for one repository:: + + config = LabelConfig(RepositorySpec("owner", "repo"), labels) + sync_repository_labels( + config=config, + repository=None, + token="ghp_example", + api_url=None, + ) + +""" + +from __future__ import annotations + +import os +import typing as typ +import urllib.parse + +import github3 +from github3.exceptions import NotFoundError +from github3.session import GitHubSession + +from .config import LabelConfig, RepositorySpec +from .defaults import DEFAULT_LABELS +from .sync import GitHubLabel, LabelSyncResult, sync_labels + +if typ.TYPE_CHECKING: + import collections.abc as cabc + + from .config import LabelSpec + +DEFAULT_API_URL = "https://api.github.com" + + +class GitHubError(RuntimeError): + """Raised when GitHub configuration or API access fails.""" + + +class _Github3Repository(typ.Protocol): + """github3.py repository methods used by the adapter.""" + + def label(self, name: str) -> GitHubLabel | None: + """Return a label by name.""" + + def create_label( + self, + name: str, + color: str, + description: str | None = None, + ) -> GitHubLabel | None: + """Create a label.""" + + +def sync_repository_labels( + *, + config: LabelConfig, + repository: RepositorySpec | None, + token: str | None, + api_url: str | None, +) -> tuple[LabelSyncResult, ...]: + """Synchronise configured labels to a GitHub repository. + + Parameters + ---------- + config + Parsed label configuration, including optional repository, labels, and + API URL. + repository + Explicit repository override. When omitted, ``config.repository`` and + then ``GITHUB_REPOSITORY`` are used. + token + Explicit API token. When omitted, ``GITHUB_TOKEN`` is used. + api_url + Explicit GitHub API URL. When omitted, ``config.api_url`` and then the + public GitHub API are used. + + Returns + ------- + tuple[LabelSyncResult, ...] + Per-label create or update results. + + Raises + ------ + GitHubError + Raised when repository coordinates, authentication, or repository + lookup fail. + RuntimeError + Raised when GitHub rejects a label create or update operation. + + Notes + ----- + This function resolves the repository, authenticates with ``_login``, + fetches the github3.py repository object, chooses configured labels or + ``DEFAULT_LABELS`` via ``_effective_labels``, and delegates to + ``sync_labels``. + + """ + resolved_repository = repository or config.repository or _repository_from_env() + if resolved_repository is None: + msg = "Repository must be provided via CLI, config, or GITHUB_REPOSITORY" + raise GitHubError(msg) + + github = _login(token=token, api_url=api_url or config.api_url) + github_repository = github.repository( + resolved_repository.owner, + resolved_repository.name, + ) + if github_repository is None: + msg = f"GitHub repository not found: {resolved_repository.full_name}" + raise GitHubError(msg) + + labels = _effective_labels(config.labels) + return sync_labels(_GitHubRepositoryAdapter(github_repository), labels) + + +class _GitHubRepositoryAdapter: + """Translate github3.py repository behaviour into the sync protocol.""" + + def __init__(self, repository: _Github3Repository) -> None: + """Store the github3.py repository object to adapt.""" + self._repository = repository + + def label(self, name: str) -> GitHubLabel | None: + """Return a label or None when github3.py reports it missing.""" + try: + encoded_name = urllib.parse.quote(name, safe="") + return self._repository.label(encoded_name) + except NotFoundError: + return None + + def create_label( + self, + name: str, + color: str, + description: str | None = None, + ) -> GitHubLabel | None: + """Create a repository label through github3.py.""" + return self._repository.create_label(name, color, description) + + +def _login(token: str | None, api_url: str | None) -> github3.GitHub: + """Resolve token and API URL, then return a GitHub client.""" + resolved_token = token or os.environ.get("GITHUB_TOKEN") + if not resolved_token: + msg = "GitHub token must be provided via --token or GITHUB_TOKEN" + raise GitHubError(msg) + + resolved_api_url = (api_url or DEFAULT_API_URL).rstrip("/") + if resolved_api_url == DEFAULT_API_URL: + return github3.login(token=resolved_token) + + session = GitHubSession() + session.base_url = resolved_api_url + return github3.GitHub(token=resolved_token, session=session) + + +def _repository_from_env() -> RepositorySpec | None: + """Parse ``GITHUB_REPOSITORY`` into ``RepositorySpec`` or return None.""" + raw_repository = os.environ.get("GITHUB_REPOSITORY") + if raw_repository is None: + return None + + owner, separator, name = raw_repository.partition("/") + if not all((separator, owner, name)) or "/" in name: + msg = "GITHUB_REPOSITORY must use the owner/name format" + raise GitHubError(msg) + return RepositorySpec(owner=owner, name=name) + + +def _effective_labels(labels: cabc.Sequence[LabelSpec]) -> tuple[LabelSpec, ...]: + """Return provided labels as a tuple, or ``DEFAULT_LABELS``.""" + if labels: + return tuple(labels) + return DEFAULT_LABELS diff --git a/create_labels/pure.py b/create_labels/pure.py deleted file mode 100644 index 6217091..0000000 --- a/create_labels/pure.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Pure Python implementation for the package stub.""" - -from __future__ import annotations - - -def hello() -> str: - """Return a friendly greeting from Python.""" - return "hello from Python" diff --git a/create_labels/sync.py b/create_labels/sync.py new file mode 100644 index 0000000..e558a5f --- /dev/null +++ b/create_labels/sync.py @@ -0,0 +1,135 @@ +"""Synchronise desired label specs to a GitHub repository-like object. + +The module compares configured ``LabelSpec`` instances with repository labels +and creates missing labels or updates existing labels. It deliberately does not +delete labels that are absent from the desired set. + +The lightweight ``Protocol`` interfaces model the small repository surface used +by this package, which keeps unit and behavioural tests independent from live +GitHub objects. + +Example +------- +Synchronize two labels against a repository-like object:: + + results = sync_labels(github_repository, (LabelSpec("risk: low"),)) + +Notes +----- +Deletion is not performed; labels outside the iterable are left unchanged. + +""" + +from __future__ import annotations + +import dataclasses +import typing as typ + +if typ.TYPE_CHECKING: + import collections.abc as cabc + + from .config import LabelSpec + + +class GitHubLabel(typ.Protocol): + """Subset of ``github3.issues.label.Label`` used by this package.""" + + name: str + color: str + description: str | None + + def update(self, name: str, color: str, description: str | None = None) -> bool: + """Update this label in place.""" + + +class GitHubRepository(typ.Protocol): + """Subset of ``github3.repos.repo.Repository`` needed for labels.""" + + def label(self, name: str) -> GitHubLabel | None: + """Return an existing repository label.""" + + def create_label( + self, + name: str, + color: str, + description: str | None = None, + ) -> GitHubLabel | None: + """Create a repository label.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class LabelSyncResult: + """Outcome of one label synchronisation operation.""" + + name: str + action: typ.Literal["created", "updated", "unchanged"] + + +def sync_labels( + repository: GitHubRepository, + labels: cabc.Iterable[LabelSpec], +) -> tuple[LabelSyncResult, ...]: + """Create or update labels so the repository matches ``labels``. + + Parameters + ---------- + repository + GitHub repository object supporting label lookup, creation, and update. + labels + Desired label specifications. + + Returns + ------- + tuple[LabelSyncResult, ...] + Per-label results indicating whether each label was created or updated. + + Raises + ------ + RuntimeError + Raised when GitHub does not return a created label or rejects an + update. + + Notes + ----- + Labels not present in ``labels`` are left unchanged. + + """ + return tuple(_sync_label(repository, label) for label in labels) + + +def _sync_label( + repository: GitHubRepository, + label: LabelSpec, +) -> LabelSyncResult: + """Synchronize one label and return its action.""" + existing_label = _find_label(repository, label.name) + if existing_label is None: + created_label = repository.create_label( + label.name, + label.color, + label.description, + ) + if created_label is None: + msg = f"GitHub did not return a label after creating {label.name!r}" + raise RuntimeError(msg) + return LabelSyncResult(label.name, "created") + + existing_description = existing_label.description + existing_values = ( + existing_label.name, + existing_label.color.removeprefix("#").upper(), + None if existing_description is None else existing_description.strip(), + ) + desired_values = (label.name, label.color, label.description) + if existing_values == desired_values: + return LabelSyncResult(label.name, "unchanged") + + if not existing_label.update(label.name, label.color, label.description): + msg = f"GitHub rejected update for label {label.name!r}" + raise RuntimeError(msg) + return LabelSyncResult(label.name, "updated") + + +def _find_label(repository: GitHubRepository, name: str) -> GitHubLabel | None: + """Return an existing label from the repository.""" + return repository.label(name) diff --git a/docs/contents.md b/docs/contents.md new file mode 100644 index 0000000..d5357fb --- /dev/null +++ b/docs/contents.md @@ -0,0 +1,34 @@ +# Documentation contents + +This page is the index for project documentation. Use it to find the +maintainer-facing guide, user-facing guide, style rules, and operational +reference material. + +## Core guides + +- [Users' guide](users-guide.md): command usage, configuration files, + repository selection, authentication, GitHub Enterprise and simulator usage, + TOML label configuration, default labels, and local quality gates. +- [Developers' guide](developers-guide.md): package architecture, module + organization, data flow, normalization rules, testing strategy, and local + development workflow. +- [Repository layout](repository-layout.md): top-level directories and files, + package modules, tests, generated artefacts, and maintenance expectations. + +## Standards and references + +- [Documentation style guide](documentation-style-guide.md): spelling, + Markdown, diagram, roadmap, and Architectural Decision Record (ADR) + conventions. +- [Scripting standards](scripting-standards.md): robust scripting practices + for automation and maintenance scripts. +- [Local validation of GitHub Actions with act and pytest](local-validation-of-github-actions-with-act-and-pytest.md): + guidance for validating workflow behaviour locally. + +## Documentation maintenance + +When adding or changing a feature, update the relevant guide in the same +change. User-facing behaviour belongs in the users' guide. Internal +architecture, conventions, and test strategy belong in the developers' guide or +repository layout document. Substantive decisions should be recorded in an ADR +and referenced from the relevant design document. diff --git a/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 0000000..1da444a --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,85 @@ +# create-labels developers' guide + +This guide summarizes the package structure, internal boundaries, and local +development workflow for maintainers. + +## Architecture + +`create-labels` has three small runtime layers: + +- `create_labels.config` owns the TOML parsing boundary. It converts raw data + into `RepositorySpec`, `LabelSpec`, and `LabelConfig`, normalizes label + colours and descriptions, and raises `ConfigError` before any network call is + attempted. +- `create_labels.sync` owns the domain sync decision. It works against + protocol-shaped repository and label objects, so unit tests can use simple + fakes while the GitHub adapter supplies real `github3.py` objects. +- `create_labels.github` owns GitHub integration. It resolves repository, + token, and API URL inputs, creates the GitHub client, fetches the repository, + selects configured labels or `DEFAULT_LABELS`, and delegates mutation + decisions to `sync_labels`. + +The command-line layer in `create_labels.cli` wires Cyclopts arguments into the +configuration and GitHub integration layers. It is intentionally thin so +behaviour remains testable through `load_config`, `sync_labels`, and +`sync_repository_labels`. + +## Data flow + +The normal execution path is: + +1. `cli.main` receives `--config`, `--repository`, `--token`, and `--api-url`. +2. `load_config` parses TOML into a `LabelConfig`, or `cli.main` creates an + empty `LabelConfig` so the imported defaults apply. +3. `sync_repository_labels` resolves repository coordinates and authentication + from explicit arguments, configuration, and environment variables. +4. `sync_labels` looks up each desired label, creates missing labels, updates + changed labels, and returns `LabelSyncResult` values. + +`sync_labels` never deletes labels that are absent from the desired +configuration. Existing label names are URL-encoded for github3.py lookup, but +create and update payloads keep the raw GitHub label name. + +## Normalization rules + +`LabelSpec` is the source of truth for desired label normalization: + +- label names are stripped and must be non-empty; +- colours may include a leading `#`, are uppercased, and must be six + hexadecimal characters; +- descriptions are stripped when present and remain `None` when omitted. + +Remote GitHub labels are normalized to the same shape before sync compares them +with `LabelSpec`. This keeps repeated runs idempotent when GitHub returns +lowercase colours, a leading `#`, padded descriptions, or null descriptions. + +## Testing strategy + +The test suite has three levels: + +- unit tests cover configuration parsing, CLI wiring, and sync decisions using + in-memory fakes; +- behaviour tests in `tests/steps` exercise create/update scenarios through + pytest-bdd; +- the Betamax test records github3.py request behaviour against a local + GitHub-shaped HTTP server. + +Prefer focused unit tests for parser and sync changes. Add behaviour coverage +when a change affects user-visible workflows. + +## Local workflow + +Use Makefile targets for validation: + +```bash +make fmt +make check-fmt +make markdownlint +make nixie +make typecheck +make lint +make test +``` + +Run the same gates before committing. The Makefile provisions the virtual +environment through `uv` and runs pytest with the configured worker count. diff --git a/docs/repository-layout.md b/docs/repository-layout.md new file mode 100644 index 0000000..284fd6b --- /dev/null +++ b/docs/repository-layout.md @@ -0,0 +1,82 @@ +# Repository layout + +This document describes the repository structure for maintainers. It explains +where runtime code, tests, documentation, and validation configuration live. + +## Top-level files + +- `AGENTS.md`: agent instructions for coding, testing, documentation, and + quality gates in this repository. +- `README.md`: public project overview, quick-start commands, and links to the + users' and developers' guides. +- `Makefile`: canonical local validation entrypoint. Prefer Make targets over + invoking tools directly. +- `pyproject.toml`: Python package metadata, dependency groups, and tool + configuration. +- `uv.lock`: locked Python dependency graph for reproducible development + environments. + +## Runtime package + +Runtime code lives under `create_labels/`: + +- `__init__.py`: public import boundary for library consumers. +- `cli.py`: Cyclopts command-line entrypoint and argument wiring. +- `config.py`: TOML parsing, validation, and typed configuration values. +- `defaults.py`: built-in default label set. +- `github.py`: github3.py integration, repository resolution, authentication, + and adapter behaviour. +- `sync.py`: vendor-neutral label synchronization decisions over repository + and label protocols. + +Keep parsing, GitHub integration, defaults, and sync decisions in their +existing modules unless a change needs a new boundary. The sync module should +remain independent of github3.py-specific exceptions and transport behaviour. + +## Tests + +Tests live under `tests/`: + +- `test_config.py`: configuration parsing and default-label regression tests. +- `test_cli.py`: command wiring and CLI output tests. +- `test_github.py`: GitHub adapter error-path tests. +- `test_github_betamax.py`: github3.py integration behaviour recorded through + Betamax against a local GitHub-shaped HTTP server. +- `test_sync.py`: unit tests for label synchronization decisions. +- `steps/`: pytest-bdd step definitions for behavioural scenarios. +- `features/`: Gherkin feature files consumed by pytest-bdd. +- `fixtures/`: JSON and other reusable test fixtures. + +Prefer focused unit tests for parser and sync changes. Add behavioural tests +when a change affects user-visible workflows. Add integration tests when a +change affects GitHub API contracts, command-line behaviour, or another +externally observable boundary. + +## Documentation + +Documentation lives under `docs/`: + +- `contents.md`: documentation index. +- `users-guide.md`: user-facing command and configuration guidance. +- `developers-guide.md`: maintainer-facing architecture and workflow guidance. +- `repository-layout.md`: this repository structure reference. +- `documentation-style-guide.md`: documentation writing and ADR conventions. +- `scripting-standards.md`: standards for scripts and automation. +- `local-validation-of-github-actions-with-act-and-pytest.md`: local workflow + validation guidance. + +Update documentation with the code change that makes it necessary. Behaviour +changes belong in the users' guide; internal interfaces and conventions belong +in the developers' guide or this layout reference. + +## Generated and local artefacts + +The following paths are local or generated and should not be treated as source +design inputs: + +- `.venv/`: local virtual environment managed by `uv`. +- `.uv-cache/` and `.uv-tools/`: local `uv` cache and tool directories. +- `.pytest_cache/`: pytest cache. +- `__pycache__/`: Python bytecode cache directories. + +Use `make clean` to remove common generated artefacts when needed. diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index 9c4885d..0c08944 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -32,8 +32,9 @@ as a default. integration constraints require them, and any exception must be documented inline. - Each script starts with an `uv` script block so runtime and dependency - expectations travel with the file. Prefer the shebang `#!/usr/bin/env -S uv - run python` followed by the metadata block shown in the example below. + expectations travel with the file. Prefer the shebang + `#!/usr/bin/env -S uv run python` followed by the metadata block shown in the + example below. - External processes are invoked via [`plumbum`](https://plumbum.readthedocs.io) to provide structured command execution rather than ad‑hoc shell strings. - File‑system interactions use `pathlib.Path`. Higher‑level operations (for diff --git a/docs/users-guide.md b/docs/users-guide.md index f798c33..1397bb8 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -1,43 +1,105 @@ # create-labels Users' Guide -## Quality Gates +`create-labels` creates and updates GitHub repository labels from a TOML +configuration file. When no labels are provided in configuration, it applies +the default label set imported from Axinite's +`.github/scripts/create-labels.sh` script. -Generated projects use `make all` as the standard local quality gate. It runs -these targets in order: +## Command -- `build`: create the local virtual environment and install development - dependencies with `uv sync --group dev`. -- `check-fmt`: check Ruff formatting for Python sources and, when Rust is - enabled, `cargo fmt` for the Rust extension. -- `lint`: run `lint-python` and, when Rust is enabled, `lint-rust`. -- `typecheck`: run `ty check`. -- `test`: run pytest and, when Rust is enabled, Rust tests. +Install the package and run the console script: -The `lint-python` target runs Ruff followed by Pylint via a PyPy-backed runner. -The Pylint runner is installed through `uv tool run` from the pinned -`pylint-pypy-shim` repository. +```bash +create-labels --repository owner/repo --token "$GITHUB_TOKEN" +``` -When the Rust extension is enabled, `lint-rust` runs: +The command uses `github3.py` for GitHub API access. It does not shell out to +the GitHub CLI. -- `cargo doc` with warnings denied; -- `cargo clippy` with the generated Clippy configuration; and -- Whitaker with `whitaker --all`. +## Configuration files -The generated Makefile installs Whitaker on demand before local Rust linting -when it is not already available. +Pass a TOML configuration file with `--config`: -## Rust Test Behaviour +```bash +create-labels --config labels.toml --repository owner/repo +``` -Rust-enabled projects use `cargo nextest run` when `cargo-nextest` is available. -If `cargo-nextest` is not installed, the generated `test` target falls back to -`cargo test`. Rust documentation tests still run through `cargo test --doc`. +When `--config` is omitted, the imported default labels are used. Repository, +authentication, and API URL values can still come from CLI options or +environment variables. -If cargo is missing from the local environment, generated Rust test targets fail -early with a clear error instead of falling through to an unusable `cargo` -invocation. +## Repository Selection -## Cleaning Local State +The target repository can be supplied in three ways, in priority order: -Run `make clean` to remove local build and cache outputs, including `.venv`, -`.uv-cache`, `.uv-tools`, Python cache directories, coverage outputs, and Rust -`target` output when the Rust extension is enabled. +- `--repository owner/repo`; +- `[repository]` in the TOML file; or +- the `GITHUB_REPOSITORY` environment variable. + +## Authentication + +Pass a token with `--token`, or set `GITHUB_TOKEN` in the environment: + +```bash +GITHUB_TOKEN=ghp_example create-labels --repository owner/repo +``` + +## GitHub Enterprise and Simulators + +Use `--api-url` to target GitHub Enterprise or a local GitHub API simulator: + +```bash +create-labels --repository owner/repo --api-url http://127.0.0.1:3000 +``` + +The same value can be stored as `github.api_url` in TOML. + +## TOML Configuration + +Each label must have a `name`. `color` and `description` are optional. Colours +may include a leading `#`; they are normalized to the six-character hex form +expected by GitHub. + +```toml +[repository] +owner = "leynos" +name = "example" + +[github] +api_url = "https://api.github.com" + +[[labels]] +name = "risk: low" +color = "4CAF50" +description = "Changes to docs, tests, or low-risk modules" + +[[labels]] +name = "needs-review" +``` + +Labels omitted from the file are not deleted from GitHub. The tool only creates +missing labels and updates labels named in the effective configuration. + +## Default Labels + +If the TOML file contains no `[[labels]]` entries, the imported Axinite label +set is used. It includes: + +- size labels; +- risk labels; +- scope labels; +- workflow labels; and +- contributor labels. + +## Local Quality Gates + +Use the Makefile targets for local validation: + +```bash +make check-fmt +make typecheck +make lint +make test +``` + +`make test` runs the unit tests and pytest-bdd behavioural scenarios. diff --git a/pyproject.toml b/pyproject.toml index 8846b9b..cea3e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,25 @@ description = "create-labels package" readme = "README.md" requires-python = ">=3.14" license = { text = "ISC" } -dependencies = [] +dependencies = [ + "cyclopts", + "github3.py", +] + +[project.scripts] +create-labels = "create_labels.cli:run" [dependency-groups] dev = [ "pytest", + "pytest-bdd", "ruff", "pyright", "ty", "pytest-timeout", "pytest-xdist", + "simulacat @ git+https://github.com/leynos/simulacat@3a62fe852a60e4060c0e17eb90a2dc0a117e143e", + "betamax>=0.9.0", ] [tool.ruff] @@ -306,4 +315,3 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["create_labels"] - diff --git a/tests/features/synchronise_labels.feature b/tests/features/synchronise_labels.feature new file mode 100644 index 0000000..da4622d --- /dev/null +++ b/tests/features/synchronise_labels.feature @@ -0,0 +1,7 @@ +Feature: Synchronise GitHub labels + Scenario: Create and update configured labels + Given a repository with an existing "risk: low" label + And a label configuration containing "risk: low" and "risk: high" + When the labels are synchronised + Then the existing "risk: low" label is updated + And the missing "risk: high" label is created diff --git a/tests/fixtures/repository_payload.json b/tests/fixtures/repository_payload.json new file mode 100644 index 0000000..36f4b0a --- /dev/null +++ b/tests/fixtures/repository_payload.json @@ -0,0 +1,93 @@ +{ + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_squash_merge": true, + "archive_url": "__REPO_URL__/{archive_format}{/ref}", + "archived": false, + "assignees_url": "__REPO_URL__/assignees{/user}", + "blobs_url": "__REPO_URL__/git/blobs{/sha}", + "branches_url": "__REPO_URL__/branches{/branch}", + "clone_url": "https://github.com/octocat/hello-world.git", + "collaborators_url": "__REPO_URL__/collaborators{/collaborator}", + "comments_url": "__REPO_URL__/comments{/number}", + "commits_url": "__REPO_URL__/commits{/sha}", + "compare_url": "__REPO_URL__/compare/{base}...{head}", + "contents_url": "__REPO_URL__/contents/{+path}", + "contributors_url": "__REPO_URL__/contributors", + "created_at": "2026-01-01T00:00:00Z", + "default_branch": "main", + "deployments_url": "__REPO_URL__/deployments", + "description": "Example repository", + "downloads_url": "__REPO_URL__/downloads", + "events_url": "__REPO_URL__/events", + "fork": false, + "forks_count": 0, + "forks_url": "__REPO_URL__/forks", + "full_name": "octocat/hello-world", + "git_commits_url": "__REPO_URL__/git/commits{/sha}", + "git_refs_url": "__REPO_URL__/git/refs{/sha}", + "git_tags_url": "__REPO_URL__/git/tags{/sha}", + "git_url": "git://github.com/octocat/hello-world.git", + "has_downloads": true, + "has_issues": true, + "has_pages": false, + "has_projects": true, + "has_wiki": true, + "homepage": null, + "hooks_url": "__REPO_URL__/hooks", + "html_url": "https://github.com/octocat/hello-world", + "id": 1, + "issue_comment_url": "__REPO_URL__/issues/comments{/number}", + "issue_events_url": "__REPO_URL__/issues/events{/number}", + "issues_url": "__REPO_URL__/issues{/number}", + "keys_url": "__REPO_URL__/keys{/key_id}", + "labels_url": "__REPO_URL__/labels{/name}", + "language": "Python", + "languages_url": "__REPO_URL__/languages", + "license": null, + "merges_url": "__REPO_URL__/merges", + "milestones_url": "__REPO_URL__/milestones{/number}", + "mirror_url": null, + "name": "hello-world", + "network_count": 0, + "notifications_url": "__REPO_URL__/notifications{?since,all,participating}", + "open_issues_count": 0, + "owner": { + "avatar_url": "https://avatars.example/octocat", + "events_url": "__BASE_URL__/users/octocat/events{/privacy}", + "followers_url": "__BASE_URL__/users/octocat/followers", + "following_url": "__BASE_URL__/users/octocat/following{/other_user}", + "gists_url": "__BASE_URL__/users/octocat/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/octocat", + "id": 1, + "login": "octocat", + "organizations_url": "__BASE_URL__/users/octocat/orgs", + "received_events_url": "__BASE_URL__/users/octocat/received_events", + "repos_url": "__BASE_URL__/users/octocat/repos", + "site_admin": false, + "starred_url": "__BASE_URL__/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "__BASE_URL__/users/octocat/subscriptions", + "type": "User", + "url": "__BASE_URL__/users/octocat" + }, + "private": false, + "pulls_url": "__REPO_URL__/pulls{/number}", + "pushed_at": "2026-01-01T00:00:00Z", + "releases_url": "__REPO_URL__/releases{/id}", + "size": 1, + "ssh_url": "git@github.com:octocat/hello-world.git", + "stargazers_count": 0, + "stargazers_url": "__REPO_URL__/stargazers", + "statuses_url": "__REPO_URL__/statuses/{sha}", + "subscribers_count": 0, + "subscribers_url": "__REPO_URL__/subscribers", + "subscription_url": "__REPO_URL__/subscription", + "svn_url": "https://github.com/octocat/hello-world", + "tags_url": "__REPO_URL__/tags", + "teams_url": "__REPO_URL__/teams", + "trees_url": "__REPO_URL__/git/trees{/sha}", + "updated_at": "2026-01-01T00:00:00Z", + "url": "__REPO_URL__", + "watchers_count": 0 +} diff --git a/tests/steps/test_synchronise_labels.py b/tests/steps/test_synchronise_labels.py new file mode 100644 index 0000000..e5b9f6f --- /dev/null +++ b/tests/steps/test_synchronise_labels.py @@ -0,0 +1,95 @@ +"""Behavioural tests for label synchronisation.""" + +from __future__ import annotations + +import typing as typ + +import pytest +from pytest_bdd import given, parsers, scenarios, then, when + +from create_labels.config import LabelSpec +from create_labels.sync import LabelSyncResult, sync_labels +from tests.test_helpers import FakeRepository, make_fake_label, make_fake_repository + +scenarios("../features/synchronise_labels.feature") + +_LOW_RISK_COLOR = "4CAF50" +_LOW_RISK_DESC = "Low risk" +_HIGH_RISK_COLOR = "F44336" +_HIGH_RISK_DESC = "High risk" + + +class LabelSyncContext(typ.TypedDict): + """Shared state for a label synchronisation scenario.""" + + repository: FakeRepository + labels: tuple[LabelSpec, ...] + results: tuple[LabelSyncResult, ...] + + +@pytest.fixture +def label_sync_context() -> LabelSyncContext: + """Provide scenario state.""" + return { + "repository": make_fake_repository(), + "labels": (), + "results": (), + } + + +@given(parsers.parse('a repository with an existing "{name}" label')) +def given_existing_label(label_sync_context: LabelSyncContext, name: str) -> None: + """Create a repository label before synchronisation.""" + label_sync_context["repository"].labels[name] = make_fake_label(name) + + +@given(parsers.parse('a label configuration containing "{low}" and "{high}"')) +def given_label_configuration( + label_sync_context: LabelSyncContext, + low: str, + high: str, +) -> None: + """Create the desired label configuration.""" + label_sync_context["labels"] = ( + LabelSpec(low, _LOW_RISK_COLOR, _LOW_RISK_DESC), + LabelSpec(high, _HIGH_RISK_COLOR, _HIGH_RISK_DESC), + ) + + +@when("the labels are synchronised") +def when_labels_are_synchronised(label_sync_context: LabelSyncContext) -> None: + """Synchronise configured labels.""" + label_sync_context["results"] = sync_labels( + label_sync_context["repository"], + label_sync_context["labels"], + ) + + +@then(parsers.parse('the existing "{name}" label is updated')) +def then_existing_label_is_updated( + label_sync_context: LabelSyncContext, + name: str, +) -> None: + """Check the existing label was updated.""" + label = label_sync_context["repository"].labels[name] + assert label.updates == [LabelSpec(name, _LOW_RISK_COLOR, _LOW_RISK_DESC)], ( + f"expected updates to contain low-risk LabelSpec for {name}" + ) + assert LabelSyncResult(name, "updated") in label_sync_context["results"], ( + f"expected label sync results to include updated result for {name}" + ) + + +@then(parsers.parse('the missing "{name}" label is created')) +def then_missing_label_is_created( + label_sync_context: LabelSyncContext, + name: str, +) -> None: + """Check the missing label was created.""" + assert ( + LabelSpec(name, _HIGH_RISK_COLOR, _HIGH_RISK_DESC) + in label_sync_context["repository"].created + ), f"expected created labels to contain {name} with correct color/desc" + assert LabelSyncResult(name, "created") in label_sync_context["results"], ( + f"expected label sync results to include created result for {name}" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7fe8d8e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,128 @@ +"""Unit tests for the Cyclopts command implementation.""" + +from __future__ import annotations + +import typing as typ + +import pytest + +from create_labels import cli +from create_labels.config import LabelConfig, RepositorySpec +from create_labels.sync import LabelSyncResult + +_SYNC_CALL_KEYS = frozenset({"config", "repository", "token", "api_url"}) +_MALFORMED_REPOSITORIES = ("", "owner", "owner/", "/name", "owner/name/extra") + +if typ.TYPE_CHECKING: + import pathlib + + +class SyncCall: + """Test double for ``sync_repository_labels`` used by CLI tests. + + ``SyncCall`` records the keyword arguments passed by ``cli.main`` and + returns a deterministic ``LabelSyncResult`` for assertions. It raises + ``AssertionError`` on unexpected keyword names so CLI wiring changes fail + close to the call site. + """ + + def __init__( + self, + results: tuple[LabelSyncResult, ...] = ( + LabelSyncResult("risk: low", "created"), + ), + ) -> None: + self.results = results + self.config: LabelConfig | None = None + self.repository: RepositorySpec | None = None + self.token: str | None = None + self.api_url: str | None = None + + def __call__(self, **kwargs: object) -> tuple[LabelSyncResult, ...]: + """Record the sync invocation.""" + unexpected_keys = set(kwargs) - _SYNC_CALL_KEYS + if unexpected_keys: + msg = f"Unexpected sync kwargs: {sorted(unexpected_keys)}" + raise AssertionError(msg) + + self.config = typ.cast("LabelConfig", kwargs["config"]) + self.repository = typ.cast("RepositorySpec | None", kwargs["repository"]) + self.token = typ.cast("str | None", kwargs["token"]) + self.api_url = typ.cast("str | None", kwargs["api_url"]) + return self.results + + +def test_main_loads_config_and_prints_sync_results( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The CLI command wires config and repository overrides into sync.""" + config_path = tmp_path / "labels.toml" + config_path.write_text( + """ + [[labels]] + name = "risk: low" + color = "4CAF50" + """, + encoding="utf-8", + ) + sync_call = SyncCall() + auth_value = "sample-auth-value" + monkeypatch.setattr(cli, "sync_repository_labels", sync_call) + + cli.main( + config=str(config_path), + repository="owner/repo", + token=auth_value, + api_url="http://localhost:3000", + ) + + assert sync_call.config is not None, "expected sync_call.config to be set" + assert len(sync_call.config.labels) == 1, "expected exactly one configured label" + assert sync_call.config.labels[0].name == "risk: low", ( + "expected first label name to be risk: low" + ) + assert sync_call.repository == RepositorySpec( + "owner", + "repo", + ), "expected parsed repository override" + assert sync_call.token == auth_value, "expected token to match auth_value" + assert sync_call.api_url == "http://localhost:3000", ( + "expected api_url to be http://localhost:3000" + ) + assert capsys.readouterr().out == "created: risk: low\n", ( + "expected stdout to contain created result" + ) + + +@pytest.mark.parametrize( + ("result", "expected_output"), + [ + (LabelSyncResult("risk: low", "created"), "created: risk: low\n"), + (LabelSyncResult("risk: medium", "updated"), "updated: risk: medium\n"), + ( + LabelSyncResult("risk: high", "unchanged"), + "unchanged: risk: high\n", + ), + ], +) +def test_main_prints_each_sync_result_action( + result: LabelSyncResult, + expected_output: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The CLI prints every sync result action using stable text.""" + monkeypatch.setattr(cli, "sync_repository_labels", SyncCall((result,))) + + cli.main(repository="owner/repo") + + assert capsys.readouterr().out == expected_output + + +@pytest.mark.parametrize("repository", _MALFORMED_REPOSITORIES) +def test_main_rejects_malformed_repository(repository: str) -> None: + """Repository CLI values must use owner/name form.""" + with pytest.raises(ValueError, match="owner/name"): + cli.main(repository=repository) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..efb530b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,117 @@ +"""Unit tests for label configuration parsing.""" + +from __future__ import annotations + +import typing as typ + +import pytest + +from create_labels.config import ConfigError, LabelSpec, load_config, parse_config +from create_labels.defaults import DEFAULT_LABELS + +if typ.TYPE_CHECKING: + import pathlib + + +def test_default_labels_import_original_shell_script_set() -> None: + """The default package data preserves the imported bootstrap labels.""" + # DEFAULT_LABELS mirrors the shell script groups: + # size:5 + risk:4 + scope:30 + ecosystem:3 + workflow:1 + contributor:4 = 47. + assert len(DEFAULT_LABELS) == 47 + assert DEFAULT_LABELS[0] == LabelSpec( + "size: XS", + "F9D0C4", + "< 10 changed lines (excluding docs)", + ) + assert DEFAULT_LABELS[-1] == LabelSpec( + "contributor: core", + "FF8A65", + "20+ merged PRs", + ) + assert ( + LabelSpec( + "dependencies", + "0366D6", + "Dependency updates and dependency manager pull requests", + ) + in DEFAULT_LABELS + ) + assert ( + LabelSpec("github-actions", "2088FF", "GitHub Actions workflow updates") + in DEFAULT_LABELS + ) + assert LabelSpec("cargo", "DEA584", "Rust Cargo package and lockfile updates") in ( + DEFAULT_LABELS + ) + + +def test_load_config_parses_repository_api_url_and_optional_label_fields( + tmp_path: pathlib.Path, +) -> None: + """TOML config can override repository, API URL, colour, and description.""" + config_path = tmp_path / "labels.toml" + config_path.write_text( + """ + [repository] + owner = "leynos" + name = "create-labels" + + [github] + api_url = "http://127.0.0.1:1234/" + + [[labels]] + name = "needs-review" + color = "#abcdef" + description = "Ready for review" + + [[labels]] + name = "plain-label" + """, + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.repository is not None + assert config.repository.full_name == "leynos/create-labels" + # config.api_url intentionally drops a trailing slash for stable base URLs. + assert config.api_url == "http://127.0.0.1:1234" + assert config.labels == ( + LabelSpec("needs-review", "ABCDEF", "Ready for review"), + LabelSpec("plain-label"), + ) + + +def test_config_rejects_slash_only_api_url(tmp_path: pathlib.Path) -> None: + """Slash-only API URLs are empty after normalization and must fail.""" + with pytest.raises(ConfigError, match=r"github\.api_url"): + parse_config({"github": {"api_url": "/"}}) + + config_path = tmp_path / "labels.toml" + config_path.write_text( + """ + [github] + api_url = " / " + """, + encoding="utf-8", + ) + + with pytest.raises(ConfigError, match=r"github\.api_url"): + load_config(config_path) + + +def test_parse_config_rejects_duplicate_labels() -> None: + """Duplicate label names would make sync order ambiguous.""" + with pytest.raises(ConfigError, match="Duplicate label definitions"): + parse_config({ + "labels": [ + {"name": "risk: low"}, + {"name": "Risk: Low"}, + ], + }) + + +def test_parse_config_rejects_malformed_colours() -> None: + """GitHub label colours must be six hex digits.""" + with pytest.raises(ConfigError, match="invalid colour"): + parse_config({"labels": [{"name": "broken", "color": "blue"}]}) diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..1c8fddb --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,55 @@ +"""Unit tests for GitHub adapter error handling.""" + +from __future__ import annotations + +import pytest + +from create_labels.config import LabelConfig, RepositorySpec +from create_labels.github import GitHubError, sync_repository_labels + + +def test_sync_repository_labels_requires_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Repository coordinates must come from CLI, config, or environment.""" + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + auth_value = "sample-auth-value" + + with pytest.raises(GitHubError, match="Repository must be provided"): + sync_repository_labels( + config=LabelConfig(None, ()), + repository=None, + token=auth_value, + api_url=None, + ) + + +def test_sync_repository_labels_rejects_malformed_environment_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Malformed ``GITHUB_REPOSITORY`` values are reported clearly.""" + auth_value = "sample-auth-value" + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/name/extra") + + with pytest.raises(GitHubError, match="owner/name format"): + sync_repository_labels( + config=LabelConfig(None, ()), + repository=None, + token=auth_value, + api_url=None, + ) + + +def test_sync_repository_labels_requires_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Authentication must come from CLI or ``GITHUB_TOKEN``.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + with pytest.raises(GitHubError, match="GitHub token"): + sync_repository_labels( + config=LabelConfig(None, ()), + repository=RepositorySpec("owner", "repo"), + token=None, + api_url=None, + ) diff --git a/tests/test_github_betamax.py b/tests/test_github_betamax.py new file mode 100644 index 0000000..0faf826 --- /dev/null +++ b/tests/test_github_betamax.py @@ -0,0 +1,211 @@ +"""github3.py integration tests recorded through Betamax.""" + +from __future__ import annotations + +import json +import pathlib +import threading +import typing as typ +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import github3 +from betamax import Betamax +from github3.session import GitHubSession + +from create_labels.config import LabelSpec +from create_labels.github import _GitHubRepositoryAdapter +from create_labels.sync import LabelSyncResult, sync_labels + +_PAYLOAD_FIXTURE = ( + pathlib.Path(__file__).parent / "fixtures" / "repository_payload.json" +) + + +class LabelApiHandler(BaseHTTPRequestHandler): + """Minimal GitHub API mock used for Betamax integration tests. + + The handler implements repository lookup plus label GET, PATCH, and POST + endpoints. It records all ``(method, path)`` pairs in ``requests`` for + assertions and returns JSON payloads shaped for github3.py. It is not a + general GitHub API simulator. + """ + + requests: typ.ClassVar[list[tuple[str, str]]] + _requests_lock: typ.ClassVar[threading.Lock] + + def do_GET(self) -> None: + """Serve repository lookup and existing-label lookup.""" + self._record_request("GET") + if self.path == "/repos/octocat/hello-world": + self._send_json(200, self._repository_payload()) + return + + label_name = _label_name_from_path(self.path) + if label_name == "risk: low": + self._send_json(200, self._label_payload(label_name, "FFFFFF", None)) + return + + self._send_json(404, {"message": "Not Found"}) + + def do_PATCH(self) -> None: + """Serve label updates.""" + self._record_request("PATCH") + request = self._read_json() + self._send_json( + 200, + self._label_payload( + str(request["name"]), + str(request["color"]), + typ.cast("str | None", request.get("description")), + ), + ) + + def do_POST(self) -> None: + """Serve label creation.""" + self._record_request("POST") + request = self._read_json() + self._send_json( + 201, + self._label_payload( + str(request["name"]), + str(request["color"]), + typ.cast("str | None", request.get("description")), + ), + ) + + def log_message( + self, + format: str, # noqa: A002 - BaseHTTPRequestHandler interface contract. + *args: object, + ) -> None: + """Silence request logs during tests.""" + + def _read_json(self) -> dict[str, object]: + length = int(self.headers.get("content-length", "0")) + raw_body = self.rfile.read(length) + return typ.cast("dict[str, object]", json.loads(raw_body.decode("utf-8"))) + + def _record_request(self, method: str) -> None: + with self.__class__._requests_lock: + self.requests.append((method, self.path)) + + def _send_json(self, status: int, payload: dict[str, object]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _repository_payload(self) -> dict[str, object]: + base_url = self._base_url() + repo_url = f"{base_url}/repos/octocat/hello-world" + raw_payload = _PAYLOAD_FIXTURE.read_text(encoding="utf-8") + payload = typ.cast("dict[str, object]", json.loads(raw_payload)) + return _replace_payload_urls(payload, base_url=base_url, repo_url=repo_url) + + def _label_payload( + self, + name: str, + color: str, + description: str | None, + ) -> dict[str, object]: + return { + "id": abs(hash(name)) % 100_000, + "name": name, + "color": color, + "description": description, + "url": f"{self._base_url()}/repos/octocat/hello-world/labels/{name}", + } + + def _base_url(self) -> str: + server = typ.cast("ThreadingHTTPServer", self.server) + return f"http://127.0.0.1:{server.server_port}" + + +def test_sync_labels_uses_github3_requests_recorded_by_betamax( + tmp_path: pathlib.Path, +) -> None: + """Betamax records github3.py HTTP calls against a GitHub-shaped API.""" + LabelApiHandler.requests = [] + LabelApiHandler._requests_lock = threading.Lock() + server = ThreadingHTTPServer(("127.0.0.1", 0), LabelApiHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + results = _sync_with_betamax(server.server_port, tmp_path) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert results == ( + LabelSyncResult("risk: low", "updated"), + LabelSyncResult("risk: high", "created"), + ) + with LabelApiHandler._requests_lock: + recorded = list(LabelApiHandler.requests) + assert ( + "PATCH", + "/repos/octocat/hello-world/labels/risk:%20low", + ) in recorded + assert ("POST", "/repos/octocat/hello-world/labels") in recorded + + +def _sync_with_betamax( + port: int, + cassette_directory: pathlib.Path, +) -> tuple[LabelSyncResult, ...]: + """Record github3.py HTTP traffic through Betamax and return sync results.""" + session = GitHubSession() + session.base_url = f"http://127.0.0.1:{port}" + auth_value = "sample-auth-value" + + recorder = Betamax(session, cassette_library_dir=str(cassette_directory)) + with recorder.use_cassette("github-label-sync", record="all"): + github = github3.GitHub(token=auth_value, session=session) + repository = github.repository("octocat", "hello-world") + assert repository is not None + return sync_labels( + _GitHubRepositoryAdapter(repository), + ( + LabelSpec("risk: low", "4CAF50", "Low risk"), + LabelSpec("risk: high", "F44336", "High risk"), + ), + ) + + +def _label_name_from_path(path: str) -> str | None: + """Extract URL-decoded label name from GitHub API path, or None.""" + label_path = "/repos/octocat/hello-world/labels/" + if not path.startswith(label_path): + return None + return urllib.parse.unquote(path.removeprefix(label_path)) + + +def _replace_payload_urls( + value: object, + *, + base_url: str, + repo_url: str, +) -> dict[str, object]: + replaced = _replace_placeholders(value, base_url=base_url, repo_url=repo_url) + return typ.cast("dict[str, object]", replaced) + + +def _replace_placeholders(value: object, *, base_url: str, repo_url: str) -> object: + if isinstance(value, str): + return value.replace("__BASE_URL__", base_url).replace("__REPO_URL__", repo_url) + if isinstance(value, list): + return [ + _replace_placeholders(item, base_url=base_url, repo_url=repo_url) + for item in value + ] + if isinstance(value, dict): + return { + key: _replace_placeholders(item, base_url=base_url, repo_url=repo_url) + for key, item in value.items() + } + return value diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..712d398 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,63 @@ +"""Shared test doubles for label synchronisation tests.""" + +from __future__ import annotations + +import urllib.parse + +from create_labels.config import LabelSpec + + +class FakeLabel: + """In-memory label object with the github3.py update shape.""" + + def __init__(self, name: str, color: str, description: str | None) -> None: + self.name = name + self.color = color + self.description = description + self.updates: list[LabelSpec] = [] + + def update(self, name: str, color: str, description: str | None = None) -> bool: + """Record an update and return success.""" + self.name = name + self.color = color + self.description = description + self.updates.append(LabelSpec(name, color, description)) + return True + + +class FakeRepository: + """In-memory repository with the subset of github3.py used by sync.""" + + def __init__(self, labels: list[FakeLabel] | None = None) -> None: + self.labels = {label.name: label for label in labels or []} + self.created: list[LabelSpec] = [] + + def label(self, name: str) -> FakeLabel | None: + """Return an existing label or None.""" + return self.labels.get(urllib.parse.unquote(name)) + + def create_label( + self, + name: str, + color: str, + description: str | None = None, + ) -> FakeLabel | None: + """Create and record a new label.""" + label = FakeLabel(name, color, description) + self.labels[name] = label + self.created.append(LabelSpec(name, color, description)) + return label + + +def make_fake_label( + name: str, + color: str = "FFFFFF", + description: str | None = None, +) -> FakeLabel: + """Build a fake label.""" + return FakeLabel(name, color, description) + + +def make_fake_repository(labels: list[FakeLabel] | None = None) -> FakeRepository: + """Build a fake repository.""" + return FakeRepository(labels) diff --git a/tests/test_stub.py b/tests/test_stub.py deleted file mode 100644 index f605330..0000000 --- a/tests/test_stub.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Tests for the generated package stub.""" - -from __future__ import annotations - -import create_labels - - -def test_hello_returns_stub_greeting() -> None: - """The generated package exposes a working greeting.""" - assert create_labels.hello() == "hello from Python" diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..6a614b6 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,144 @@ +"""Unit tests for label synchronisation.""" + +from __future__ import annotations + +import pytest + +from create_labels.config import LabelSpec +from create_labels.sync import LabelSyncResult, sync_labels +from tests.test_helpers import FakeLabel, make_fake_label, make_fake_repository + + +def test_sync_labels_creates_missing_labels() -> None: + """Missing labels are created with the configured metadata.""" + repository = make_fake_repository() + + results = sync_labels( + repository, + [LabelSpec("risk: low", "4CAF50", "Low-risk change")], + ) + + assert results == (LabelSyncResult("risk: low", "created"),) + assert repository.created == [LabelSpec("risk: low", "4CAF50", "Low-risk change")] + + +def test_sync_labels_updates_existing_labels() -> None: + """Existing labels are force-updated to match configuration.""" + existing = make_fake_label("risk: low") + repository = make_fake_repository([existing]) + + results = sync_labels( + repository, + [LabelSpec("risk: low", "4CAF50", "Low-risk change")], + ) + + assert results == (LabelSyncResult("risk: low", "updated"),) + assert existing.updates == [LabelSpec("risk: low", "4CAF50", "Low-risk change")] + + +def test_sync_labels_leaves_matching_existing_labels_unchanged() -> None: + """Existing labels that already match configuration are not updated.""" + existing = make_fake_label("risk: low", "4CAF50", "Low-risk change") + repository = make_fake_repository([existing]) + + results = sync_labels( + repository, + [LabelSpec("risk: low", "4CAF50", "Low-risk change")], + ) + + assert results == (LabelSyncResult("risk: low", "unchanged"),) + assert not existing.updates + + +def test_sync_labels_normalizes_existing_label_fields_before_comparison() -> None: + """GitHub field formatting differences do not force redundant updates.""" + existing = make_fake_label("risk: low", "#4caf50", " Low-risk change ") + repository = make_fake_repository([existing]) + + results = sync_labels( + repository, + [LabelSpec("risk: low", "4CAF50", "Low-risk change")], + ) + + assert results == (LabelSyncResult("risk: low", "unchanged"),) + assert not existing.updates + + +def test_sync_labels_preserves_null_descriptions_when_comparing() -> None: + """GitHub null descriptions match omitted ``LabelSpec`` descriptions.""" + existing = make_fake_label("needs-review", "#abcdef", None) + repository = make_fake_repository([existing]) + + results = sync_labels(repository, [LabelSpec("needs-review", "ABCDEF")]) + + assert results == (LabelSyncResult("needs-review", "unchanged"),) + assert not existing.updates + + +def test_sync_labels_uses_raw_names_for_repository_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Repository adapters own any transport-specific label-name encoding.""" + existing = make_fake_label("scope: channel/cli", "1976D2", "CLI channel") + repository = make_fake_repository([existing]) + lookups: list[str] = [] + + def label(name: str) -> FakeLabel | None: + lookups.append(name) + if name == "scope: channel/cli": + return existing + return None + + monkeypatch.setattr(repository, "label", label) + + results = sync_labels( + repository, + [LabelSpec("scope: channel/cli", "1976D2", "CLI channel")], + ) + + assert lookups == ["scope: channel/cli"] + assert results == (LabelSyncResult("scope: channel/cli", "unchanged"),) + assert not repository.created + assert not existing.updates + + +@pytest.mark.parametrize( + ("patched_callable", "expected_message"), + [ + ( + "create_label", + "GitHub did not return a label after creating 'risk: low'", + ), + ("update", "GitHub rejected update for label 'risk: low'"), + ], +) +def test_sync_labels_failure_paths( + monkeypatch: pytest.MonkeyPatch, + patched_callable: str, + expected_message: str, +) -> None: + """GitHub create and update failures are reported clearly.""" + existing = make_fake_label("risk: low") if patched_callable == "update" else None + repository = make_fake_repository([existing] if existing is not None else None) + + if patched_callable == "create_label": + + def create_label( + name: str, + color: str, + description: str | None = None, + ) -> None: + return None + + monkeypatch.setattr(repository, "create_label", create_label) + else: + if existing is None: + pytest.fail("update failure path requires an existing label") + + def update(name: str, color: str, description: str | None = None) -> bool: + return False + + monkeypatch.setattr(existing, "update", update) + + with pytest.raises(RuntimeError, match=expected_message): + sync_labels(repository, [LabelSpec("risk: low", "4CAF50", "Low-risk change")]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0b78e6e --- /dev/null +++ b/uv.lock @@ -0,0 +1,648 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "betamax" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/a2/b5a47f7c57ef30337503bf7ea959e498a314018eb74dd833d4bd4a689e03/betamax-0.9.0.tar.gz", hash = "sha256:82316e1679bc6879e3c83318d016b54b7c9225ff08c4462de4813e22038d5f94", size = 79957, upload-time = "2024-02-08T13:13:37.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/80/84dfae1dea86beb985c9aeaf907a7c98e1dd9de2156c8dd2e336d868f38a/betamax-0.9.0-py2.py3-none-any.whl", hash = "sha256:880d6da87eaf7e61c42bdc4240f0ac04ab5365bd7f2798784c18d37d8cf747bc", size = 33418, upload-time = "2024-02-08T13:13:23.888Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[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 = "create-labels" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cyclopts" }, + { name = "github3-py" }, +] + +[package.dev-dependencies] +dev = [ + { name = "betamax" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-bdd" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "simulacat" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "cyclopts" }, + { name = "github3-py" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "betamax", specifier = ">=0.9.0" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-bdd" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "simulacat", git = "https://github.com/leynos/simulacat?rev=3a62fe852a60e4060c0e17eb90a2dc0a117e143e" }, + { name = "ty" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/42/33977afb50c23345551c973fa1d25458d946ad6937373a73acd99ae21d9b/cyclopts-4.16.0.tar.gz", hash = "sha256:6a07b8ada2fa3d7611e227a98b661523c39644a50e04c92839832d9f599f398d", size = 179246, upload-time = "2026-05-24T19:31:59.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/45/9da25f3fe4b99e701b9a704bb6213e2d61bc44ae66294f9728574f3a607a/cyclopts-4.16.0-py3-none-any.whl", hash = "sha256:cbb9f8af92ace82c250178a3a51f5ecec1df95ab99116af3aa7140b218ccd2a1", size = 216887, upload-time = "2026-05-24T19:31:57.924Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "gherkin-official" +version = "29.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d8/7a28537efd7638448f7512a0cce011d4e3bf1c7f4794ad4e9c87b3f1e98e/gherkin_official-29.0.0.tar.gz", hash = "sha256:dbea32561158f02280d7579d179b019160d072ce083197625e2f80a6776bb9eb", size = 32303, upload-time = "2024-08-12T09:41:09.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fc/b86c22ad3b18d8324a9d6fe5a3b55403291d2bf7572ba6a16efa5aa88059/gherkin_official-29.0.0-py3-none-any.whl", hash = "sha256:26967b0d537a302119066742669e0e8b663e632769330be675457ae993e1d1bc", size = 37085, upload-time = "2024-08-12T09:41:07.954Z" }, +] + +[[package]] +name = "github3-py" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/91/603bcaf8cd1b3927de64bf56c3a8915f6653ea7281919140c5bcff2bfe7b/github3.py-4.0.1.tar.gz", hash = "sha256:30d571076753efc389edc7f9aaef338a4fcb24b54d8968d5f39b1342f45ddd36", size = 36214038, upload-time = "2023-04-26T17:56:37.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/2394d4fb542574678b0ba342daf734d4d811768da3c2ee0c84d509dcb26c/github3.py-4.0.1-py3-none-any.whl", hash = "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753", size = 151800, upload-time = "2023-04-26T17:56:25.015Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "parse" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/a2/dd269daedd5ac3a244ca7855b4878d8655393fd4554d5c24a56bc31e302a/parse-1.22.0.tar.gz", hash = "sha256:d4987d68ccf08b6ba3bf80b5004ff7de61c4337cba2d8350ae5c9925794979d9", size = 36767, upload-time = "2026-05-02T01:36:25.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3a/0c2cf5922c6133b74c1cebe4b66f6949818e2cf8121aa59e3ebcd64ac6ac/parse-1.22.0-py2.py3-none-any.whl", hash = "sha256:eea8ed34e2614cea65d9c1d4af9cb68cce26aea13d44bdcaf83c1b40884fe945", size = 20839, upload-time = "2026-05-02T01:36:24.403Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[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 = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-bdd" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gherkin-official" }, + { name = "mako" }, + { name = "packaging" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/2f/14c2e55372a5718a93b56aea48cd6ccc15d2d245364e516cd7b19bbd07ad/pytest_bdd-8.1.0.tar.gz", hash = "sha256:ef0896c5cd58816dc49810e8ff1d632f4a12019fb3e49959b2d349ffc1c9bfb5", size = 56147, upload-time = "2024-12-05T21:45:58.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7d/1461076b0cc9a9e6fa8b51b9dea2677182ba8bc248d99d95ca321f2c666f/pytest_bdd-8.1.0-py3-none-any.whl", hash = "sha256:2124051e71a05ad7db15296e39013593f72ebf96796e1b023a40e5453c47e5fb", size = 49149, upload-time = "2024-12-05T21:45:56.184Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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 = "rich-rst" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + +[[package]] +name = "simulacat" +version = "0.1.0" +source = { git = "https://github.com/leynos/simulacat?rev=3a62fe852a60e4060c0e17eb90a2dc0a117e143e#3a62fe852a60e4060c0e17eb90a2dc0a117e143e" } +dependencies = [ + { name = "github3-py" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "ty" +version = "0.0.39" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/8d/7b5c74dc287fbcb37bae9853cec13bf44717c1735298500e4aeba31579a9/ty-0.0.39.tar.gz", hash = "sha256:f750277e76a01ecd86185960eca73823c26a53c51103568d56d4d904575159fd", size = 5702365, upload-time = "2026-05-22T21:09:56.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/17/9b89802c26d12d0f7a27bc25d4066d941d42891e8898f9f26499f0067e32/ty-0.0.39-py3-none-linux_armv6l.whl", hash = "sha256:c1bb7ac70f1f7d70cc6655fd96558039e4562b10f489fa49c7ebfd5fcee73ad1", size = 11360431, upload-time = "2026-05-22T21:09:18.689Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c6/663ded50e823dbf9fb9d002eca46b7cb1fb2c72b744b84f22ce732a0ee0b/ty-0.0.39-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3435b64c1e59c14c9aa39c20cc018823937cd38d55db853e74d95b8f420569b0", size = 11096281, upload-time = "2026-05-22T21:09:15.383Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/5d38ba9a6456ff4c78d212cf464fd8b9a25d8118465197b0b2dc891c0b19/ty-0.0.39-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f136377ce46c73677701a9e1ad730bf72f699bcec046e422eb79d0886cac3ab", size = 10529674, upload-time = "2026-05-22T21:09:46.471Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/43638cb8106445d3c8817256a0731cde9dd7b6a53ae2e881294bc1930ca3/ty-0.0.39-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b65fb0cc17f03e851d40e210d420be94ab8bc52d041328ad1e45f616036a61", size = 11055561, upload-time = "2026-05-22T21:09:36.981Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/95e62cf4458527ce78dc386eba18f8b10c3fb64cd8c9e7e59b262ff6029d/ty-0.0.39-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4967967bfadf3860ff84c3fccdbaec8edf8aa20d0d727521084733d853de6657", size = 11127185, upload-time = "2026-05-22T21:09:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c0/93666c213db5c71ab1b1f1a0db5f66bf8c7c0e0b0bf59859f5da8f0b3c36/ty-0.0.39-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e10ecb1297099ddf9a1f054f8bd921d1863ce85fb819a3c96ed27865a1ba6ed", size = 11608459, upload-time = "2026-05-22T21:09:12.862Z" }, + { url = "https://files.pythonhosted.org/packages/79/85/3b26585afc8b50230d6464bb0642feef4fab3f847e38b1f0ffa971a81446/ty-0.0.39-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b19cca70e465d71b0510656343883d62372bbe74b7845cae7c0e701d6d5264b", size = 12177101, upload-time = "2026-05-22T21:09:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/1039e4f6afc576dc1c3a4d22a6478904a1ad3766597cd0b93c077ab9dfce/ty-0.0.39-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56c6704b01b9b3d80ff26b2918423b742516d1e469bef830e9254dcedc9185bf", size = 11827815, upload-time = "2026-05-22T21:09:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c5/4688652870e350a76a8157f7ffb59ad54f37d5d10725aa7076f66ac94ec8/ty-0.0.39-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b7840ff46764b6a6757f4ade1cd0530fc3e8a0b435ca93e7602360e4cb90b6", size = 11694429, upload-time = "2026-05-22T21:09:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/fc/72/8a1c4e823bb5bdc935a1c8140e100304e36a68a4139592f170aa9736fdb7/ty-0.0.39-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c62a3a87ce26b50819f0dbf03bd95f23f19eeb87bbc7aa732ec64277c77f1aa", size = 11869846, upload-time = "2026-05-22T21:09:28.053Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/cf982457b861ae22d657c5dcdbc631199f7f90264279db1d17230dfbc3ff/ty-0.0.39-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f8c34bc81a9c3516e49904e9d8330aac385377cca98390193ea02b903a40fcf0", size = 11029763, upload-time = "2026-05-22T21:09:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/95b64f6d43ae6e8f0b7e13dacf9c196d35819af22b1924171fba31383156/ty-0.0.39-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:66f5ab11586a64e79cb692ad685ee5469325c31b5f30bd3554f52f36dbe28cc4", size = 11146761, upload-time = "2026-05-22T21:09:10.178Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/0a89cfb06f7632a05bf56c78e0affb4a40f81759e275376cea75c9c5abe9/ty-0.0.39-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e8d89732bcbbcb091f439e556dfc4932f198b118b47d5b85212c60662099670e", size = 11281843, upload-time = "2026-05-22T21:09:34.234Z" }, + { url = "https://files.pythonhosted.org/packages/0e/53/64c4a27067a46643fea2b3fcf21a8a2f838d91a65ffdd14f2e82945b9538/ty-0.0.39-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:eceb6c91dcd05a231119f82abdd9aa337513de23ca6ac990bc44f88791dc1799", size = 11792477, upload-time = "2026-05-22T21:09:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e8/02f4dd4a12bcdbda0006f9c7ff3b99a4be06bd0d257d3bd4a5b66de074e6/ty-0.0.39-py3-none-win32.whl", hash = "sha256:891c3262314dbc80bf3e872634d23dd216306945daa9a9fcc206ce5ed21ac4c9", size = 10615377, upload-time = "2026-05-22T21:09:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5a/aaeb22faa8d4dae90a287d4c3636c671edcff3b99be5f4fc8b79ad71eef6/ty-0.0.39-py3-none-win_amd64.whl", hash = "sha256:ba7f2d54452535419e90f6f03ff39282999e87b43c21c00559f6d7ad711a36d5", size = 11710711, upload-time = "2026-05-22T21:09:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/a3/17/ae7339651bfcaa5f54698c8c70eaf5031baa400ecb67baec31d03a56cbd4/ty-0.0.39-py3-none-win_arm64.whl", hash = "sha256:eb4cf0fefbbfedf9a352597bb2431ebdcb7eb3a595c0f825f228e897a0ec285d", size = 11081409, upload-time = "2026-05-22T21:09:03.741Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]