diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59c11b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 + +[*.md] +indent_size = 4 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..48f349e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,10 @@ +# Copilot Instructions + +This is `template_python` — a minimal skeleton for a Python repository: `invoke` tasks, a couple of +shared modules under `modules/`, `uv` for environment/dependency management, and CI (ruff, pylint, +pytest, yamllint, actionlint) via `.github/workflows/tests.yml`. It has no AI-agent tooling of its +own — see [`README.md`](../README.md) for the project layout and `invoke` task list. + +For a version of this template with AI-agent tooling (Claude Code, Copilot instructions/prompts, +Cline) layered on top, see +[`template_ai_python`](https://github.com/LevonBecker/template_ai_python), which forks this repo. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a9aea50 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,122 @@ +--- +name: Tests +run-name: "Tests (${{ github.head_ref || github.ref_name }})" + +on: + pull_request: + branches: + - development # Trigger on PRs targeting development + - main # Trigger on PRs targeting main + push: + branches: + - main # Keep the status badge current after every merge + +jobs: + # https://github.com/rhysd/actionlint + action_lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: "3.14" + + - name: Install Dependencies + run: uv sync + + - name: Action Lint + run: | + uv run --no-sync invoke debug.env + uv run --no-sync invoke tests.actionlint + + pylint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: "3.14" + enable-cache: true + + - name: Install Dependencies + run: uv sync + + - name: Pylint + run: | + uv run --no-sync invoke debug.env + uv run --no-sync invoke tests.pylint + + pytest: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: "3.14" + enable-cache: true + + - name: Install Dependencies + run: uv sync + + - name: Pytest + run: | + uv run --no-sync invoke debug.env + uv run --no-sync invoke tests.pytest + + ruff_lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: "3.14" + enable-cache: true + + - name: Install Dependencies + run: uv sync + + - name: Ruff Lint + run: | + uv run --no-sync invoke debug.env + uv run --no-sync invoke tests.rufflint + + yaml_lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + python-version: "3.14" + + - name: Install Dependencies + run: uv sync + + - name: Yaml Lint + run: | + uv run --no-sync invoke debug.env + uv run --no-sync invoke tests.yamllint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b71899 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Basics +*# +*.un~ +*~ +.#* +.*.sw[a-z] +/.yardoc +/_yardoc/ +/coverage/ +/pkg/ +/spec/reports/ +\#*# +pkg/ +**/tmp/** +**/wip/** +**/vendor/** +**/.bin/** +**/.vendor/** +**/.bundle/** + +# Machine-specific config — generated by `inv setup.properties` (see modules/setup/properties.py) +/properties.yml + +# Python +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +**/__pycache__/** +**/*.pyc +**/.ruff_cache/** +**/.pytest_cache/** + +# Virtual environments +**/.venv/** +.venv/ +venv/ +ENV/ +env/ + +# uv +uv.lock.bak + +# Linters / tooling caches +.ruff_cache/ +.pylint.d/ +.mypy_cache/ + +# Linux +**/.directory +**/.Trash-* + +# macOS +**/.DS_Store +**/.localized + +# VSCode +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..fdc3771 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,28 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "codezombiech.gitignore", + "editorconfig.editorconfig", + "esbenp.prettier-vscode", + "github.remotehub", + "github.vscode-github-actions", + "github.vscode-pull-request-github", + "juggernautjp.less-toml", + "ms-python.python", + "ms-vscode.live-server", + "ms-vscode.remote-explorer", + "ms-vscode.remote-repositories", + "redhat.vscode-xml", + "redhat.vscode-yaml", + "repreng.csv", + "ryu1kn.partial-diff", + "streetsidesoftware.code-spell-checker", + "tamasfe.even-better-toml", + "tomoki1207.pdf", + "yzane.markdown-pdf" + ], + "unwantedRecommendations": [ + "mechatroner.rainbow-csv", + "eamodio.gitlens" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f422aab --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + } + }, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" +} diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..5676c78 --- /dev/null +++ b/.yamllint @@ -0,0 +1,42 @@ +--- +# https://yamllint.readthedocs.io/en/stable/rules.html + +extends: default + +ignore: | + **/tmp + **/.venv + **/*.mako.yml + +yaml-files: + - '*.yaml' + - '*.yml' + - '.yamllint' + +rules: + anchors: enable + braces: enable + brackets: enable + colons: enable + commas: enable + comments: + level: warning + comments-indentation: + level: warning + document-end: disable + document-start: + level: warning + empty-lines: disable + empty-values: disable + float-values: disable + hyphens: enable + indentation: enable + key-duplicates: enable + key-ordering: disable + line-length: disable + new-line-at-end-of-file: enable + new-lines: enable + octal-values: disable + quoted-strings: disable + trailing-spaces: enable + truthy: disable diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4fba81b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Levon Becker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6faed53 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Template Python +[![Tests](https://github.com/LevonBecker/template_python/actions/workflows/tests.yml/badge.svg)](https://github.com/LevonBecker/template_python/actions/workflows/tests.yml) + +Minimal skeleton for a Python repository: invoke tasks, a couple of shared modules, uv for +environment/dependency management, and CI (ruff, pylint, pytest, yamllint, actionlint). No AI-agent +tooling — see [`template_ai_python`](https://github.com/LevonBecker/template_ai_python), which forks +this repo and adds that layer on top. + +## Setup +```bash +./setup.sh # macOS/Linux +.\setup.ps1 # Windows (PowerShell) +``` + +Creates a `.venv` with `uv` and installs dependencies. Update `properties.yml` with the local repo +path before running tasks. + +## Project Structure +``` +pyproject.toml # Dependencies (Python >=3.14), ruff/pylint/pytest config +invoke.yml # Invoke config (auto_dash_names: false) +setup.sh # Shell-based setup script (uv venv + uv sync) +properties.yml # Project configuration (repo path/remote, template path/remote) +modules/ + common/ # cli.py, properties.py, utils.py — shared helpers + setup/ # properties.py — creates properties.yml (no-op if it exists), called by setup.sh/setup.ps1; templates/properties/*.yml — tier fragments +tasks/ + __init__.py # Wires the invoke Collection (debug, ruff, setup, tests) plus top-level aliases (fix, test) + combos.py # Top-level aliases: fix, test + debug.py # debug.env — print cwd + sorted env vars + ruff.py # ruff.fix, ruff.format + setup.py # setup.properties — creates/stamps properties.yml + tests.py # tests.actionlint, tests.pylint, tests.pytest, tests.rufflint, tests.yamllint +.github/ + copilot-instructions.md # What this repo is, for GitHub Copilot + workflows/ + tests.yml # CI: ruff + pylint + pytest + yamllint + actionlint, on PRs to development/main +.vscode/ + extensions.json # Recommended VS Code extensions + settings.json # Ruff formatter + Python interpreter settings +``` + +## Invoke Tasks +```sh +uv run --no-sync invoke # List all available tasks +uv run --no-sync invoke test # Ruff + Pylint + pytest + yamllint + actionlint +uv run --no-sync invoke fix # Ruff autocorrect + format +uv run --no-sync invoke setup.properties # Create/stamp properties.yml +``` + +## Modules +| Module | Purpose | +|--------|---------| +| [`modules/common/`](modules/common/README.md) | CLI helpers, `properties.yml` config reader, output/utility helpers | +| [`modules/setup/`](modules/setup/README.md) | Creates `properties.yml` (no-op if it exists) from tier fragments, called by `setup.sh`/`setup.ps1` | + +See [modules/README.md](modules/README.md) for full details. + +## CI +GitHub Actions runs Ruff, Pylint, pytest, yamllint, and actionlint on every pull request targeting +`development` or `main` via `.github/workflows/tests.yml`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/cspell.config.yaml b/cspell.config.yaml new file mode 100644 index 0000000..143ec3f --- /dev/null +++ b/cspell.config.yaml @@ -0,0 +1,27 @@ +--- +version: "0.2" +ignorePaths: [] +dictionaryDefinitions: [] +dictionaries: [] +words: + - booleaness + - diffstat + - frontmatter + - fstring + - Levon + - levonbecker + - mccabe + - oneline + - pycache + - pylint + - pyproject + - pyyaml + - rcfile + - rufflint + - secho + - setuptools + - timespec + - tomlkit + - venv +ignoreWords: [] +import: [] diff --git a/docs/how_tos/local_setup.md b/docs/how_tos/local_setup.md new file mode 100644 index 0000000..e1c071b --- /dev/null +++ b/docs/how_tos/local_setup.md @@ -0,0 +1,13 @@ +# HowTo: Setup Local Workstation +These are the steps to set up your dev workstation or CI worker to use the Python Invoke CLI Wrapper. + +## Assumptions +* macOS (BASH Shell) + +## Prerequisites +* [uv](https://docs.astral.sh/uv/) + * macOS + * `brew install uv` + +#### Install Python Virtual Environment and Project Libraries +`./setup.sh` diff --git a/docs/how_tos/usage.md b/docs/how_tos/usage.md new file mode 100644 index 0000000..9cdb4b8 --- /dev/null +++ b/docs/how_tos/usage.md @@ -0,0 +1,19 @@ +# HowTo: Use Invoke Tasks + +## Prerequisites +* [Local Setup](local_setup.md) + +### Display Invoke Tasks +```bash +uv run --no-sync invoke -l +``` + +#### Autocorrect +```bash +uv run --no-sync invoke fix +``` + +#### Run All Tests +```bash +uv run --no-sync invoke test +``` diff --git a/invoke.yml b/invoke.yml new file mode 100644 index 0000000..a51be29 --- /dev/null +++ b/invoke.yml @@ -0,0 +1,6 @@ +--- + +tasks: + auto_dash_names: false +collections: + auto_dash_names: false diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 0000000..7c36907 --- /dev/null +++ b/modules/README.md @@ -0,0 +1,27 @@ +# Modules + +Reusable Python modules imported by `tasks/*.py` invoke tasks. All functions are module-level — +no classes required except small helper validators in `common/cli.py`. + +## Structure + +``` +modules/ + common/ # cli, properties, utils helpers + setup/ # creates/stamps properties.yml, called by setup.sh/setup.ps1 +``` + +## Submodules + +| Directory | Purpose | +|-----------|---------| +| [`common/`](common/README.md) | CLI helpers, `properties.yml` config reader, output/utility helpers | +| [`setup/`](setup/README.md) | Creates/stamps `properties.yml`, called by `setup.sh`/`setup.ps1` | + +## Conventions + +- One module per file; filename matches the concern in snake_case +- Each `modules/repo/*.py` file exposes a `main()` entry point +- Shell out via `subprocess.run(..., cwd=repo_path)` — never `shell=True` +- Use `modules.common.utils` (`success`/`error`/`warning`/`info`) for all console output +- Resolve repo config via `modules.common.properties` diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..b7b8c06 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1 @@ +"""Project Automation Modules.""" diff --git a/modules/common/README.md b/modules/common/README.md new file mode 100644 index 0000000..2a9b195 --- /dev/null +++ b/modules/common/README.md @@ -0,0 +1,40 @@ +# Common Utilities Module + +Shared utilities and helper functions used across all modules in the template_python repository. + +## Overview + +This module provides common functionality that is used by other modules throughout the repository, including configuration parsing and utility functions. + +## Files + +- `utils.py` — console output helpers: `success()`, `error()`, `warning()`, `info()` (✅/❌/⚠️/ℹ️ + prefixed) +- `properties.py` — reads `properties.yml`: `get_repo_root()`, `get_repo_local()`, + `get_repo_remote()`, `get_template_local()`, `get_template_remote()` +- `cli.py` — Click-like CLI compatibility helpers backed by `argparse` (`echo`, `secho`, `prompt`, + `confirm`, `is_tty`, `Choice`, `command`/`option` decorators) — TUI-safe, no external dependency +- `README.md` — this file + +## Dependencies + +This module depends on: +- Standard library: `pathlib`, `argparse`, `subprocess` +- `pyyaml` — for reading `properties.yml` + +## Architecture + +The common module follows these principles: +- **Shared utilities only** - Functions used by multiple modules +- **No business logic** - Pure utility functions +- **Minimal dependencies** - Only depends on standard library and config +- **Clear error messages** - User-friendly output with emojis +- **Type hints** - Full type annotations for all functions + +## Integration + +Other modules import from common: + +```python +from modules.common.utils import success, error, warning, info +``` diff --git a/modules/common/__init__.py b/modules/common/__init__.py new file mode 100644 index 0000000..4c44e35 --- /dev/null +++ b/modules/common/__init__.py @@ -0,0 +1 @@ +"""Common utilities for repo automation.""" diff --git a/modules/common/cli.py b/modules/common/cli.py new file mode 100644 index 0000000..2a5448d --- /dev/null +++ b/modules/common/cli.py @@ -0,0 +1,253 @@ +"""Click-like CLI compatibility helpers backed by argparse.""" + +from __future__ import annotations + +import argparse +import inspect +import pathlib +import sys +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +def is_tty() -> bool: + """Return True when stdin is interactive.""" + return sys.stdin.isatty() + + +def style(text: Any, fg: str | None = None, bold: bool = False) -> str: + """Return text unchanged (color/style intentionally ignored).""" + del fg, bold + return str(text) + + +def echo(message: str = "", *, err: bool = False) -> None: + """Print a message to stdout/stderr.""" + print(message, file=sys.stderr if err else sys.stdout) + + +def secho(message: str, fg: str | None = None, bold: bool = False, *, err: bool = False) -> None: + """Styled print helper compatible with click.secho.""" + echo(style(message, fg=fg, bold=bold), err=err) + + +class Choice: # pylint: disable=too-few-public-methods + """Simple callable choice validator for prompt(type=...).""" + + def __init__(self, choices: list[str], case_sensitive: bool = True) -> None: + self.case_sensitive = case_sensitive + self.choices = choices + + def __call__(self, value: str) -> str: + for choice in self.choices: + if self.case_sensitive and value == choice: + return choice + if not self.case_sensitive and value.lower() == choice.lower(): + return choice + raise ValueError(f"Expected one of: {', '.join(self.choices)}") + + +class IntRange: # pylint: disable=too-few-public-methods + """Simple callable integer range validator for prompt(type=...).""" + + def __init__(self, min_value: int, max_value: int) -> None: + self.min_value = min_value + self.max_value = max_value + + def __call__(self, value: str) -> int: + parsed = int(value) + if parsed < self.min_value or parsed > self.max_value: + raise ValueError(f"Expected integer in range [{self.min_value}, {self.max_value}]") + return parsed + + +class Path: # pylint: disable=too-few-public-methods + """Simple path type validator similar to click.Path.""" + + def __init__(self, exists: bool = False) -> None: + self.exists = exists + + def __call__(self, value: str) -> str: + path = pathlib.Path(value) + if self.exists and not path.exists(): + raise ValueError(f"Path does not exist: {value}") + return value + + +def _coerce(value: str, value_type: type | object) -> object: + if value_type is str: + return value + if value_type is int: + return int(value) + if value_type is float: + return float(value) + if callable(value_type): + return value_type(value) + return value + + +def prompt( + label: str, + *, + default: object | None = None, + show_default: bool = True, + type: type | object = str, # pylint: disable=redefined-builtin # noqa: A002 +) -> Any: + """Prompt for input in interactive terminals; use default when possible.""" + if not is_tty(): + if default is not None: + return _coerce(str(default), type) + raise RuntimeError(f"Prompt required in non-interactive mode: {label}") + + suffix = "" + if show_default and default not in (None, ""): + suffix = f" [{default}]" + + while True: + raw = input(f"{label}{suffix}: ").strip() + if raw == "": + if default is not None: + return _coerce(str(default), type) + continue + try: + return _coerce(raw, type) + except TypeError: + echo("Invalid input, please try again.", err=True) + except ValueError: + echo("Invalid input, please try again.", err=True) + + +def confirm(label: str, *, default: bool = False) -> bool: + """Prompt yes/no in interactive terminals; use default in non-interactive mode.""" + if not is_tty(): + return default + + hint = "[Y/n]" if default else "[y/N]" + while True: + raw = input(f"{label} {hint}: ").strip().lower() + if raw == "": + return default + if raw in {"y", "yes"}: + return True + if raw in {"n", "no"}: + return False + echo("Please answer yes or no.", err=True) + + +@dataclass +class _OptionSpec: # pylint: disable=too-many-instance-attributes + flags: list[str] + dest: str + default: Any + required: bool + is_flag: bool + help_text: str | None + value_type: Any + dual_flag: tuple[str, str] | None + + +def option(*param_decls: str, **kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator compatible subset of click.option.""" + explicit_dest = next((d for d in param_decls if not d.startswith("-")), None) + flags = [d for d in param_decls if d.startswith("-")] + if not flags: + raise ValueError("option() requires at least one flag declaration") + + dual_flag: tuple[str, str] | None = None + expanded_flags: list[str] = [] + for flag in flags: + if flag.startswith("--") and "/" in flag: + left, right = flag.split("/", maxsplit=1) + dual_flag = (left, right) + expanded_flags.extend([left, right]) + else: + expanded_flags.append(flag) + + long_flags = [f for f in expanded_flags if f.startswith("--")] + if explicit_dest: + dest = explicit_dest + elif long_flags: + dest = long_flags[0][2:].replace("-", "_") + else: + dest = expanded_flags[0].lstrip("-").replace("-", "_") + + spec = _OptionSpec( + flags=expanded_flags, + dest=dest, + default=kwargs.get("default"), + required=kwargs.get("required", False), + is_flag=kwargs.get("is_flag", False), + help_text=kwargs.get("help"), + value_type=kwargs.get("type", str), + dual_flag=dual_flag, + ) + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + specs = getattr(func, "__cli_options__", []) + specs.append(spec) + func.__cli_options__ = specs + return func + + return decorator + + +def _add_option(parser: argparse.ArgumentParser, spec: _OptionSpec) -> None: + kwargs: dict[str, Any] = {"dest": spec.dest, "help": spec.help_text} + + if spec.dual_flag is not None: + parser.set_defaults(**{spec.dest: spec.default if spec.default is not None else False}) + parser.add_argument(spec.dual_flag[0], action="store_true", dest=spec.dest, help=spec.help_text) + parser.add_argument(spec.dual_flag[1], action="store_false", dest=spec.dest, help=argparse.SUPPRESS) + return + + if spec.is_flag: + kwargs["action"] = "store_true" + kwargs["default"] = bool(spec.default) if spec.default is not None else False + parser.add_argument(*spec.flags, **kwargs) + return + + if spec.default is not None: + kwargs["default"] = spec.default + if spec.required: + kwargs["required"] = True + if spec.value_type is not None: + kwargs["type"] = spec.value_type + parser.add_argument(*spec.flags, **kwargs) + + +def _invoked_as_script() -> bool: + """True only when reached via this module's own `if __name__ == "__main__": main()`. + + Distinguishes `python -m modules.x.y [--flags]` (where `sys.argv[1:]` are genuine CLI args + for this command) from an in-process call like `some_module.main()` from an invoke task + (where `sys.argv` still holds the invoke process's own argv, e.g. `['repo.pr_diff']` — parsing + that as this command's own args raises a spurious "unrecognized arguments" error). + """ + frame = inspect.currentframe() + caller = frame.f_back.f_back if frame and frame.f_back else None + return caller is not None and caller.f_globals.get("__name__") == "__main__" + + +def command() -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator compatible subset of click.command.""" + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + specs: list[_OptionSpec] = list(getattr(func, "__cli_options__", [])) + + def wrapper(*args: Any, **kwargs: Any) -> Any: + if args or kwargs: + return func(*args, **kwargs) + + parser = argparse.ArgumentParser(prog=func.__name__, add_help=True) + for spec in specs: + _add_option(parser, spec) + + argv = sys.argv[1:] if _invoked_as_script() else [] + parsed = parser.parse_args(argv) + return func(**vars(parsed)) + + wrapper.__cli_options__ = specs + return wrapper + + return decorator diff --git a/modules/common/properties.py b/modules/common/properties.py new file mode 100644 index 0000000..699044b --- /dev/null +++ b/modules/common/properties.py @@ -0,0 +1,111 @@ +"""Properties management for repo automation.""" + +import os +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + + +def _expand_path(value: str) -> Path: + """Expand ~ and environment variables (e.g. $HOME) in a properties.yml path value.""" + return Path(os.path.expandvars(os.path.expanduser(value))) + + +@lru_cache(maxsize=1) +def get_repo_root() -> Path: + """ + Find repository root by searching upward for properties.yml. + + Works from any subdirectory within the repository. + + Returns: + Path to repository root. + + Raises: + FileNotFoundError: If properties.yml cannot be found. + """ + # Start from current file location + current = Path(__file__).resolve() + + # Search upward from current file location + for parent in [current.parent.parent.parent] + list(current.parents): + props_file = parent / "properties.yml" + if props_file.exists(): + return parent + + # Also try from current working directory + current_cwd = Path.cwd().resolve() + for parent in [current_cwd] + list(current_cwd.parents): + props_file = parent / "properties.yml" + if props_file.exists(): + return parent + + msg = "Could not find repository root (properties.yml not found)" + raise FileNotFoundError(msg) + + +@lru_cache(maxsize=1) +def get_properties() -> dict[str, Any]: + """ + Load properties.yml with singleton pattern (cached). + + Returns: + Dictionary with all repository properties. + + Raises: + FileNotFoundError: If properties.yml does not exist. + """ + repo_root = get_repo_root() + props_file = repo_root / "properties.yml" + + with props_file.open() as f: + return yaml.safe_load(f) + + +def get_repo_local() -> Path: + """ + Get repo local path as Path object. + + Returns: + Path to local repository. + """ + props = get_properties() + return _expand_path(props["repo"]["local"]) + + +def get_repo_remote() -> str: + """ + Get repo remote URL. + + Returns: + Remote repository URL. + """ + props = get_properties() + return props["repo"]["remote"] + + +def get_template_local() -> Path: + """ + Get the local path to the shared template repo (template_python), used by /template. + + A relative path is resolved against this repo's root. + + Returns: + Path to the template repository. + """ + props = get_properties() + local = _expand_path(props["template"]["local"]) + return local if local.is_absolute() else get_repo_root() / local + + +def get_template_remote() -> str: + """ + Get the template repo's remote (e.g. "github.com/LevonBecker/template_python"). + + Returns: + Remote repository reference, without a URL scheme. + """ + props = get_properties() + return props["template"]["remote"] diff --git a/modules/common/utils.py b/modules/common/utils.py new file mode 100644 index 0000000..203a5f4 --- /dev/null +++ b/modules/common/utils.py @@ -0,0 +1,25 @@ +"""Common utilities for repo automation.""" + +import sys +from typing import NoReturn + + +def success(message: str) -> None: + """Print success message with emoji prefix.""" + print(f"✅ {message}") + + +def error(message: str, exit_code: int = 1) -> NoReturn: + """Print error message to stderr and exit.""" + print(f"❌ {message}", file=sys.stderr) + sys.exit(exit_code) + + +def warning(message: str) -> None: + """Print warning message with emoji prefix.""" + print(f"⚠️ {message}") + + +def info(message: str) -> None: + """Print info message with emoji prefix.""" + print(f"📂 {message}") diff --git a/modules/setup/README.md b/modules/setup/README.md new file mode 100644 index 0000000..5d9bcb3 --- /dev/null +++ b/modules/setup/README.md @@ -0,0 +1,35 @@ +# Setup Module +One-time repo bootstrapping helpers, called by `setup.sh`. + +## Commands +```sh +uv run --no-sync invoke setup.properties +``` + +## What It Does +`properties.yml` is gitignored. **A no-op if it already exists** — `modules/setup/properties.py` +only ever creates the file, it never rewrites an existing one. To regenerate it (e.g. after moving +the repo, renaming it, or pointing it at a new fork), delete or rename `properties.yml` first, then +run again. + +On first run, assembles it from every tier fragment under `modules/setup/templates/properties/*.yml` +— one file per repo in the lineage, each named after itself. This repo (template_python) is the +root: its own fragment, `template_python.yml`, holds just `repo`, `template`, and its own `repos` +entry (no lineage edge, since it has no parent). Descendant repos add their own same-named fragment +on top when they fork from this template or a domain template descended from it. + +`repos` (the GitHub org/repo map + template lineage) is built additively rather than concatenated: +each fragment's own `repos:` block contributes just its own org/repo + the lineage edge to its +parent, deep-merged into whatever was inherited from earlier tiers. A repo only ever ends up +knowing its own ancestor chain, never a sibling branch it isn't descended from. + +Detects this repo's actual path on disk and its git `origin` remote (if any), and stamps +`repo.local`, `repo.remote`, and `screenshots.location` (if a `screenshots` section is present — +generic to that section, not something this root repo declares) with those values. Auto-detects +`template.*` (the parent template repo for `/template`) via GitHub's generated-from link, falling +back to an interactive prompt. + +## Files +- `properties.py` — creates `properties.yml` (used by `inv setup.properties`) +- `templates/properties/*.yml` — per-tier fragments merged into a fresh `properties.yml` +- `README.md` — this file diff --git a/modules/setup/__init__.py b/modules/setup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/setup/properties.py b/modules/setup/properties.py new file mode 100644 index 0000000..d2fd272 --- /dev/null +++ b/modules/setup/properties.py @@ -0,0 +1,316 @@ +""" +Create properties.yml (this machine's repo path, git remote, and template lineage) on first run. + +Run via `inv setup.properties` (called automatically by setup.sh). A no-op if properties.yml +already exists — this only ever creates the file, never rewrites it. To regenerate (e.g. after +moving the repo, renaming it, or forking it to a new remote), delete or rename properties.yml +first, then run again. + +properties.yml is gitignored. It's assembled from every tier fragment under +`modules/setup/templates/properties/*.yml` — one file per repo in the lineage, each named after +itself. This repo (template_python) is the root: its own fragment, `template_python.yml`, holds +just `repo`, `template`, and its own `repos` entry (no lineage edge, since it has no parent). +Descendant repos add their own same-named fragment on top when they fork from this template or a +domain template descended from it — see `.github/instructions/repos.instructions.md`. `repo.local`, +`repo.remote`, and `screenshots.location` (if present) are then stamped in with values detected at +creation time. + +template.* (this repo's parent template repo, used by /template) is +auto-detected from GitHub's generated-from link when possible, with an +interactive prompt as fallback. + +repos (the GitHub org/repo map + template lineage) is built additively, one tier at a time, as you +go down the lineage: each fragment contributes just its own org/repo + the lineage edge to its +parent, deep-merged into whatever earlier tiers contributed. A repo only ever ends up knowing its +own ancestor chain — never a sibling branch it isn't descended from. +""" + +import re +import subprocess +from pathlib import Path +from typing import Any + +import yaml + +from modules.common import cli +from modules.common.utils import info, success + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_PROPERTIES_FILE = _REPO_ROOT / "properties.yml" +_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" / "properties" + +_TEMPLATE_LOCAL_PLACEHOLDER = "$HOME/path/to/your/template/repo" +_TEMPLATE_REMOTE_PLACEHOLDER = "github.com//" + + +def _extract_repos_block(text: str) -> tuple[str, dict[str, Any] | None]: + """Split a top-level `repos:` block out of raw YAML text. + + Returns (text with that block removed, its parsed value) — or (text, None) if there's no + `repos:` block (a flow-style `repos: {}` placeholder doesn't count as one to extract). + """ + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if line.rstrip("\n") == "repos:": + end = i + 1 + while end < len(lines) and (not lines[end].strip() or lines[end][0].isspace()): + end += 1 + block = "".join(lines[i:end]) + remaining = "".join(lines[:i] + lines[end:]) + return remaining, yaml.safe_load(block)["repos"] + return text, None + + +def _merge_repos(accumulated: dict[str, Any], addition: dict[str, Any]) -> dict[str, Any]: + """Deep-merge one tier's repos contribution into the map accumulated from earlier tiers. + + Org lists are extended (no duplicates); `lineage` entries are merged key by key. + """ + merged = {org: list(names) for org, names in accumulated.items() if org != "lineage"} + lineage = dict(accumulated.get("lineage", {})) + for org, names in addition.items(): + if org == "lineage": + lineage.update(names) + continue + merged.setdefault(org, []) + for name in names: + if name not in merged[org]: + merged[org].append(name) + if lineage: + merged["lineage"] = lineage + return merged + + +def _lineage_depth(repo_key: str, lineage: dict[str, str]) -> int: + """Return how many lineage hops `org/repo` is from its root (0 for the root itself).""" + depth = 0 + current = repo_key + seen: set[str] = set() + while current in lineage and current not in seen: + seen.add(current) + current = lineage[current] + depth += 1 + return depth + + +def _render_repos_block(repos: dict[str, Any]) -> str: + """Render a merged repos dict back into this file's hand-formatted YAML block style. + + Each org's list is ordered root-to-leaf by lineage depth, regardless of merge order. + """ + lineage = repos.get("lineage", {}) + lines = ["repos:"] + for org, names in repos.items(): + if org == "lineage": + continue + ordered = sorted(names, key=lambda name: _lineage_depth(f"{org}/{name}", lineage)) + lines.append(f" {org}:") + lines.extend(f" - {name}" for name in ordered) + if lineage: + lines.append(" lineage:") + lines.extend(f" {child}: {parent}" for child, parent in lineage.items()) + return "\n".join(lines) + "\n" + + +def _build_initial_content() -> str: + """Assemble a fresh properties.yml from every tier fragment under templates/properties/*.yml. + + Each fragment's own `repos` contribution is deep-merged into the accumulated map (see module + docstring) rather than concatenated; every other section is appended as raw text, ordered + root-to-leaf by lineage depth regardless of filename/glob order. + """ + repos: dict[str, Any] = {} + fragments: list[tuple[str, str]] = [] + for template_file in sorted(_TEMPLATES_DIR.glob("*.yml")): + remaining, template_repos = _extract_repos_block(template_file.read_text()) + if template_repos: + repos = _merge_repos(repos, template_repos) + if remaining.strip(): + fragments.append((template_file.stem, remaining.rstrip("\n"))) + + lineage = repos.get("lineage", {}) + + def _fragment_depth(stem: str) -> int: + for org, names in repos.items(): + if org != "lineage" and stem in names: + return _lineage_depth(f"{org}/{stem}", lineage) + return 0 + + fragments.sort(key=lambda item: _fragment_depth(item[0])) + + parts = [_render_repos_block(repos).rstrip("\n")] + parts.extend(text for _, text in fragments) + return "---\n\n" + "\n\n".join(parts) + "\n" + + +def _detect_repo_local() -> str: + """Return this repo's absolute path, with $HOME swapped in for portability.""" + home = str(Path.home()) + repo_str = str(_REPO_ROOT) + if repo_str.startswith(home): + return "$HOME" + repo_str[len(home) :] + return repo_str + + +def _detect_repo_remote() -> str | None: + """Return the git origin remote as 'host/user/repo', or None if there isn't one.""" + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + url = result.stdout.strip() + url = re.sub(r"^git@([^:]+):", r"\1/", url) + url = re.sub(r"^https?://", "", url) + url = re.sub(r"\.git$", "", url) + return url + + +def _detect_template_remote(repo_remote: str | None) -> str | None: + """Return this repo's parent template as 'github.com/owner/name' via GitHub's generated-from link.""" + if not repo_remote or not repo_remote.startswith("github.com/"): + return None + try: + result = subprocess.run( + [ + "gh", + "api", + f"repos/{repo_remote.removeprefix('github.com/')}", + "--jq", + ".template_repository.full_name // empty", + ], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return None # gh CLI not installed + if result.returncode != 0: + return None + full_name = result.stdout.strip() + return f"github.com/{full_name}" if full_name else None + + +def _read_scalar(lines: list[str], section: str, key: str) -> str | None: + """Return the unquoted value of `key:` within a top-level `section:` block, or None.""" + in_section = False + for line in lines: + if line.rstrip("\n") == f"{section}:": + in_section = True + continue + if not in_section: + continue + if line.strip() and not line[0].isspace(): + return None # reached the next top-level key without finding it + match = re.match(rf"^\s*{re.escape(key)}:\s*(.*?)\s*$", line) + if match: + return match.group(1).strip('"') + return None + + +def _replace_scalar(lines: list[str], section: str, key: str, value: str, *, quote: bool = True) -> bool: + """Rewrite `key: ...` to `key: value` within a top-level `section:` block, in place.""" + formatted = f'"{value}"' if quote else value + in_section = False + for i, line in enumerate(lines): + if line.rstrip("\n") == f"{section}:": + in_section = True + continue + if not in_section: + continue + if line.strip() and not line[0].isspace(): + return False # reached the next top-level key without finding it + match = re.match(rf"^(\s*{re.escape(key)}:\s*).*$", line) + if match: + lines[i] = f"{match.group(1)}{formatted}\n" + return True + return False + + +def _has_section(lines: list[str], section: str) -> bool: + """Return whether a top-level `section:` block exists (e.g. a tier fragment that wasn't present).""" + return any(line.rstrip("\n") == f"{section}:" for line in lines) + + +def _prompt_icloud_enabled(lines: list[str]) -> None: + """Ask (interactively) whether to turn on iCloud sync for a freshly created properties.yml.""" + enabled = cli.confirm( + "Enable iCloud sync for /push and /pull? (most people don't need this — off by default)", + default=False, + ) + _replace_scalar(lines, "icloud", "enabled", "true" if enabled else "false", quote=False) + if enabled: + info("iCloud sync enabled — edit icloud.path in properties.yml to your Obsidian vault path") + + +def _stamp_template_parent(lines: list[str], repo_local: str, repo_remote: str | None) -> str | None: + """Fill in template.* (the /template parent) while it still holds the placeholder. + + Auto-detects via GitHub's generated-from link, falling back to an interactive prompt. + A hand-configured (non-placeholder) parent is never touched. + + Returns the parent remote now in effect (existing, detected, or prompted), or None. + """ + current = _read_scalar(lines, "template", "remote") + if current not in (None, "", _TEMPLATE_REMOTE_PLACEHOLDER): + return current + + remote = _detect_template_remote(repo_remote) + if remote: + info(f"Detected parent template repo (GitHub generated-from): {remote}") + elif cli.confirm("Sync shared tooling with a parent template repo via /template?", default=False): + remote = cli.prompt("Parent template remote (e.g. github.com//)") + + if not remote: + info("No parent template repo configured — edit template.* in properties.yml if you add one later") + return None + + template_local = f"{repo_local.rsplit('/', 1)[0]}/{remote.rstrip('/').rsplit('/', 1)[-1]}" + _replace_scalar(lines, "template", "remote", remote) + _replace_scalar(lines, "template", "local", template_local) + success(f"properties.yml: template.remote = {remote}") + success(f"properties.yml: template.local = {template_local} (sibling-path guess — edit if it lives elsewhere)") + return remote + + +@cli.command() +def main() -> None: + """Create properties.yml from every tier fragment; a no-op if it already exists.""" + if _PROPERTIES_FILE.exists(): + info("properties.yml already exists — leaving it untouched (delete or rename it to regenerate)") + return + + _PROPERTIES_FILE.write_text(_build_initial_content()) + info("Created properties.yml") + + repo_local = _detect_repo_local() + repo_remote = _detect_repo_remote() + + lines = _PROPERTIES_FILE.read_text().splitlines(keepends=True) + _replace_scalar(lines, "repo", "local", repo_local) + if repo_remote: + _replace_scalar(lines, "repo", "remote", repo_remote) + has_screenshots = _has_section(lines, "screenshots") + if has_screenshots: + _replace_scalar(lines, "screenshots", "location", f"{repo_local}/screenshots") + _stamp_template_parent(lines, repo_local, repo_remote) + if _has_section(lines, "icloud"): + _prompt_icloud_enabled(lines) + _PROPERTIES_FILE.write_text("".join(lines)) + + success(f"properties.yml: repo.local = {repo_local}") + if repo_remote: + success(f"properties.yml: repo.remote = {repo_remote}") + else: + info("No git remote 'origin' found — repo.remote left unchanged") + if has_screenshots: + success(f"properties.yml: screenshots.location = {repo_local}/screenshots") + + +if __name__ == "__main__": + main() diff --git a/modules/setup/templates/properties/template_python.yml b/modules/setup/templates/properties/template_python.yml new file mode 100644 index 0000000..9888efa --- /dev/null +++ b/modules/setup/templates/properties/template_python.yml @@ -0,0 +1,12 @@ +--- +repos: + LevonBecker: + - template_python + +repo: + local: "$HOME/path/to/this/repo" + remote: "github.com//" + +template: + local: "$HOME/path/to/your/template/repo" + remote: "github.com//" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5ce64d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,94 @@ +[project] +name = "repo-setup-python" +version = "1.0.0" +description = "Skeleton setup for a repository that uses Python invoke tasks, reusable modules, and uv for environment/dependency management." +readme = "README.md" +classifiers = [ + "Programming Language :: Python :: 3", +] +requires-python = ">=3.14" +dependencies = [ + "actionlint-py~=1.7.12.24", + "invoke~=3.0.3", + "pylint~=4.0.6", + "pytest~=9.1.1", + "pytest-cov~=7.1.0", + "pyyaml~=6.0.3", + "ruff~=0.16.0", + "yamllint~=1.38.0", +] + +[tool.setuptools] +packages = [] + +[project.urls] +homepage = "https://github.com/LevonBecker/template_python" +repository = "https://github.com/LevonBecker/template_python" + +[tool.ruff] +line-length = 120 +target-version = "py314" +extend-exclude = ["tests/*"] + +[tool.ruff.lint] +select = ["PL", "E", "W", "F", "I", "B", "A", "C4", "N", "UP", "T10", "EXE", "COM819", "C", "Q"] + +ignore = ["E501", "PLR2004"] + +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.mccabe] +max-complexity = 20 # 5 = default, 12 = previous + +[tool.ruff.lint.pylint] +max-branches = 20 # default = 12, previous = 15 +max-args = 15 # default = 5 +max-statements = 75 # default = 50 +max-returns = 10 # default = 6 +max-nested-blocks = 10 # default = 5 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = false +docstring-code-line-length = "dynamic" + +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = ["tmp", ".venv", ".ruff_cache", "__pycache__"] + +[tool.pylint.main] +py-version = "3.14" +fail-under = 10 +ignore = ["CVS"] +ignore-paths = [ + "^env/.*$", + "^.venv/.*$", + "^venv/.*$", + "^.ruff_cache/.*$", + "^tmp/.*$", + ".*__pycache__.*$", + "^tests/.*$", +] + +[tool.pylint.format] +max-line-length = 100 + +[tool.pylint.messages_control] +disable = ["all"] +enable = ["no-member"] + +[tool.pylint.design] +max-args = 15 +max-branches = 20 +max-locals = 20 +max-positional-arguments = 15 +max-public-methods = 20 +max-returns = 10 +max-statements = 75 + +[tool.pylint.refactoring] +max-nested-blocks = 10 diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..22d6133 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,36 @@ +# Windows setup — mirrors setup.sh (macOS/Linux). Run from the repo root in PowerShell: +# .\setup.ps1 + +$ErrorActionPreference = "Stop" + +function Install-Tools { + Write-Host "INFO: Installing Tools (uv, user-local install — no admin required)" + if (Get-Command uv -ErrorAction SilentlyContinue) { + Write-Host "INFO: uv already installed" + } else { + irm https://astral.sh/uv/install.ps1 | iex + } +} + +function Setup-PythonEnv { + Write-Host "`nINFO: Creating Python Virtual Environment" + uv venv .venv --python 3.14 --clear + + Write-Host "`nINFO: Activating Python Virtual Environment" + & .\.venv\Scripts\Activate.ps1 + + Write-Host "`nINFO: Installing Libraries" + uv sync + Write-Host "INFO: Python Version: $(python --version)" + Write-Host "INFO: uv Version: $(uv --version)" +} + +function Configure-Properties { + # Everything past this point is Python's job, not PowerShell's. + Write-Host "`nINFO: Configuring properties.yml for this machine" + uv run --no-sync invoke setup.properties +} + +Install-Tools +Setup-PythonEnv +Configure-Properties diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..8586dd6 --- /dev/null +++ b/setup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -e + +# --------------------------------------------------------------------------- +# OS-specific tool installation +# --------------------------------------------------------------------------- + +install_uv_curl() { + echo "INFO: Installing Tools (uv, user-local install — no sudo, no system package manager)" + if command -v uv &> /dev/null; then + echo "INFO: uv already installed" + else + curl -LsSf https://astral.sh/uv/install.sh | sh + # shellcheck disable=SC1091 + source "$HOME/.local/bin/env" 2>/dev/null || export PATH="$HOME/.local/bin:$PATH" + fi +} + +install_tools_macos() { + if command -v brew &> /dev/null; then + echo "INFO: Installing Tools (Homebrew)" + brew install uv + else + echo "INFO: Homebrew not found — falling back to user-local install" + install_uv_curl + fi +} + +install_tools_linux() { + install_uv_curl + echo "NOTE: For Windows, use setup.ps1 in PowerShell instead of this script." +} + +# --------------------------------------------------------------------------- +# Shared steps (macOS + Linux) +# --------------------------------------------------------------------------- + +setup_python_env() { + echo -e + echo "INFO: Creating Python Virtual Environment" + uv venv .venv --python 3.14 --clear + echo -e + echo "INFO: Activating Python Virtual Environment" + source .venv/bin/activate + + echo -e + echo "INFO: Installing Libraries" + uv sync + echo "INFO: Python Version: $(python --version)" + echo "INFO: uv Version: $(uv --version)" +} + +configure_properties() { + # Everything past this point is Python's job, not bash's. + echo -e + echo "INFO: Configuring properties.yml for this machine" + uv run --no-sync invoke setup.properties +} + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +main() { + case "$(uname)" in + Darwin) + install_tools_macos + ;; + Linux) + install_tools_linux + ;; + *) + echo "ERROR: Unsupported OS: $(uname)" + echo "For Windows, run setup.ps1 in PowerShell instead of this script." + exit 1 + ;; + esac + + setup_python_env + configure_properties +} + +main "$@" diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..29d3b1f --- /dev/null +++ b/tasks/__init__.py @@ -0,0 +1,29 @@ +import sys +from pathlib import Path + +from invoke import Collection + +# Ensure the repo root (parent of tasks/) is importable so `modules.*` resolves +# regardless of how invoke was invoked. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from . import ( # noqa: E402 # pylint: disable=wrong-import-position + combos, + debug, + ruff, + setup, + tests, +) + +namespace = Collection() +namespace.configure({"auto_dash_names": False}) + +namespace.add_collection(debug, name="debug") +namespace.add_collection(ruff, name="ruff") +namespace.add_collection(setup, name="setup") +namespace.add_collection(tests, name="tests") + +namespace.add_task(combos.fix, name="fix") +namespace.add_task(combos.test, name="test") diff --git a/tasks/combos.py b/tasks/combos.py new file mode 100644 index 0000000..77e2140 --- /dev/null +++ b/tasks/combos.py @@ -0,0 +1,20 @@ +from invoke import task + +from . import ruff, tests + + +@task +def fix(context): + """Run All Automated Fixes""" + ruff.fix(context) + ruff.format(context) + + +@task +def test(context): + """Run All Tests""" + tests.actionlint(context) + tests.pylint(context) + tests.pytest(context) + tests.rufflint(context) + tests.yamllint(context) diff --git a/tasks/debug.py b/tasks/debug.py new file mode 100644 index 0000000..c0af479 --- /dev/null +++ b/tasks/debug.py @@ -0,0 +1,9 @@ +"""CI/local debugging tasks.""" + +from invoke import task + + +@task +def env(context): + """Print working directory and sorted environment variables for debugging.""" + context.run("pwd && env | sort") diff --git a/tasks/ruff.py b/tasks/ruff.py new file mode 100644 index 0000000..77ea3da --- /dev/null +++ b/tasks/ruff.py @@ -0,0 +1,21 @@ +# pylint: disable=W0622 # redefined-builtin + +from invoke import task + + +@task() +def fix(context): + """Run Ruff Linter on Entire Repo and Fix Safe Offenses""" + print("\n------------") + print("Ruff Lint/Fix") + print("------------\n") + context.run("ruff check . --fix") + + +@task() +def format(context): # noqa: A001 + """Run Ruff Format on Entire Repo and fix""" + print("\n------------") + print("Ruff Format") + print("------------\n") + context.run("ruff format .") diff --git a/tasks/setup.py b/tasks/setup.py new file mode 100644 index 0000000..d3083bd --- /dev/null +++ b/tasks/setup.py @@ -0,0 +1,9 @@ +"""Repo bootstrap tasks — called by setup.sh, safe to re-run any time.""" + +from invoke import task + + +@task +def properties(context): + """Create/stamp properties.yml with this machine's repo path and git remote""" + context.run("python -m modules.setup.properties") diff --git a/tasks/tests.py b/tasks/tests.py new file mode 100644 index 0000000..c4f41e2 --- /dev/null +++ b/tasks/tests.py @@ -0,0 +1,53 @@ +from invoke import task + + +@task +def actionlint(context): + """Run Action Lint""" + print("\n------------") + print("Action Lint") + print("------------\n") + context.run("actionlint") + + +@task +def pylint(context): + """Run PyLint on Entire Repo""" + print("\n------------") + print("Pylint Lint") + print("------------\n") + context.run("pylint --verbose --rcfile=pyproject.toml .") + + +@task +def pytest(context): + """Run Pytest Unit Test Suite""" + print("\n------------") + print("Pytest") + print("------------\n") + context.run("pytest") + + +@task +def rufflint(context): + """Run Ruff Linter on Entire Repo""" + print("\n------------") + print("Ruff Lint") + print("------------\n") + context.run("ruff check .") + + +@task +def yamllint(context): + """Run Yaml Linter on Entire Repo""" + print("\n------------") + print("Yaml Lint") + print("------------\n") + context.run( + """ + yamllint --list-files -c .yamllint . && + echo '------------' && + echo -e && + yamllint -f parsable -c .yamllint . + """ + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_markdown_style.py b/tests/test_markdown_style.py new file mode 100644 index 0000000..b1a9b85 --- /dev/null +++ b/tests/test_markdown_style.py @@ -0,0 +1,47 @@ +"""Verify `.github/instructions/*.md` files don't use `---` as a body section divider. + +See .github/instructions/style.instructions.md — `---` is reserved for the YAML frontmatter +delimiter only; headers and blank lines alone provide enough visual separation. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTRUCTIONS_DIR = REPO_ROOT / ".github" / "instructions" + + +def _stray_dividers(path: Path) -> list[int]: + """Return line numbers (1-indexed) of standalone `---` lines outside frontmatter/code fences.""" + lines = path.read_text().splitlines() + stray: list[int] = [] + in_fence = False + frontmatter_done = False + skip_next_frontmatter_close = lines[:1] == ["---"] + + for i, line in enumerate(lines, start=1): + stripped = line.strip() + if i == 1 and skip_next_frontmatter_close: + continue + if skip_next_frontmatter_close and not frontmatter_done: + if stripped == "---": + frontmatter_done = True + continue + if stripped.startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + if stripped == "---": + stray.append(i) + return stray + + +def test_no_stray_horizontal_rules_in_instructions() -> None: + offenders = {} + for path in sorted(INSTRUCTIONS_DIR.glob("*.md")): + stray = _stray_dividers(path) + if stray: + offenders[path.relative_to(REPO_ROOT).as_posix()] = stray + assert not offenders, f"Stray `---` body dividers found (see style.instructions.md): {offenders}" diff --git a/tests/test_setup_properties.py b/tests/test_setup_properties.py new file mode 100644 index 0000000..ca8496e --- /dev/null +++ b/tests/test_setup_properties.py @@ -0,0 +1,65 @@ +"""Tests for modules.setup.properties template-parent detection and stamping.""" + +import pytest + +from modules.setup import properties as setup_props + + +def _template_lines() -> list[str]: + return setup_props._build_initial_content().splitlines(keepends=True) # pylint: disable=protected-access + + +def _stamp(lines, repo_local, monkeypatch, *, detected, confirm=False): + monkeypatch.setattr(setup_props, "_detect_template_remote", lambda _remote: detected) + monkeypatch.setattr(setup_props.cli, "confirm", lambda *_a, **_k: confirm) + setup_props._stamp_template_parent( # pylint: disable=protected-access + lines, repo_local, "github.com/user/my_vault" + ) + + +class TestReadScalar: + """Tests for _read_scalar().""" + + def test_reads_value_inside_section(self): + lines = _template_lines() + value = setup_props._read_scalar(lines, "template", "remote") # pylint: disable=protected-access + assert value == setup_props._TEMPLATE_REMOTE_PLACEHOLDER # pylint: disable=protected-access + + def test_missing_key_returns_none(self): + lines = _template_lines() + assert setup_props._read_scalar(lines, "template", "nope") is None # pylint: disable=protected-access + + def test_key_outside_section_not_matched(self): + lines = _template_lines() + assert setup_props._read_scalar(lines, "repos", "remote") is None # pylint: disable=protected-access + + +class TestStampTemplateParent: + """Tests for _stamp_template_parent().""" + + def test_detected_parent_stamped_with_sibling_local_guess(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/Development/user/my_vault", monkeypatch, detected="github.com/user/template_my_vault") + content = "".join(lines) + assert 'remote: "github.com/user/template_my_vault"' in content + assert 'local: "$HOME/Development/user/template_my_vault"' in content + + def test_hand_configured_parent_never_touched(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected="github.com/user/template_my_vault") + before = "".join(lines) + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected="github.com/user/other_template") + assert "".join(lines) == before + + def test_no_detection_and_declined_prompt_leaves_placeholder(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected=None, confirm=False) + content = "".join(lines) + assert setup_props._TEMPLATE_REMOTE_PLACEHOLDER in content # pylint: disable=protected-access + assert setup_props._TEMPLATE_LOCAL_PLACEHOLDER in content # pylint: disable=protected-access + + def test_prompted_parent_stamped_when_confirmed(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + monkeypatch.setattr(setup_props.cli, "prompt", lambda *_a, **_k: "github.com/user/template_my_vault") + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected=None, confirm=True) + assert 'remote: "github.com/user/template_my_vault"' in "".join(lines) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..255fa91 --- /dev/null +++ b/uv.lock @@ -0,0 +1,304 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "actionlint-py" +version = "1.7.12.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[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 = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[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 = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[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 = "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 = "pylint" +version = "4.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "repo-setup-python" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "actionlint-py" }, + { name = "invoke" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "yamllint" }, +] + +[package.metadata] +requires-dist = [ + { name = "actionlint-py", specifier = "~=1.7.12.24" }, + { name = "invoke", specifier = "~=3.0.3" }, + { name = "pylint", specifier = "~=4.0.6" }, + { name = "pytest", specifier = "~=9.1.1" }, + { name = "pytest-cov", specifier = "~=7.1.0" }, + { name = "pyyaml", specifier = "~=6.0.3" }, + { name = "ruff", specifier = "~=0.16.0" }, + { name = "yamllint", specifier = "~=1.38.0" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + +[[package]] +name = "yamllint" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" }, +]