From 9bb8a637bae6dfb725d3a410f132f42e8296cf26 Mon Sep 17 00:00:00 2001 From: Thijs Miedema Date: Mon, 13 Jan 2025 14:27:42 +0100 Subject: [PATCH] feat: Initial commit --- .github/workflows/ci.yml | 14 ++ .github/workflows/release.yml | 20 +++ .gitignore | 18 +++ LICENSE.md | 8 ++ README.md | 40 ++++++ pyproject.toml | 109 ++++++++++++++++ src/fonk/__init__.py | 0 src/fonk/__main__.py | 5 + src/fonk/cli.py | 93 ++++++++++++++ src/fonk/config.py | 191 ++++++++++++++++++++++++++++ src/fonk/locator.py | 13 ++ src/fonk/render.py | 203 +++++++++++++++++++++++++++++ src/fonk/runner.py | 109 ++++++++++++++++ src/fonk/session.py | 74 +++++++++++ tests/conftest.py | 0 tests/test_dummy.py | 2 + uv.lock | 232 ++++++++++++++++++++++++++++++++++ 17 files changed, 1131 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/fonk/__init__.py create mode 100644 src/fonk/__main__.py create mode 100644 src/fonk/cli.py create mode 100644 src/fonk/config.py create mode 100644 src/fonk/locator.py create mode 100644 src/fonk/render.py create mode 100644 src/fonk/runner.py create mode 100644 src/fonk/session.py create mode 100644 tests/conftest.py create mode 100644 tests/test_dummy.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7cb129 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +name: Commit CI + +on: + push: + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - name: Run CI with fonk + run: uv run fonk all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6d7d05c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release + +on: + push: + tags: + - "*" + +jobs: + pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv build + - run: uv publish --trusted-publishing always diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2fd96e --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +.python-version + +# Virtual environments +.venv + +# IDE +.vscode/ +.idea/ + +# Caches +.*_cache/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..df071b3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2025 Thijs Miedema + +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..d5b132c --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Fonk + +Fonk is an open-source command runner that is fully configured via `pyproject.toml`. + +## Usage + +First add an entry into your `pyproject.toml` file that contains the command you want to run: + +```toml +[tool.fonk.command.my_command] +description = "Run my command" +command = "echo Hello" +type = "shell" +``` + +Then run the command using the following command: + +```bash +uvx fonk my_command +``` +## Contributing + +We welcome contributions from the community. To contribute to Fonk, follow these steps: + +1. Fork the repository. +2. Create a new branch (`git checkout -b feature-branch`). +3. Make your changes. +4. Commit your changes (`git commit -m 'feat: Add new feature'`). +5. Push to the branch (`git push origin feature-branch`). +6. Open a pull request. + +Please ensure your code adheres to our coding standards. Since this is a task runner, the required CI steps are also defined as Fonk commands in the `pyproject.toml` file. Simply use `uv run fonk` to run all steps. + +## License + +Fonk is licensed under the MIT License. See the [LICENSE](./LICENSE.md) file for more details. + +## Contact + +For any questions or feedback, please open an issue on the [GitHub repository](https://github.com/yourusername/fonk). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e909a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,109 @@ +[project] +name = "fonk" +version = "0.1.0" +description = "fonk: pyproject.toml based task runner" +readme = "README.md" +license = { file = "LICENSE.md" } +requires-python = ">=3.9" +dependencies = [ + "rich>=13.9.4", +] +authors = [ + {name = "Thijs Miedema", email = "opensource@tmiedema.com"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +[dependency-groups] +dev = [ + "mypy>=1.14.1", + "pytest>=8.3.4", +] + +[project.scripts] +fonk = "fonk.cli:app" + +[tool.uv] +package = true + +[tool.fonk] +flags = [ + {name = "fix", description = "Autofix issues where possible"}, + {name = "debug", description = "Enable debugging in test suite"}, +] + +[tool.fonk.default] +description = "Most commonly used: run all checks in fix mode" +command = "all" +flags = ["fix"] + +[tool.fonk.alias.all] +description = "Run all checks" +commands = ["uv-lock", "ruff-check", "ruff-format", "mypy", "pytest"] + +[tool.fonk.alias.format] +description = "Check and/or fix code formatting/styling" +commands = ["ruff-check", "ruff-format"] +flags = ["fix"] + +[tool.fonk.alias.typecheck] +description = "Run the type checker" +commands = ["mypy"] + +[tool.fonk.alias.test] +description = "Run the test suite" +commands = ["pytest"] + +[tool.fonk.command.uv-lock] +type = "shell" +description = "Check if the lock file is up to date" +arguments = ["uv", "lock", "--check"] +flags = [ + {on = "verbose", add = "--verbose"}, + {on = "quiet", add = "--quiet"}, + {on = "fix", remove = "--check"}, +] + +[tool.fonk.command.ruff-check] +type = "uvx" +description = "Check and/or fix the code style" +arguments = ["ruff", "check", "src"] +flags = [ + {on = "verbose", add = "--verbose"}, + {on = "quiet", add = "--quiet"}, + {on = "fix", add = "--fix"}, +] + +[tool.fonk.command.ruff-format] +type = "uvx" +description = "Check and/or fix code formatting" +arguments = ["ruff", "format", "--check", "src"] +flags = [ + {on = "verbose", add = "--verbose"}, + {on = "quiet", add = "--quiet"}, + {on = "fix", remove = "--check"}, +] + +[tool.fonk.command.mypy] +type = "uv" +description = "Perform static type checking" +arguments = ["mypy", "src"] +flags = [ + {on = "verbose", add = "--verbose"}, + {on = "quiet", add = "--no-error-summary"} +] + +[tool.fonk.command.pytest] +type = "uv" +description = "Run the test suite with pytest" +arguments = ["pytest", "tests", "--verbose"] +flags = [ + {on = "verbose", add = "-vvv", remove = "--verbose"}, + {on = "quiet", add = ["-q", "--no-summary"], remove = "--verbose"}, + {on = "fail-quick", add = "-x"}, + {on = "debug", add = "--pdb"} +] diff --git a/src/fonk/__init__.py b/src/fonk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fonk/__main__.py b/src/fonk/__main__.py new file mode 100644 index 0000000..8309670 --- /dev/null +++ b/src/fonk/__main__.py @@ -0,0 +1,5 @@ +from fonk.cli import app + + +if __name__ == "__main__": + app() diff --git a/src/fonk/cli.py b/src/fonk/cli.py new file mode 100644 index 0000000..262fa25 --- /dev/null +++ b/src/fonk/cli.py @@ -0,0 +1,93 @@ +import asyncio +from fonk.config import ( + get_config, + ConfigurationError, + Flag, + FLAG_CONCURRENT, + FLAG_HELP, + FLAG_FAIL_QUICK, + FLAG_QUIET, + FLAG_VERBOSE, +) +import sys +import rich +from fonk.render import render_help, render_help_command +from fonk.session import Session, SessionError + + +def app() -> None: + try: + config = get_config() + except ConfigurationError as e: + rich.print(f"πŸ’₯[bold red] {e}") + sys.exit(1) + + flags: set[Flag] = set() + runnables: list[str] = [] + + for arg in sys.argv[1:]: + if arg.startswith("-"): + if arg.startswith("--"): + flag_name = arg[2:] + for flag in config.flags: + if flag_name == flag.name: + flags.add(flag) + break + else: + rich.print(f"πŸ’₯[bold red] Unknown flag: {flag_name}") + sys.exit(1) + else: + shorthands = arg[1:] + + for flag in config.flags: + if flag.shorthand and flag.shorthand in shorthands: + flags.add(flag) + shorthands = shorthands.replace(flag.shorthand, "") + + if shorthands: + rich.print(f"πŸ’₯[bold red] Unknown flag shorthand: {shorthands}") + sys.exit(1) + + for flag in config.flags: + if arg == f"--{flag.name}" or arg == f"-{flag.shorthand}": + flags.add(flag) + break + else: + runnables.append(arg) + + if FLAG_HELP in flags: + if runnables: + for runnable in runnables: + render_help_command(config, runnable) + else: + render_help(config) + + sys.exit(0) + + if not runnables: + default = config.default + + if default is None: + rich.print("πŸ’₯[bold red] No runnables provided and no default set") + sys.exit(1) + + runnables.append(default.command) + flags.update({flag for flag in config.flags if flag.name in default.flags}) + + session = Session( + config, + FLAG_QUIET in flags, + FLAG_VERBOSE in flags, + FLAG_FAIL_QUICK in flags, + ) + + try: + if FLAG_CONCURRENT in flags: + asyncio.run(session.run_runnables_concurrently(runnables, flags)) + else: + session.run_runnables(runnables, flags) + except SessionError as e: + rich.print(f"πŸ’₯[bold red] {e}") + sys.exit(1) + + session.exit() diff --git a/src/fonk/config.py b/src/fonk/config.py new file mode 100644 index 0000000..4304f46 --- /dev/null +++ b/src/fonk/config.py @@ -0,0 +1,191 @@ +from tomllib import load +from pathlib import Path +from typing import Literal, Self +from dataclasses import dataclass +from fonk.locator import get_pyproject + + +class ConfigurationError(Exception): + pass + + +@dataclass(kw_only=True) +class ApplyFlag: + on: str + add: str | list[str] | None = None + remove: str | list[str] | None = None + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + on=data["on"], + add=data.get("add"), + remove=data.get("remove"), + ) + + +@dataclass(kw_only=True) +class Command: + name: str + description: str | None = None + type: Literal["shell", "python", "uv", "uvx"] + arguments: list[str] + flags: list[ApplyFlag] + + @classmethod + def from_dict(cls, name: str, data: dict) -> Self: + return cls( + name=name, + description=data.get("description"), + type=data["type"], + arguments=data.get("arguments", []), + flags=[ApplyFlag.from_dict(flag) for flag in data.get("flags", [])], + ) + + +@dataclass(kw_only=True) +class Alias: + commands: list[str] + flags: list[str] + description: str | None = None + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + commands=data.get("commands", []), + flags=data.get("flags", []), + description=data.get("description"), + ) + + +@dataclass(kw_only=True) +class Flag: + name: str + shorthand: str | None = None + description: str | None = None + is_builtin: bool = False + + def __post_init__(self): + if self.shorthand and len(self.shorthand) > 1: + raise ConfigurationError("Shorthand must be a single character") + + def __hash__(self): + return hash(self.name) + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + name=data["name"], + shorthand=data.get("shorthand"), + description=data.get("description"), + ) + + +FLAG_QUIET = Flag( + name="quiet", shorthand="q", description="Suppress output", is_builtin=True +) +FLAG_VERBOSE = Flag( + name="verbose", + shorthand="v", + description="Print command before running", + is_builtin=True, +) +FLAG_FAIL_QUICK = Flag( + name="fail-quick", + shorthand="x", + description="Exit on first failure", + is_builtin=True, +) +FLAG_HELP = Flag(name="help", shorthand="h", description="Show help", is_builtin=True) +FLAG_CONCURRENT = Flag( + name="concurrent", + shorthand="j", + description="Run commands concurrently", + is_builtin=True, +) + + +@dataclass(kw_only=True) +class Default: + command: str + flags: list[str] + description: str | None + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + command=data["command"], + flags=data.get("flags", []), + description=data.get("description"), + ) + + +@dataclass(kw_only=True) +class Config: + project_name: str | None + default: Default | None = None + commands: dict[str, Command] + aliases: dict[str, Alias] + flags: list[Flag] + + def __post_init__(self): + if ( + self.default + and self.default.command not in self.commands + and self.default.command not in self.aliases + ): + raise ConfigurationError("Default command not found in commands") + + flags_in_use = set() + shorthands_in_use = set() + + for flag in self.flags: + if flag.name in flags_in_use: + raise ConfigurationError(f"Duplicate flag flag: {flag.name}") + + if flag.shorthand and flag.shorthand in shorthands_in_use: + raise ConfigurationError(f"Duplicate flag shorthand: {flag.shorthand}") + + flags_in_use.add(flag.name) + if flag.shorthand: + shorthands_in_use.add(flag.shorthand) + + for command in self.commands.values(): + for flag in command.flags: + if flag.on not in flags_in_use: + raise ConfigurationError( + f"Unknown flag flag {flag.on} used in {command.name}" + ) + + @classmethod + def from_dict(cls, project_name: str | None, data: dict) -> Self: + return cls( + project_name=project_name, + default=Default.from_dict(data["default"]) if "default" in data else None, + commands={ + name: Command.from_dict(name, command) + for name, command in data.get("command", {}).items() + }, + aliases={ + name: Alias.from_dict(alias) + for name, alias in data.get("alias", {}).items() + }, + flags=[Flag.from_dict(flag) for flag in data.get("flags", [])] + + [ + FLAG_QUIET, + FLAG_VERBOSE, + FLAG_FAIL_QUICK, + FLAG_HELP, + FLAG_CONCURRENT, + ], + ) + + +def get_config(cwd: Path | None = None) -> Config: + with get_pyproject(cwd).open("rb") as file: + pyproject = load(file) + + project_name = pyproject.get("project", {}).get("name") + this_tool_config = pyproject.get("tool", {}).get("fonk", {}) + + return Config.from_dict(project_name, this_tool_config) diff --git a/src/fonk/locator.py b/src/fonk/locator.py new file mode 100644 index 0000000..92b88ec --- /dev/null +++ b/src/fonk/locator.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +def get_pyproject(cwd: Path | None = None) -> Path: + cwd = cwd or Path.cwd() + + while not (cwd / "pyproject.toml").exists(): + cwd = cwd.parent + + if cwd.parent == cwd: + raise FileNotFoundError("pyproject.toml not found") + + return cwd / "pyproject.toml" diff --git a/src/fonk/render.py b/src/fonk/render.py new file mode 100644 index 0000000..8e800fc --- /dev/null +++ b/src/fonk/render.py @@ -0,0 +1,203 @@ +from rich.console import Console, Group +from rich.panel import Panel +from rich.table import Table +from rich.markdown import Markdown +from fonk.config import Config + + +def render_help(config: Config) -> None: + console = Console() + + commands = Table( + "[bold green]Commands:", + "", + box=None, + pad_edge=False, + header_style="", + ) + + for command in config.commands.values(): + commands.add_row( + f" [bold]β€’[/] [cyan]{command.name}[/]", + "[italic]" + (command.description or ""), + ) + + flags = Table( + "[bold green]Flags:", + "", + box=None, + pad_edge=False, + header_style="", + ) + + for flag in config.flags: + flags.add_row( + f" [bold]β€’[/] [yellow]--{flag.name}" + + (f"/-{flag.shorthand}" if flag.shorthand else ""), + "[italic]" + (flag.description or ""), + ) + + aliases = Table( + "[bold green]Aliases:", + "", + "", + "", + box=None, + pad_edge=False, + header_style="", + ) + + for alias, conf in config.aliases.items(): + aliases.add_row( + f" [bold]β€’[/] [magenta]{alias}[/]", + "[yellow]" + ", ".join(f"--{f}" for f in conf.flags), + "[cyan]" + ", ".join(conf.commands), + "[italic]" + (conf.description or ""), + ) + + help = Group( + Markdown("*A `pyproject.toml` driven task runner*"), + "", + "[bold green]Usage:[/][cyan] fonk \\[commands] \\[aliases] \\[flags][/]", + "", + commands, + "", + flags, + "", + aliases, + ) + + if config.default is not None: + color = "magenta" if config.default.command in config.aliases else "cyan" + + default_help = Table( + "[bold green]Default:", + "", + "", + box=None, + pad_edge=False, + header_style="", + ) + default_help.add_row( + f" [bold]β€’[/] [{color}]{config.default.command}[/]", + "[yellow]" + ", ".join(f"--{f}" for f in config.default.flags), + "[italic]" + (config.default.description or ""), + ) + + help.renderables.extend( + [ + "", + default_help, + ] + ) + + console.print( + Panel( + help, + title=( + f"[bold red]πŸ”₯ Fonk {config.project_name} πŸ”₯" + if config.project_name + else "[bold red]πŸ”₯ Fonk πŸ”₯" + ), + expand=True, + ) + ) + + return + + +def render_header(quiet: bool) -> None: + if quiet: + return + console = Console() + console.rule(title="[bold red]πŸ”₯ Fonk πŸ”₯", style="") + return + + +def render_failures(failed: dict[str, int], quiet: bool) -> None: + console = Console() + + if not failed: + if not quiet: + console.rule(title="[bold green]✨ Fonky Fresh! ✨[/]", style="green") + return + + failures = [f"{fail} has returncode {status}" for fail, status in failed.items()] + + console.print( + Panel( + "\n".join(failures), + title="[bold red]πŸ’₯ Fonked Out! πŸ’₯[/]", + expand=True, + border_style="red", + padding=(0, 6), + ) + ) + + return + + +def render_help_command(config: Config, cmd: str) -> None: + console = Console() + help_content: list[str | Table] = [] + title = "" + + if cmd in config.commands: + title = f"[bold green]Command: [cyan]{cmd}" + command = config.commands[cmd] + help_content.extend( + ( + "[italic]" + (command.description or ""), + "", + "[bold green]Usage: [cyan]" + command.name + " \\[flags]", + "", + ) + ) + + flags = Table( + "[bold green]Flags", + "", + box=None, + pad_edge=False, + header_style="", + ) + + for flag in config.flags: + if not flag.is_builtin and not any( + m.on == flag.name for m in command.flags + ): + continue + + flags.add_row( + f" [bold]β€’[/] [yellow]--{flag.name}" + + (f"/-{flag.shorthand}" if flag.shorthand else ""), + "[italic]" + (flag.description or ""), + ) + + help_content.append(flags) + + elif cmd in config.aliases: + alias = config.aliases[cmd] + title = f"[bold green]Alias: [magenta]{cmd}" + help_content.extend( + ( + "[italic]" + (alias.description or ""), + "", + "[bold green]Commands:", + ) + ) + for subcommand in alias.commands: + applicable_mods = [] + for modd in config.commands[subcommand].flags: + if modd.on in alias.flags: + applicable_mods.append(f"--{modd.on}") + help_content.append( + f" [cyan]{subcommand}[/] [yellow]" + " ".join(applicable_mods) + ) + else: + title = f"[bold red]Unknown {cmd}" + help_content.append(f"[bold red]Unknown command or alias: {cmd}") + + console.print(Panel(Group(*help_content), title=title, expand=True)) + + return diff --git a/src/fonk/runner.py b/src/fonk/runner.py new file mode 100644 index 0000000..7a0e9e7 --- /dev/null +++ b/src/fonk/runner.py @@ -0,0 +1,109 @@ +from fonk.config import Command, Flag +from subprocess import run, CompletedProcess +import os +import rich +import sys +import asyncio + + +def _command_runner_prefix(command: Command) -> list[str]: + match command.type: + case "shell": + return [] + case "python": + return [sys.executable] + case "uv": + return ["uv", "run"] + case "uvx": + return ["uvx"] + + +def _command_mods_args( + command: Command, flags: set[Flag] +) -> tuple[list[str], list[str]]: + applied_mods: set[str] = set() + arguments = command.arguments.copy() + + for flag in flags: + for apply_flag in command.flags: + if apply_flag.on == flag.name: + if isinstance(apply_flag.add, list): + arguments.extend(apply_flag.add) + elif isinstance(apply_flag.add, str): + arguments.append(apply_flag.add) + if isinstance(apply_flag.remove, list): + arguments = [ + arg for arg in arguments if arg not in apply_flag.remove + ] + elif isinstance(apply_flag.remove, str): + arguments.remove(apply_flag.remove) + applied_mods.add(flag.name) + + return sorted(applied_mods), _command_runner_prefix(command) + arguments + + +def run_command( + command: Command, flags: set[Flag], quiet: bool, verbose: bool +) -> CompletedProcess[bytes]: + applied_mods, arguments = _command_mods_args(command, flags) + + if not quiet: + rich.print( + f"[bold red]πŸ”₯ Running {command.name}" + + (f"([green]{', '.join(applied_mods)}[/])" if applied_mods else "") + ) + + if verbose: + rich.print(f"[bold]β‹™[/] {' '.join(arguments)}") + + return run(arguments) + + +async def run_commands_concurrently( + commands_with_flags: list[tuple[Command, set[Flag]]], + quiet: bool, + verbose: bool, +) -> dict[str, int]: + failures: dict[str, int] = {} + names_mods_args: list[tuple[str, list[str], list[str]]] = [] + tasks: list[asyncio.Task[asyncio.subprocess.Process]] = [] + env = os.environ.copy() + env["FORCE_COLOR"] = "1" + + for command, flags in commands_with_flags: + mods, args = _command_mods_args(command, flags) + names_mods_args.append((command.name, mods, args)) + tasks.append( + asyncio.create_task( + coro=asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + ) + ) + + processes = await asyncio.gather(*tasks) + + for (name, mods, arguments), process in zip(names_mods_args, processes): + stdout, stderr = await process.communicate() + if not quiet: + if mods: + rich.print(f"[bold red]πŸ”₯ Ran {name} ([green]{', '.join(mods)}[/])") + else: + rich.print(f"[bold red]πŸ”₯ Ran {name}") + + if verbose: + rich.print(f"[bold]>>[/] {' '.join(arguments)}") + + retcode = process.returncode + if retcode and retcode != 0: + failures[name] = retcode + + if (not quiet or retcode != 0) and stdout: + print(stdout.decode().rstrip()) + if (not quiet or retcode != 0) and stderr: + print(stderr.decode().rstrip()) + + return failures diff --git a/src/fonk/session.py b/src/fonk/session.py new file mode 100644 index 0000000..e8c5a28 --- /dev/null +++ b/src/fonk/session.py @@ -0,0 +1,74 @@ +from fonk.config import Command, Config, Flag +from fonk.runner import run_command, run_commands_concurrently +import sys +from fonk.render import render_failures, render_header +from rich.console import Console + + +class SessionError(Exception): + pass + + +class Session: + def __init__( + self, config: Config, quiet: bool, verbose: bool, fail_quick: bool = False + ) -> None: + self.failed: dict[str, int] = {} + self.config = config + self.fail_quick = fail_quick + self.quiet = quiet + self.verbose = verbose + self.console = Console() + render_header(quiet) + + def gather_commands( + self, runnable: str, flags: set[Flag] + ) -> list[tuple[Command, set[Flag]]]: + if alias := self.config.aliases.get(runnable): + mods = flags.union((m for m in self.config.flags if m.name in alias.flags)) + return sum( + (self.gather_commands(runnable, mods) for runnable in alias.commands), + [], + ) + elif command := self.config.commands.get(runnable): + return [(command, flags)] + + raise SessionError(f"Unknown command or alias: {runnable}") + + def gather_commands_deduped( + self, runnables: list[str], flags: set[Flag] + ) -> list[tuple[Command, set[Flag]]]: + commands_with_flags = [] + + for runnable in runnables: + for command, mods in self.gather_commands(runnable, flags): + if (command, mods) not in commands_with_flags: + commands_with_flags.append((command, mods)) + + return commands_with_flags + + def run_runnables(self, runnables: list[str], flags: set[Flag]) -> None: + for command, mods in self.gather_commands_deduped(runnables, flags): + self.run_command(command, mods) + + async def run_runnables_concurrently( + self, runnables: list[str], flags: set[Flag] + ) -> None: + commands_with_flags = self.gather_commands_deduped(runnables, flags) + + failed = await run_commands_concurrently( + commands_with_flags, self.quiet, self.verbose + ) + self.failed.update(failed) + + def run_command(self, command: Command, flags: set[Flag]) -> None: + result = run_command(command, flags, self.quiet, self.verbose) + if result.returncode != 0: + if self.fail_quick: + sys.exit(1) + + self.failed[command.name] = result.returncode + + def exit(self) -> None: + render_failures(self.failed, self.quiet) + sys.exit(1 if self.failed else 0) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dummy.py b/tests/test_dummy.py new file mode 100644 index 0000000..f4f5361 --- /dev/null +++ b/tests/test_dummy.py @@ -0,0 +1,2 @@ +def test_dummy(): + assert True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1de8856 --- /dev/null +++ b/uv.lock @@ -0,0 +1,232 @@ +version = 1 +requires-python = ">=3.9" + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "fonk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "rich", specifier = ">=13.9.4" }] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pytest", specifier = ">=8.3.4" }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/7a/87ae2adb31d68402da6da1e5f30c07ea6063e9f09b5e7cfc9dfa44075e74/mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", size = 11211002 }, + { url = "https://files.pythonhosted.org/packages/e1/23/eada4c38608b444618a132be0d199b280049ded278b24cbb9d3fc59658e4/mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", size = 10358400 }, + { url = "https://files.pythonhosted.org/packages/43/c9/d6785c6f66241c62fd2992b05057f404237deaad1566545e9f144ced07f5/mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", size = 12095172 }, + { url = "https://files.pythonhosted.org/packages/c3/62/daa7e787770c83c52ce2aaf1a111eae5893de9e004743f51bfcad9e487ec/mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", size = 12828732 }, + { url = "https://files.pythonhosted.org/packages/1b/a2/5fb18318a3637f29f16f4e41340b795da14f4751ef4f51c99ff39ab62e52/mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", size = 13012197 }, + { url = "https://files.pythonhosted.org/packages/28/99/e153ce39105d164b5f02c06c35c7ba958aaff50a2babba7d080988b03fe7/mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", size = 9780836 }, + { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432 }, + { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515 }, + { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791 }, + { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203 }, + { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900 }, + { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869 }, + { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668 }, + { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060 }, + { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167 }, + { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 }, + { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 }, + { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 }, + { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 }, + { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 }, + { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 }, + { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, + { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, + { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, + { url = "https://files.pythonhosted.org/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", size = 11200493 }, + { url = "https://files.pythonhosted.org/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", size = 10357702 }, + { url = "https://files.pythonhosted.org/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", size = 12091104 }, + { url = "https://files.pythonhosted.org/packages/f1/bf/76a569158db678fee59f4fd30b8e7a0d75bcbaeef49edd882a0d63af6d66/mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", size = 12830167 }, + { url = "https://files.pythonhosted.org/packages/43/bc/0bc6b694b3103de9fed61867f1c8bd33336b913d16831431e7cb48ef1c92/mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", size = 13013834 }, + { url = "https://files.pythonhosted.org/packages/b0/79/5f5ec47849b6df1e6943d5fd8e6632fbfc04b4fd4acfa5a5a9535d11b4e2/mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", size = 9781231 }, + { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +]