From 78f95bc88e0fa7425611c71fca290f62ddbe336e Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Wed, 13 May 2026 23:02:47 +0300 Subject: [PATCH] update --- README.md | 113 ++-- docs/refactor-plan.md | 281 ++++++++++ .../_shared/flow/.config/flow/config.yaml | 6 +- .../_shared/flow/.config/flow/profiles.yaml | 6 +- src/flow/cli.py | 11 +- src/flow/commands/completion.py | 118 ++-- src/flow/commands/dotfiles.py | 137 ++--- src/flow/commands/projects.py | 4 +- src/flow/core/config.py | 292 ++-------- src/flow/core/config_parse.py | 111 ++++ src/flow/core/containers.py | 176 ++++++ src/flow/core/errors.py | 4 - src/flow/core/runtime.py | 9 + src/flow/core/tmux.py | 86 +++ src/flow/core/yaml.py | 83 +++ src/flow/domain/bootstrap/models.py | 21 +- src/flow/domain/bootstrap/modules.py | 1 - src/flow/domain/bootstrap/planning.py | 14 +- src/flow/domain/containers/models.py | 4 - src/flow/domain/containers/resolution.py | 6 +- src/flow/domain/dotfiles/models.py | 4 +- src/flow/domain/dotfiles/planning.py | 51 +- src/flow/domain/dotfiles/resolution.py | 18 +- src/flow/domain/packages/catalog.py | 22 +- src/flow/domain/packages/resolution.py | 7 +- src/flow/domain/remote/resolution.py | 19 +- src/flow/services/bootstrap.py | 46 +- src/flow/services/containers.py | 178 ++----- src/flow/services/dotfiles.py | 502 ++++++++++-------- src/flow/services/packages.py | 10 +- src/flow/services/projects.py | 2 - src/flow/services/remote.py | 7 +- tests/fakes.py | 36 ++ tests/test_cli.py | 15 + tests/test_completion.py | 43 ++ tests/test_core_config.py | 10 + tests/test_core_config_parse.py | 71 +++ tests/test_core_containers.py | 271 ++++++++++ tests/test_core_runtime.py | 12 + tests/test_core_tmux.py | 97 ++++ tests/test_core_yaml.py | 113 ++++ tests/test_domain_bootstrap_planning.py | 8 +- tests/test_domain_containers.py | 22 +- tests/test_domain_dotfiles_planning.py | 13 + tests/test_domain_packages.py | 2 +- tests/test_errors.py | 6 +- tests/test_service_containers.py | 48 +- tests/test_service_dotfiles.py | 225 +++++++- uv.lock | 393 ++++++++++++++ 49 files changed, 2747 insertions(+), 987 deletions(-) create mode 100644 docs/refactor-plan.md create mode 100644 src/flow/core/config_parse.py create mode 100644 src/flow/core/containers.py create mode 100644 src/flow/core/tmux.py create mode 100644 src/flow/core/yaml.py create mode 100644 tests/fakes.py create mode 100644 tests/test_core_config_parse.py create mode 100644 tests/test_core_containers.py create mode 100644 tests/test_core_tmux.py create mode 100644 tests/test_core_yaml.py create mode 100644 uv.lock diff --git a/README.md b/README.md index a444885..3896600 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,84 @@ # flow -`flow` is a CLI for managing development environments: dotfiles, packages, containers, remote targets, and system bootstrap. +CLI for managing development environments: dotfiles, packages, dev containers, remote targets, and system bootstrap. -## Installation +## Quick start ```bash -make build -make install-local +make build && make install-local # installs to ~/.local/bin/flow +flow setup run linux-work # bootstrap a machine +flow dotfiles link # symlink dotfiles +flow dev create api -i tm0/node # spin up a dev container +flow dev attach api # attach via tmux ``` -This installs `flow` to `~/.local/bin/flow`. - -## Architecture - -Four-layer design: **core** (runtime, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters). - -- Domain layer is pure: no I/O, no side effects, fully testable -- Services perform all I/O and delegate logic to domain functions -- Commands are trivial dispatchers from argparse to services - ## Commands ```bash # Dotfiles flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...] flow dotfiles unlink [PACKAGES...] [--dry-run] -flow dotfiles status -flow dotfiles sync +flow dotfiles status [PACKAGES...] +flow dotfiles edit PACKAGE [--no-commit] # pull -> $EDITOR -> commit+push + +# Dotfiles repos (unified: dotfiles repo + module repos) +flow dotfiles repos list +flow dotfiles repos status [--repo NAME] +flow dotfiles repos pull [--repo NAME] [--dry-run] +flow dotfiles repos push [--repo NAME] [--dry-run] # Packages flow packages install [NAMES...] [--profile NAME] [--dry-run] -flow packages remove NAMES... [--dry-run] -flow packages list +flow packages remove NAMES... +flow packages list [--all] # Bootstrap -flow setup run PROFILE [--dry-run] -flow setup show PROFILE +flow setup run PROFILE [--dry-run] # run a full bootstrap profile +flow setup show PROFILE # preview profile steps flow setup list # Remote targets -flow remote enter NAMESPACE@PLATFORM [--dry-run] +flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target flow remote list -# Dev containers -flow dev create IMAGE [--namespace NS] [--dry-run] -flow dev enter NAME [--shell PATH] -flow dev stop NAME -flow dev remove NAME +# Dev containers (docker or podman) +flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run] +flow dev attach NAME # tmux session into container +flow dev exec NAME [-- CMD...] # run a command in container +flow dev enter NAME # interactive shell +flow dev stop NAME [--kill] +flow dev remove NAME [-f] +flow dev respawn NAME # restart all tmux panes flow dev list # Projects -flow projects check [--fetch] +flow projects check [--fetch] # scan ~/projects for dirty repos +flow projects fetch # fetch all project remotes +flow projects summary # quick overview -# Other +# Shell completion +flow completion zsh # print zsh completion script +flow completion install-zsh # install to ~/.zsh/completions + +# Global flags flow --version -flow --quiet -flow completion +flow --quiet # suppress info output +flow --no-color # disable colored output ``` +### Aliases + +- `dotfiles` -> `dot` +- `dotfiles repos` -> `dotfiles repo` +- `packages` -> `package`, `pkg` +- `projects` -> `project`, `sync` (with `--fetch` default) +- `setup` -> `bootstrap` +- `dev attach` -> `dev connect` +- `dev remove` -> `dev rm` + ## Configuration -Config is loaded from `~/.config/flow/config.yaml` and merged with self-hosted config from the dotfiles repo at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`. +Loaded from `~/.config/flow/config.yaml`, merged with dotfiles repo overlay at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`. ```yaml repository: @@ -71,7 +89,9 @@ paths: projects: ~/projects defaults: + container-runtime: auto # auto | docker | podman | podman-rootful container-registry: registry.example.com + container-tag: latest tmux-session: main targets: @@ -81,6 +101,17 @@ targets: identity: ~/.ssh/id_work ``` +### Container runtime + +`container-runtime` controls which engine `flow dev` uses: + +- `auto` -- detect docker or podman from PATH (default) +- `docker` -- force docker +- `podman` -- force podman, prefer rootless socket +- `podman-rootful` -- force podman, prefer rootful socket (`/run/podman/podman.sock`) + +When using podman, `flow dev create` automatically adds `--security-opt label=disable` for engine socket access. The socket is always mounted to `/var/run/docker.sock` inside the container (Podman's Docker-compatible API). + ## Dotfiles layout ``` @@ -103,7 +134,7 @@ linux-work/ # Profile-specific layer ### External modules -A `_module.yaml` inside a package mounts an external git repo at that location: +`_module.yaml` inside a package mounts an external git repo at that location: ```yaml source: github:org/nvim-config @@ -111,11 +142,11 @@ ref: branch: main ``` -Module files are linked from the clone cache, while local files outside the mount path are linked from the dotfiles repo. +Modules are regular git clones managed by flow (not git submodules). They appear alongside the dotfiles repo in `flow dotfiles repos list` and are pulled/pushed with the same commands. ## Manifest -Packages and profiles are defined in YAML files under the flow config directory. +Packages and profiles defined in YAML files under the flow config directory. ```yaml packages: @@ -148,15 +179,21 @@ profiles: - echo "setup complete" ``` +## Architecture + +Four layers: **core** (runtime primitives, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters). + +Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`. + ## Security -- `flow` must run as a regular user (root invocation is rejected) -- `_root/` files require sudo for linking +- Rejects root invocation +- `_root/` dotfile paths require sudo for linking - Package post-install hooks run without sudo by default ## Development ```bash -make deps -.venv/bin/python -m pytest tests/ -v +make deps # create .venv + install deps +.venv/bin/python -m pytest tests/ -v # run tests ``` diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md new file mode 100644 index 0000000..2ee209c --- /dev/null +++ b/docs/refactor-plan.md @@ -0,0 +1,281 @@ +# Flow CLI Refactor Plan + +> Based on code review (2026-03-22) and architecture discussion. +> Spec: `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md` + +## Current State + +The rewrite from the original "vibe-coded" codebase is **largely complete**. The four-layer +architecture (core -> domain -> services -> commands) is in place, 303 tests pass, and the major +structural problems from the old codebase (duplicated code, monkeypatching, dead modules, singleton +abuse) have been resolved. + +What remains is a second pass: finishing incomplete features, unifying the repo abstraction, and +trimming redundant commands. + +--- + +## Agreed Command Surface + +From the architecture discussion. This is the target. + +``` +flow remote enter # Host only. SSH+tmux into VM. +flow remote list # List configured targets. + +flow dev create -i # VM only. Create+start container. +flow dev attach # Attach to container tmux session. +flow dev exec [cmd...] # Run command in container. +flow dev enter # Interactive shell in container. +flow dev list # List dev containers. +flow dev stop # Stop container. +flow dev rm # Remove container. +flow dev respawn # Respawn tmux panes. + +flow dotfiles init --repo # Clone dotfiles repo + all module repos. +flow dotfiles link [--profile p] # Reconcile symlinks (creates, fixes broken, removes stale). +flow dotfiles unlink [packages...] # Remove managed symlinks. +flow dotfiles status [packages...] # Show packages, link health, module info. +flow dotfiles edit # Pull -> $EDITOR -> commit+push. + +flow dotfiles repos list # List ALL managed repos (dotfiles + modules). +flow dotfiles repos status [--repo=x] # Git status for one or all repos. +flow dotfiles repos pull [--repo=x] # Pull one or all repos. +flow dotfiles repos push [--repo=x] # Push one or all repos. + +flow setup run [--profile p] # Bootstrap a machine. +flow setup list # List profiles. +flow setup show # Show profile plan. + +flow packages install # Install packages from manifest. +flow packages list [--all] # List packages. +flow packages remove # Remove packages. + +flow projects check [--fetch] # VM only. Git health across ~/projects. +flow projects fetch # Fetch all project remotes. +flow projects summary # Quick status overview. +``` + +### Aliases + +- `dotfiles` -> `dot` +- `packages` -> `package`, `pkg` +- `projects` -> `project` +- `setup` -> `bootstrap` +- `dev attach` -> `dev connect` +- `dev rm` -> `dev remove` +- `dotfiles repos` -> `dotfiles repo` + +### Global flags + +- `--version` +- `--quiet` / `-q` +- `--no-color` + +### Commands removed (vs current implementation) + +| Removed | Reason | +|---------|--------| +| `dotfiles sync` | Redundant: `repos pull` + `link` | +| `dotfiles relink` | Redundant: `link` is idempotent | +| `dotfiles undo` | Redundant: `unlink` is the inverse of `link` | +| `dotfiles clean` | Folded into `link` (reconciliation handles broken symlinks) | +| `dotfiles modules list` | Replaced by `dotfiles repos list` | +| `dotfiles modules sync` | Replaced by `dotfiles repos pull` | + +### Key design decision: modules are repos + +The `_module.yaml` files define external git repos that provide content for dotfiles packages. +These module repos are **not** git submodules -- they are regular git clones managed by flow. + +The dotfiles repo itself is also a git clone managed by flow. So **all managed git repos** (the +dotfiles repo + every module repo) share the same abstraction and the same commands. + +`dotfiles repos` is the single entry point for all repo operations. There is no separate +`dotfiles modules` subcommand group. The `--repo=` flag filters to a specific repo when +needed (dotfiles repo is named `dotfiles`, module repos are named by their package, e.g. `nvim`). + +--- + +## 1. Unified Repos Abstraction + +This is the most impactful change. Currently: +- `repo_status/pull/push` operate only on the dotfiles repo +- `sync_modules` handles module repos separately +- `list_modules` is a standalone method + +**Target**: a single `_discover_repos() -> list[RepoInfo]` that returns all managed repos, and +repo commands iterate over them. + +### 1.1 Add `RepoInfo` model + +```python +# domain/dotfiles/models.py +@dataclass(frozen=True) +class RepoInfo: + name: str # "dotfiles" or module package name (e.g. "nvim") + path: Path # Local clone path + remote: str # Remote URL + is_module: bool # False for dotfiles repo, True for module repos +``` + +### 1.2 Implement `_discover_repos` + +In `DotfilesService`: walk packages to find `_module.yaml` files, build `RepoInfo` for each +module repo, plus one for the dotfiles repo itself. + +### 1.3 Refactor repo commands + +Replace `repo_status`, `repo_pull`, `repo_push` with methods that iterate `_discover_repos()`, +filtered by `--repo`. Add `repos_list`. + +### 1.4 Remove `dotfiles modules` subcommand group + +Delete `modules list` and `modules sync` subparsers. Remove `sync_modules`, `list_modules` +methods. Remove from completion candidates. + +### 1.5 Update `dotfiles init` + +`init` should clone the dotfiles repo, then discover `_module.yaml` files and clone all module +repos. Currently it calls `sync_modules()` -- this should call `repos_pull()` instead (which +pulls/clones all repos). + +**Files**: `models.py`, `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`. + +--- + +## 2. Remove Redundant Commands + +### 2.1 Remove `dotfiles sync` + +Currently does `git pull --ff-only` + `sync_modules` + optional relink. After the repos +unification, this is just `repos pull` + `link`. No need for a dedicated command. + +### 2.2 Remove `dotfiles relink` + +Currently calls `link()`. `link` is already idempotent -- calling it again reconciles state. + +### 2.3 Remove `dotfiles undo` + +`unlink` is the inverse of `link`. The backup/undo transaction machinery (`_save_backup`, +`_load_backup`, `_backup_path`) can be deleted. + +### 2.4 Fold `dotfiles clean` into `dotfiles link` + +`link` should detect and remove broken symlinks as part of reconciliation, not require a separate +`clean` step. Modify `plan_link` in `domain/dotfiles/planning.py` to include broken link removal +in its plan. + +**Files**: `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`, +`domain/dotfiles/planning.py`. + +--- + +## 3. Previously Incomplete Features -- DONE + +### 3.1 `dotfiles edit` -- DONE + +Implemented: pull -> `$EDITOR`/`$VISUAL` -> scoped `git add` -> commit+push. +Flag: `--no-commit` to skip auto-commit/push. + +### 3.2 `dotfiles status` -- DONE + +Enhanced: shows module info (`branch:main`), link health (`ok`/`broken`/`not linked`), +package name filtering via positional args. + +### 3.3 `dotfiles repos list` -- DONE + +Shows all managed repos (dotfiles + modules) with: name, type, local path, clone status. + +--- + +## 4. Spec Deviations + +### 4.1 `--no-color` global flag -- DONE + +Added to `cli.py`. + +### 4.2 `--dry-run` coverage -- DONE for dotfiles + +`repos pull` and `repos push` now have `--dry-run`. Remaining: + +| Command | Has it | Should have | +|---------|--------|-------------| +| `dev stop` | No | Consider | +| `dev rm` | No | Consider | + +### 4.3 Improvements over spec + +These are correct deviations -- the implementation improved on the spec: +- `core/containers.py` + `core/tmux.py` extracted as adapters (spec had them inline) +- `core/config_parse.py` + `core/yaml.py` extracted for config parsing +- `SystemRuntime` extended with `.containers` and `.tmux` fields + +--- + +## 5. Code Quality (done) + +These were fixed in this session: + +- `FakeRunner` consolidated from 3 test files into `tests/fakes.py` +- `services/dotfiles.py` now uses `flow.core.yaml.load_yaml_file` instead of raw `yaml` +- `ContainerRuntime.binary` no longer eagerly validates PATH for explicit modes + +--- + +## 6. Future (defer) + +### 6.1 Bootstrap as orchestrator + +The spec envisions bootstrap as a pure orchestrator over packages + dotfiles + setup modules. +Current implementation works but has its own package resolution logic. Defer until dotfiles and +packages domains are fully stable. + +### 6.2 Global `--dry-run` + +If per-command `--dry-run` becomes a maintenance burden, promote to a global flag in `cli.py`. + +--- + +## 7. Execution Status + +All phases complete. 315 tests pass, 0 failures. + +### Phase A: Unify repos + trim commands -- DONE + +1. `RepoInfo` model with `module_ref` field +2. `_discover_repos()` finds dotfiles + module repos +3. `repos_list/status/pull/push` iterate all repos with `--repo` filter +4. `repos list` subcommand added +5. `dotfiles modules` subcommand removed entirely +6. `dotfiles sync`, `relink`, `undo`, `clean` removed +7. Broken-symlink handling folded into `plan_link` +8. `dotfiles init` uses unified `repos_pull()` + +### Phase B: Complete features -- DONE + +9. `dotfiles edit` implemented (pull -> $EDITOR -> commit+push, scoped git add) +10. `dotfiles status` enhanced (module info, link health, package filtering) + +### Phase C: CLI polish -- DONE + +11. `--no-color` global flag added to `cli.py` +12. `--dry-run` added to `repos pull` and `repos push` +13. Zsh completion updated for new command surface + +### Phase D: Code quality -- DONE + +14. Dispatch patterns: completion `complete()`, dotfiles `_git_checkout_ref`, bootstrap phases +15. `FakeRunner` consolidated to single `tests/fakes.py` (all 4 test files) +16. Bootstrap `VALID_PHASES` as single source of truth in models +17. Bootstrap models: `Any` types replaced with `ProfilePackageEntry` and `PackageDef` +18. Canonical `ssh-keys` field (removed `ssh-keygen` alias) +19. `getattr` defensive patterns removed from command handlers +20. Test coverage added: `repos_status`, `repos_push`, `repos_pull --dry-run`, status filtering, + broken symlink repair +21. README updated to reflect new command surface + +### Remaining (deferred) + +- Bootstrap as orchestrator (section 6.1) +- Global `--dry-run` (section 6.2) diff --git a/example/dotfiles-repo/_shared/flow/.config/flow/config.yaml b/example/dotfiles-repo/_shared/flow/.config/flow/config.yaml index 21f0935..414fe5d 100644 --- a/example/dotfiles-repo/_shared/flow/.config/flow/config.yaml +++ b/example/dotfiles-repo/_shared/flow/.config/flow/config.yaml @@ -1,10 +1,10 @@ repository: - dotfiles-url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo - dotfiles-branch: main + url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo + branch: main pull-before-edit: true paths: - projects-dir: ~/projects + projects: ~/projects defaults: container-registry: registry.example.com diff --git a/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml b/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml index 0cec873..57b609f 100644 --- a/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml +++ b/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml @@ -1,7 +1,7 @@ profiles: linux-auto: os: linux - requires: [TARGET_HOSTNAME, USER_EMAIL] + env-required: [TARGET_HOSTNAME, USER_EMAIL] hostname: "{{ env.TARGET_HOSTNAME }}" shell: zsh packages: @@ -12,11 +12,11 @@ profiles: - ripgrep - binary/neovim - name: docker - allow_sudo: true + allow-sudo: true post-install: | sudo groupadd docker || true sudo usermod -aG docker $USER - ssh-keygen: + ssh-keys: - type: ed25519 filename: id_ed25519 comment: "{{ env.USER_EMAIL }}" diff --git a/src/flow/cli.py b/src/flow/cli.py index 4a0141b..3e4fccd 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -34,10 +34,11 @@ def main(argv: Optional[list[str]] = None) -> None: return try: - console = Console(quiet=getattr(args, "quiet", False), color=None) + color = False if args.no_color else None + console = Console(quiet=args.quiet, color=color) platform_info = detect_platform() context = detect_context() - cmd_name = getattr(args, "command", "") + cmd_name = args.command or "" if context == "vm" and cmd_name == "remote": raise FlowError("Command 'remote' is not available inside a VM.") @@ -45,12 +46,13 @@ def main(argv: Optional[list[str]] = None) -> None: raise FlowError(f"Command '{cmd_name}' is not available inside a container.") paths.ensure_dirs() + config = load_config() ctx = FlowContext( - config=load_config(), + config=config, manifest=load_manifest(), platform=platform_info, console=console, - runtime=SystemRuntime(), + runtime=SystemRuntime(container_mode=config.container_runtime), ) args.handler(ctx, args) except FlowError as e: @@ -68,6 +70,7 @@ def _build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--version", action="store_true", help="Show version") parser.add_argument("--quiet", "-q", action="store_true", help="Suppress info output") + parser.add_argument("--no-color", action="store_true", help="Disable colored output") subparsers = parser.add_subparsers(dest="command") diff --git a/src/flow/commands/completion.py b/src/flow/commands/completion.py index e5eef36..f875a85 100644 --- a/src/flow/commands/completion.py +++ b/src/flow/commands/completion.py @@ -4,17 +4,20 @@ from __future__ import annotations import argparse import json -import shutil import subprocess from pathlib import Path from typing import Sequence from flow.core.config import load_config, load_manifest from flow.core import paths +from flow.core.containers import ContainerRuntime +from flow.core.errors import FlowError +from flow.core.runtime import CommandRunner from flow.domain.remote.resolution import HOST_TEMPLATES ZSH_RC_START = "# >>> flow completion >>>" ZSH_RC_END = "# <<< flow completion <<<" +CONTAINER_COMPLETION_TIMEOUT_SECONDS = 1.0 TOP_LEVEL_COMMANDS = [ "enter", @@ -56,23 +59,20 @@ def complete(words: Sequence[str], cword: int) -> list[str]: return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current) command = _canonical_command(before[0]) - - if command in {"enter", "remote"}: - return _complete_remote(before, current) - if command == "dev": - return _complete_dev(before, current) - if command == "dotfiles": - return _complete_dotfiles(before, current) - if command == "bootstrap": - return _complete_bootstrap(before, current) - if command == "packages": - return _complete_packages(before, current) - if command == "projects": - return _complete_projects(before, current) - if command == "completion": - return _complete_completion(before, current) - - return [] + completers = { + "enter": _complete_remote, + "remote": _complete_remote, + "dev": _complete_dev, + "dotfiles": _complete_dotfiles, + "setup": _complete_setup, + "packages": _complete_packages, + "projects": _complete_projects, + "completion": _complete_completion, + } + handler = completers.get(command) + if handler is None: + return [] + return handler(before, current) def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]: @@ -88,9 +88,9 @@ def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]: def _canonical_command(command: str) -> str: aliases = { "dot": "dotfiles", - "bootstrap": "bootstrap", - "setup": "bootstrap", - "provision": "bootstrap", + "bootstrap": "setup", + "setup": "setup", + "provision": "setup", "package": "packages", "pkg": "packages", "project": "projects", @@ -205,32 +205,18 @@ def _list_dotfiles_packages(profile: str | None = None) -> list[str]: def _list_container_names() -> list[str]: - runtime = None - for candidate in ("docker", "podman"): - if shutil.which(candidate): - runtime = candidate - break - if runtime is None: + try: + config = _config() + rt = ContainerRuntime(CommandRunner(), mode=config.container_runtime) + output = rt.ps( + all=True, + filter="label=dev=true", + format='{{.Label "dev.name"}}', + timeout=CONTAINER_COMPLETION_TIMEOUT_SECONDS, + ) + except (FlowError, subprocess.TimeoutExpired): return [] - - result = subprocess.run( - [ - runtime, - "ps", - "-a", - "--filter", - "label=dev=true", - "--format", - '{{.Label "dev.name"}}', - ], - capture_output=True, - text=True, - timeout=1, - check=False, - ) - if result.returncode != 0: - return [] - return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()}) + return sorted({line.strip() for line in output.splitlines() if line.strip()}) def _profile_from_before(before: Sequence[str]) -> str | None: @@ -302,7 +288,7 @@ def _complete_dev(before: Sequence[str], current: str) -> list[str]: def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]: if len(before) <= 1: return _filter( - ["init", "link", "relink", "unlink", "undo", "status", "clean", "sync", "modules", "repo", "repos", "edit"], + ["init", "link", "unlink", "status", "edit", "repo", "repos"], current, ) @@ -310,7 +296,7 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]: if subcommand == "init": return _filter(["--repo"], current) if current.startswith("-") else [] - if subcommand in {"link", "relink"}: + if subcommand == "link": if before and before[-1] == "--profile": return _filter(_list_dotfiles_profiles(), current) if current.startswith("-"): @@ -322,43 +308,27 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]: return _filter(["--dry-run"], current) return _filter(_list_dotfiles_packages(), current) - if subcommand == "sync": - if before and before[-1] == "--profile": - return _filter(_list_dotfiles_profiles(), current) - if current.startswith("-"): - return _filter(["--relink", "--profile"], current) - return [] - - if subcommand == "clean": - return _filter(["--dry-run"], current) if current.startswith("-") else [] - if subcommand == "edit": - return _filter(_list_dotfiles_packages(), current) if not current.startswith("-") else [] + if current.startswith("-"): + return _filter(["--no-commit"], current) + return _filter(_list_dotfiles_packages(), current) if subcommand in {"repo", "repos"}: if len(before) <= 2: - return _filter(["status", "pull", "push"], current) + return _filter(["list", "status", "pull", "push"], current) repo_subcommand = before[2] - if repo_subcommand == "pull": - if before and before[-1] == "--profile": - return _filter(_list_dotfiles_profiles(), current) + if repo_subcommand in {"pull", "push"}: if current.startswith("-"): - return _filter(["--rebase", "--no-rebase", "--relink", "--profile"], current) + return _filter(["--repo", "--dry-run"], current) + if repo_subcommand == "status": + if current.startswith("-"): + return _filter(["--repo"], current) return [] - if subcommand == "modules": - if len(before) <= 2: - return _filter(["list", "sync"], current) - if before and before[-1] == "--profile": - return _filter(_list_dotfiles_profiles(), current) - if current.startswith("-"): - return _filter(["--profile"], current) - return _filter(_list_dotfiles_packages(_profile_from_before(before)), current) - return [] -def _complete_bootstrap(before: Sequence[str], current: str) -> list[str]: +def _complete_setup(before: Sequence[str], current: str) -> list[str]: if len(before) <= 1: return _filter(["run", "show", "list"], current) diff --git a/src/flow/commands/dotfiles.py b/src/flow/commands/dotfiles.py index ef09f31..a993050 100644 --- a/src/flow/commands/dotfiles.py +++ b/src/flow/commands/dotfiles.py @@ -14,77 +14,54 @@ def register(subparsers): init.add_argument("--repo", help="Override the configured repository URL") init.set_defaults(handler=_init) - link = sub.add_parser("link", help="Link dotfiles to home") + link = sub.add_parser("link", help="Reconcile dotfile symlinks") link.add_argument("--profile", help="Profile to include") link.add_argument("--dry-run", "-n", action="store_true") link.add_argument("--skip", nargs="*", default=[]) link.set_defaults(handler=_link) - relink = sub.add_parser("relink", help="Refresh managed symlinks") - relink.add_argument("--profile", help="Profile to include") - relink.set_defaults(handler=_relink) - unlink = sub.add_parser("unlink", help="Remove managed symlinks") unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)") unlink.add_argument("--dry-run", "-n", action="store_true") unlink.set_defaults(handler=_unlink) - undo = sub.add_parser("undo", help="Restore the previous linked state") - undo.set_defaults(handler=_undo) - - status = sub.add_parser("status", help="Show link status") + status = sub.add_parser("status", help="Show package and link status") + status.add_argument("packages", nargs="*", help="Filter by package name") status.set_defaults(handler=_status) - clean = sub.add_parser("clean", help="Remove broken symlinks") - clean.add_argument("--dry-run", action="store_true") - clean.set_defaults(handler=_clean) + edit = sub.add_parser("edit", help="Edit a package (pull -> editor -> commit+push)") + edit.add_argument("package", help="Package name") + edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit/push") + edit.set_defaults(handler=_edit) - sync = sub.add_parser("sync", help="Pull dotfiles and sync modules") - sync.add_argument("--relink", action="store_true") - sync.add_argument("--profile", help="Profile to relink after syncing") - sync.set_defaults(handler=_sync) - - modules = sub.add_parser("modules", help="Inspect and refresh external modules") - modules_sub = modules.add_subparsers(dest="dotfiles_modules_action") - - modules_list = modules_sub.add_parser("list", help="List module packages") - modules_list.add_argument("--profile", help="Limit to shared + one profile") - modules_list.set_defaults(handler=_modules_list) - - modules_sync = modules_sub.add_parser("sync", help="Refresh module repositories") - modules_sync.add_argument("--profile", help="Limit to shared + one profile") - modules_sync.set_defaults(handler=_modules_sync) - - modules.set_defaults(handler=_modules_list) - - repo = sub.add_parser("repo", aliases=["repos"], help="Manage the dotfiles repository") + # repos subcommand group (unified: dotfiles repo + module repos) + repo = sub.add_parser("repos", aliases=["repo"], help="Manage dotfiles and module repos") repo_sub = repo.add_subparsers(dest="dotfiles_repo_action") + repo_list = repo_sub.add_parser("list", help="List all managed repos") + repo_list.set_defaults(handler=_repos_list) + repo_status = repo_sub.add_parser("status", help="Show git status") - repo_status.set_defaults(handler=_repo_status) + repo_status.add_argument("--repo", dest="repo_filter", help="Filter by repo name") + repo_status.set_defaults(handler=_repos_status) - repo_pull = repo_sub.add_parser("pull", help="Pull latest changes") - repo_pull.add_argument("--rebase", dest="rebase", action="store_true") - repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false") - repo_pull.add_argument("--relink", action="store_true") - repo_pull.add_argument("--profile", help="Profile to relink after pulling") - repo_pull.set_defaults(rebase=True) - repo_pull.set_defaults(handler=_repo_pull) + repo_pull = repo_sub.add_parser("pull", help="Pull (or clone) repos") + repo_pull.add_argument("--repo", dest="repo_filter", help="Filter by repo name") + repo_pull.add_argument("--dry-run", "-n", action="store_true") + repo_pull.set_defaults(handler=_repos_pull) - repo_push = repo_sub.add_parser("push", help="Push local changes") - repo_push.set_defaults(handler=_repo_push) + repo_push = repo_sub.add_parser("push", help="Push repos") + repo_push.add_argument("--repo", dest="repo_filter", help="Filter by repo name") + repo_push.add_argument("--dry-run", "-n", action="store_true") + repo_push.set_defaults(handler=_repos_push) - repo.set_defaults(handler=_repo_status) - - edit = sub.add_parser("edit", help="Show the package directory") - edit.add_argument("package", help="Package name") - edit.set_defaults(handler=_edit) + repo.set_defaults(handler=_repos_list) p.set_defaults(handler=_default) def _default(ctx: FlowContext, args): - _status(ctx, args) + DotfilesService(ctx).status() def _init(ctx: FlowContext, args): @@ -99,10 +76,6 @@ def _link(ctx: FlowContext, args): ) -def _relink(ctx: FlowContext, args): - DotfilesService(ctx).relink(profile=args.profile) - - def _unlink(ctx: FlowContext, args): DotfilesService(ctx).unlink( packages=args.packages if args.packages else None, @@ -110,45 +83,33 @@ def _unlink(ctx: FlowContext, args): ) -def _undo(ctx: FlowContext, args): - DotfilesService(ctx).undo() - - def _status(ctx: FlowContext, args): - DotfilesService(ctx).status() - - -def _clean(ctx: FlowContext, args): - DotfilesService(ctx).clean(dry_run=args.dry_run) - - -def _sync(ctx: FlowContext, args): - DotfilesService(ctx).sync(profile=args.profile, relink=args.relink) - - -def _modules_list(ctx: FlowContext, args): - DotfilesService(ctx).list_modules(profile=getattr(args, "profile", None)) - - -def _modules_sync(ctx: FlowContext, args): - DotfilesService(ctx).sync_modules(profile=args.profile) - - -def _repo_status(ctx: FlowContext, args): - DotfilesService(ctx).repo_status() - - -def _repo_pull(ctx: FlowContext, args): - DotfilesService(ctx).repo_pull( - profile=args.profile, - relink=args.relink, - rebase=args.rebase, + DotfilesService(ctx).status( + package_filter=args.packages if args.packages else None, ) -def _repo_push(ctx: FlowContext, args): - DotfilesService(ctx).repo_push() - - def _edit(ctx: FlowContext, args): - DotfilesService(ctx).edit(args.package) + DotfilesService(ctx).edit(args.package, no_commit=args.no_commit) + + +def _repos_list(ctx: FlowContext, args): + DotfilesService(ctx).repos_list() + + +def _repos_status(ctx: FlowContext, args): + DotfilesService(ctx).repos_status(repo_filter=args.repo_filter) + + +def _repos_pull(ctx: FlowContext, args): + DotfilesService(ctx).repos_pull( + repo_filter=args.repo_filter, + dry_run=args.dry_run, + ) + + +def _repos_push(ctx: FlowContext, args): + DotfilesService(ctx).repos_push( + repo_filter=args.repo_filter, + dry_run=args.dry_run, + ) diff --git a/src/flow/commands/projects.py b/src/flow/commands/projects.py index dfc0bbe..4209c5a 100644 --- a/src/flow/commands/projects.py +++ b/src/flow/commands/projects.py @@ -15,7 +15,7 @@ def _default(ctx: FlowContext, args): def _check(ctx: FlowContext, args): svc = ProjectService(ctx) - svc.check(fetch=getattr(args, "fetch", False)) + svc.check(fetch=args.fetch) def _fetch(ctx: FlowContext, args): @@ -45,4 +45,4 @@ def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, ali summary = sub.add_parser("summary", help="Show a summary without fetching") summary.set_defaults(handler=_summary) - parser.set_defaults(handler=_default) + parser.set_defaults(handler=_default, fetch=default_fetch) diff --git a/src/flow/core/config.py b/src/flow/core/config.py index 1094f70..9070f3f 100644 --- a/src/flow/core/config.py +++ b/src/flow/core/config.py @@ -6,11 +6,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional -import yaml - from flow.core import paths from flow.core.console import Console -from flow.core.errors import ConfigError from flow.core.platform import PlatformInfo from flow.core.runtime import SystemRuntime @@ -29,6 +26,7 @@ class AppConfig: dotfiles_branch: str = "main" dotfiles_pull_before_edit: bool = True projects_dir: str = "~/projects" + container_runtime: str = "auto" container_registry: str = "registry.tomastm.com" container_tag: str = "latest" tmux_session: str = "default" @@ -44,182 +42,26 @@ class FlowContext: runtime: SystemRuntime = field(default_factory=SystemRuntime) -def _load_yaml_file(path: Path) -> dict[str, Any]: - try: - with open(path, "r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) - except yaml.YAMLError as e: - raise ConfigError(f"Invalid YAML in {path}: {e}") from e - - if data is None: - return {} - - if not isinstance(data, dict): - raise ConfigError(f"YAML file must contain a mapping at root: {path}") - - return data +def _resolve_source_paths( + config_dir: Optional[Path], + overlay_dir: Optional[Path], +) -> tuple[Path, ...]: + """Resolve config/overlay paths to a tuple of source directories.""" + if config_dir is None and overlay_dir is None: + return (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) + if overlay_dir is None: + return (config_dir,) + if config_dir is None: + return (paths.CONFIG_DIR, overlay_dir) + return (config_dir, overlay_dir) -def _merge_yaml_values(base: Any, overlay: Any) -> Any: - if isinstance(base, dict) and isinstance(overlay, dict): - merged = dict(base) - for key, value in overlay.items(): - if key in merged: - merged[key] = _merge_yaml_values(merged[key], value) - else: - merged[key] = value - return merged - - if isinstance(base, list) and isinstance(overlay, list): - return [*base, *overlay] - - return overlay - - -def _list_yaml_files(directory: Path) -> list[Path]: - if not directory.exists() or not directory.is_dir(): - return [] - - return sorted( - ( - child for child in directory.iterdir() - if child.is_file() and child.suffix.lower() in {".yaml", ".yml"} - ), - key=lambda child: child.name, - ) - - -def _load_yaml_source(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - - if path.is_file(): - return _load_yaml_file(path) - - merged: dict[str, Any] = {} - for file_path in _list_yaml_files(path): - merged = _merge_yaml_values(merged, _load_yaml_file(file_path)) - return merged - - -def _load_yaml_documents(path: Path) -> list[dict[str, Any]]: - if not path.exists(): - return [] - if path.is_file(): - return [_load_yaml_file(path)] - return [_load_yaml_file(file_path) for file_path in _list_yaml_files(path)] - - -def _load_yaml_sources(*source_paths: Path) -> dict[str, Any]: - merged: dict[str, Any] = {} - for path in source_paths: - merged = _merge_yaml_values(merged, _load_yaml_source(path)) - return merged - - -def _as_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "y", "on"}: - return True - if normalized in {"0", "false", "no", "n", "off"}: - return False - raise ConfigError(f"Expected boolean value, got {value!r}") - - -def _parse_target_shorthand(key: str, value: str) -> TargetConfig: - parts = value.split() - if not parts: - raise ConfigError(f"Target '{key}' must define a host") - - if "@" in key: - namespace, platform = key.split("@", 1) - if not namespace or not platform: - raise ConfigError(f"Invalid target key '{key}'") - return TargetConfig( - namespace=namespace, - platform=platform, - host=parts[0], - identity=parts[1] if len(parts) > 1 else None, - ) - - if len(parts) < 2: - raise ConfigError( - f"Invalid target value for '{key}': expected 'platform host [identity]'" - ) - - return TargetConfig( - namespace=key, - platform=parts[0], - host=parts[1], - identity=parts[2] if len(parts) > 2 else None, - ) - - -def _parse_targets(raw: Any) -> list[TargetConfig]: - targets: list[TargetConfig] = [] - - if raw is None: - return targets - - if isinstance(raw, dict): - for key, value in raw.items(): - if isinstance(value, str): - targets.append(_parse_target_shorthand(key, value)) - continue - - if not isinstance(value, dict): - raise ConfigError(f"Target '{key}': value must be a string or mapping") - - if "@" in key: - namespace, platform = key.split("@", 1) - else: - namespace = key - platform = value.get("platform") - - host = value.get("host", value.get("ssh-host", value.get("ssh_host"))) - if not namespace or not platform or not host: - raise ConfigError( - f"Target '{key}' must define namespace, platform, and host" - ) - identity = value.get("identity", value.get("ssh-identity", value.get("ssh_identity"))) - targets.append( - TargetConfig( - namespace=str(namespace), - platform=str(platform), - host=str(host), - identity=str(identity) if identity is not None else None, - ) - ) - return targets - - if isinstance(raw, list): - for item in raw: - if not isinstance(item, dict): - raise ConfigError("Target list entries must be mappings") - namespace = item.get("namespace") - platform = item.get("platform") - host = item.get("host", item.get("ssh-host", item.get("ssh_host"))) - if not namespace or not platform or not host: - raise ConfigError( - "Target list entries must define namespace, platform, and host" - ) - identity = item.get("identity", item.get("ssh-identity", item.get("ssh_identity"))) - targets.append( - TargetConfig( - namespace=str(namespace), - platform=str(platform), - host=str(host), - identity=str(identity) if identity is not None else None, - ) - ) - return targets - - raise ConfigError("Targets must be a mapping or list of mappings") - - return targets +def _section_get(data: dict[str, Any], section: str, key: str, default: Any = None) -> Any: + """Get a value from a nested config section.""" + s = data.get(section) + if isinstance(s, dict) and key in s: + return s[key] + return default def load_config( @@ -227,88 +69,37 @@ def load_config( overlay_dir: Optional[Path] = None, ) -> AppConfig: """Load config into AppConfig.""" - if config_dir is None and overlay_dir is None: - source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) - elif overlay_dir is None: - source_paths = (config_dir,) - elif config_dir is None: - source_paths = (paths.CONFIG_DIR, overlay_dir) - else: - source_paths = (config_dir, overlay_dir) + from flow.core.config_parse import as_bool, parse_targets + from flow.core.yaml import load_yaml_documents, merge_yaml_values + + source_paths = _resolve_source_paths(config_dir, overlay_dir) loaded_sources = [ source for path in source_paths - for source in _load_yaml_documents(path) + for source in load_yaml_documents(path) ] data: dict[str, Any] = {} for source in loaded_sources: - data = _merge_yaml_values(data, source) + data = merge_yaml_values(data, source) - repository = data.get("repository") - paths_section = data.get("paths") - defaults = data.get("defaults") + pull_raw = _section_get(data, "repository", "pull-before-edit") return AppConfig( - dotfiles_url=( - str(repository["url"]) - if isinstance(repository, dict) and "url" in repository - else str(data["dotfiles_url"]) if "dotfiles_url" in data - else "" - ), - dotfiles_branch=( - str(repository["branch"]) - if isinstance(repository, dict) and "branch" in repository - else str(data["dotfiles_branch"]) if "dotfiles_branch" in data - else "main" - ), - dotfiles_pull_before_edit=( - _as_bool(repository["pull-before-edit"]) - if isinstance(repository, dict) and "pull-before-edit" in repository - else _as_bool(repository["pull_before_edit"]) - if isinstance(repository, dict) and "pull_before_edit" in repository - else _as_bool(data["dotfiles_pull_before_edit"]) - if "dotfiles_pull_before_edit" in data - else True - ), - projects_dir=( - str(paths_section["projects"]) - if isinstance(paths_section, dict) and "projects" in paths_section - else str(paths_section["projects_dir"]) - if isinstance(paths_section, dict) and "projects_dir" in paths_section - else str(data["projects_dir"]) if "projects_dir" in data - else "~/projects" - ), - container_registry=( - str(defaults["container-registry"]) - if isinstance(defaults, dict) and "container-registry" in defaults - else str(defaults["container_registry"]) - if isinstance(defaults, dict) and "container_registry" in defaults - else str(data["container_registry"]) if "container_registry" in data - else "registry.tomastm.com" - ), - container_tag=( - str(defaults["container-tag"]) - if isinstance(defaults, dict) and "container-tag" in defaults - else str(defaults["container_tag"]) - if isinstance(defaults, dict) and "container_tag" in defaults - else str(data["container_tag"]) if "container_tag" in data - else "latest" - ), - tmux_session=( - str(defaults["tmux-session"]) - if isinstance(defaults, dict) and "tmux-session" in defaults - else str(defaults["tmux_session"]) - if isinstance(defaults, dict) and "tmux_session" in defaults - else str(data["tmux_session"]) if "tmux_session" in data - else "default" - ), + dotfiles_url=str(_section_get(data, "repository", "url", "")), + dotfiles_branch=str(_section_get(data, "repository", "branch", "main")), + dotfiles_pull_before_edit=as_bool(pull_raw) if pull_raw is not None else True, + projects_dir=str(_section_get(data, "paths", "projects", "~/projects")), + container_runtime=str(_section_get(data, "defaults", "container-runtime", "auto")), + container_registry=str(_section_get(data, "defaults", "container-registry", "registry.tomastm.com")), + container_tag=str(_section_get(data, "defaults", "container-tag", "latest")), + tmux_session=str(_section_get(data, "defaults", "tmux-session", "default")), targets=[ target for source in loaded_sources if "targets" in source - for target in _parse_targets(source["targets"]) - ] if any("targets" in source for source in loaded_sources) else [], + for target in parse_targets(source["targets"]) + ], ) @@ -317,13 +108,6 @@ def load_manifest( overlay_dir: Optional[Path] = None, ) -> dict[str, Any]: """Load merged manifest YAML.""" - if config_dir is None and overlay_dir is None: - source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) - elif overlay_dir is None: - source_paths = (config_dir,) - elif config_dir is None: - source_paths = (paths.CONFIG_DIR, overlay_dir) - else: - source_paths = (config_dir, overlay_dir) + from flow.core.yaml import load_yaml_sources - return _load_yaml_sources(*source_paths) + return load_yaml_sources(*_resolve_source_paths(config_dir, overlay_dir)) diff --git a/src/flow/core/config_parse.py b/src/flow/core/config_parse.py new file mode 100644 index 0000000..eb29317 --- /dev/null +++ b/src/flow/core/config_parse.py @@ -0,0 +1,111 @@ +"""Config parsing helpers.""" + +from __future__ import annotations + +from typing import Any + +from flow.core.config import TargetConfig +from flow.core.errors import ConfigError + + +def as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + raise ConfigError(f"Expected boolean value, got {value!r}") + + +def parse_target_shorthand(key: str, value: str) -> TargetConfig: + parts = value.split() + if not parts: + raise ConfigError(f"Target '{key}' must define a host") + + if "@" in key: + namespace, platform = key.split("@", 1) + if not namespace or not platform: + raise ConfigError(f"Invalid target key '{key}'") + return TargetConfig( + namespace=namespace, + platform=platform, + host=parts[0], + identity=parts[1] if len(parts) > 1 else None, + ) + + if len(parts) < 2: + raise ConfigError( + f"Invalid target value for '{key}': expected 'platform host [identity]'" + ) + + return TargetConfig( + namespace=key, + platform=parts[0], + host=parts[1], + identity=parts[2] if len(parts) > 2 else None, + ) + + +def parse_targets(raw: Any) -> list[TargetConfig]: + targets: list[TargetConfig] = [] + + if raw is None: + return targets + + if isinstance(raw, dict): + for key, value in raw.items(): + if isinstance(value, str): + targets.append(parse_target_shorthand(key, value)) + continue + + if not isinstance(value, dict): + raise ConfigError(f"Target '{key}': value must be a string or mapping") + + if "@" in key: + namespace, platform = key.split("@", 1) + else: + namespace = key + platform = value.get("platform") + + host = value.get("host") + if not namespace or not platform or not host: + raise ConfigError( + f"Target '{key}' must define namespace, platform, and host" + ) + identity = value.get("identity") + targets.append( + TargetConfig( + namespace=str(namespace), + platform=str(platform), + host=str(host), + identity=str(identity) if identity is not None else None, + ) + ) + return targets + + if isinstance(raw, list): + for item in raw: + if not isinstance(item, dict): + raise ConfigError("Target list entries must be mappings") + namespace = item.get("namespace") + platform = item.get("platform") + host = item.get("host") + if not namespace or not platform or not host: + raise ConfigError( + "Target list entries must define namespace, platform, and host" + ) + identity = item.get("identity") + targets.append( + TargetConfig( + namespace=str(namespace), + platform=str(platform), + host=str(host), + identity=str(identity) if identity is not None else None, + ) + ) + return targets + + raise ConfigError("Targets must be a mapping or list of mappings") diff --git a/src/flow/core/containers.py b/src/flow/core/containers.py new file mode 100644 index 0000000..c3f50b2 --- /dev/null +++ b/src/flow/core/containers.py @@ -0,0 +1,176 @@ +"""Container runtime adapter -- typed wrapper around docker/podman.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Sequence + +from flow.core.errors import FlowError + +if TYPE_CHECKING: + from flow.core.runtime import CommandRunner + + +MODES = ("auto", "docker", "podman", "podman-rootful") + + +class ContainerRuntime: + """Container runtime (docker/podman) adapter following the GitClient pattern.""" + + def __init__( + self, + runner: CommandRunner, + *, + mode: str = "auto", + binary: Optional[str] = None, + ): + if mode not in MODES: + raise FlowError( + f"Unknown container runtime mode {mode!r}, " + f"expected one of: {', '.join(MODES)}" + ) + self.runner = runner + self.mode = mode + self._binary = binary + + @property + def binary(self) -> str: + if self._binary is None: + if self.mode in ("podman", "podman-rootful"): + name = "podman" + elif self.mode == "docker": + name = "docker" + else: + name = next( + (n for n in ("docker", "podman") if shutil.which(n)), + None, + ) + if name is None: + raise FlowError("No container runtime found (docker or podman)") + self._binary = name + return self._binary + + @property + def socket_path(self) -> Optional[Path]: + for candidate in self._socket_candidates(): + if candidate.exists(): + return candidate + return None + + def _socket_candidates(self) -> list[Path]: + if self.binary == "docker": + return [Path("/var/run/docker.sock")] + uid = os.getuid() + xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}") + rootless = Path(f"{xdg}/podman/podman.sock") + rootful = Path("/run/podman/podman.sock") + compat = Path("/var/run/docker.sock") + if self.mode == "podman-rootful": + return [rootful, rootless, compat] + return [rootless, rootful, compat] + + @property + def socket_security_opts(self) -> list[str]: + """Security opt values needed when mounting the engine socket.""" + if self.binary == "podman": + return ["label=disable"] + return [] + + def run_container( + self, + name: str, + image: str, + *, + network: str = "host", + init: bool = True, + labels: Optional[dict[str, str]] = None, + mounts: Optional[Sequence[str]] = None, + security_opts: Optional[Sequence[str]] = None, + command: Optional[Sequence[str]] = None, + detach: bool = False, + ) -> None: + argv = [self.binary, "run"] + if detach: + argv.append("-d") + argv.extend(["--name", name, "--network", network]) + if init: + argv.append("--init") + for opt in security_opts or []: + argv.extend(["--security-opt", opt]) + for key, value in (labels or {}).items(): + argv.extend(["--label", f"{key}={value}"]) + for mount in mounts or []: + argv.extend(["-v", mount]) + argv.append(image) + if command: + argv.extend(command) + self.runner.run(argv, capture_output=False, check=True) + + def exec_in( + self, + container: str, + command: Sequence[str], + *, + interactive: bool = False, + detach_keys: Optional[str] = None, + ) -> int: + argv = [self.binary, "exec"] + if interactive: + argv.append("-it") + if detach_keys: + argv.extend(["--detach-keys", detach_keys]) + argv.append(container) + argv.extend(command) + result = self.runner.run(argv, capture_output=False) + return result.returncode + + def start(self, container: str) -> None: + self.runner.run([self.binary, "start", container], capture_output=True, check=True) + + def stop(self, container: str) -> None: + self.runner.run([self.binary, "stop", container], capture_output=False, check=True) + + def kill(self, container: str) -> None: + self.runner.run([self.binary, "kill", container], capture_output=False, check=True) + + def rm(self, container: str, *, force: bool = False) -> None: + argv = [self.binary, "rm"] + if force: + argv.append("-f") + argv.append(container) + self.runner.run(argv, capture_output=False, check=True) + + def inspect(self, container: str, format: str) -> str: + result = self.runner.run( + [self.binary, "container", "inspect", container, "--format", format], + check=True, + ) + return result.stdout.strip() + + def ps( + self, + *, + all: bool = False, + filter: Optional[str] = None, + format: Optional[str] = None, + timeout: Optional[float] = None, + ) -> str: + argv = [self.binary, "ps"] + if all: + argv.append("-a") + if filter: + argv.extend(["--filter", filter]) + if format: + argv.extend(["--format", format]) + result = self.runner.run(argv, check=True, timeout=timeout) + return result.stdout.strip() + + def container_exists(self, name: str) -> bool: + output = self.ps(all=True, format="{{.Names}}") + return name in output.splitlines() + + def container_running(self, name: str) -> bool: + output = self.ps(format="{{.Names}}") + return name in output.splitlines() diff --git a/src/flow/core/errors.py b/src/flow/core/errors.py index 6bb0100..d198c8b 100644 --- a/src/flow/core/errors.py +++ b/src/flow/core/errors.py @@ -15,7 +15,3 @@ class PlanConflict(FlowError): def __init__(self, message: str, conflicts: list[str]): super().__init__(message) self.conflicts = conflicts - - -class ExecutionError(FlowError): - """A plan step failed during execution.""" diff --git a/src/flow/core/runtime.py b/src/flow/core/runtime.py index adf6c02..13f71dc 100644 --- a/src/flow/core/runtime.py +++ b/src/flow/core/runtime.py @@ -9,7 +9,9 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Mapping, Optional, Sequence +from flow.core.containers import ContainerRuntime from flow.core.errors import FlowError +from flow.core.tmux import TmuxClient class CommandRunner: @@ -135,6 +137,8 @@ class FileSystem: runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True) return self.ensure_dir(target.parent) + if target.is_symlink() or target.exists(): + target.unlink() target.symlink_to(source) def same_symlink(self, target: Path, source: Path) -> bool: @@ -190,7 +194,12 @@ class SystemRuntime: """Shared runtime dependencies.""" runner: CommandRunner = field(default_factory=CommandRunner) fs: FileSystem = field(default_factory=FileSystem) + container_mode: str = "auto" git: GitClient = field(init=False) + tmux: TmuxClient = field(init=False) + containers: ContainerRuntime = field(init=False) def __post_init__(self) -> None: self.git = GitClient(self.runner) + self.tmux = TmuxClient(self.runner) + self.containers = ContainerRuntime(self.runner, mode=self.container_mode) diff --git a/src/flow/core/tmux.py b/src/flow/core/tmux.py new file mode 100644 index 0000000..aa30504 --- /dev/null +++ b/src/flow/core/tmux.py @@ -0,0 +1,86 @@ +"""Tmux adapter -- typed wrapper around tmux commands.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from flow.core.runtime import CommandRunner + + +class TmuxClient: + """Tmux session manager following the GitClient pattern.""" + + def __init__(self, runner: CommandRunner): + self.runner = runner + + def has_session(self, session: str) -> bool: + result = self.runner.run(["tmux", "has-session", "-t", session]) + return result.returncode == 0 + + def new_session( + self, + session: str, + *, + detached: bool = False, + env: Optional[dict[str, str]] = None, + command: Optional[str] = None, + ) -> None: + argv = ["tmux", "new-session"] + if detached: + argv.append("-ds") + else: + argv.append("-s") + argv.append(session) + for key, value in (env or {}).items(): + argv.extend(["-e", f"{key}={value}"]) + if command: + argv.append(command) + self.runner.run(argv, capture_output=True, check=True) + + def set_option(self, session: str, option: str, value: str) -> None: + self.runner.run( + ["tmux", "set-option", "-t", session, option, value], + capture_output=True, + check=True, + ) + + def list_panes(self, session: str) -> list[str]: + result = self.runner.run( + [ + "tmux", + "list-panes", + "-t", + session, + "-s", + "-F", + "#{session_name}:#{window_index}.#{pane_index}", + ], + check=True, + ) + return [p for p in result.stdout.strip().splitlines() if p] + + def respawn_pane(self, pane: str) -> None: + self.runner.run( + ["tmux", "respawn-pane", "-t", pane], + capture_output=False, + check=True, + ) + + def attach_or_switch(self, session: str) -> None: + if os.environ.get("TMUX"): + os.execvp("tmux", ["tmux", "switch-client", "-t", session]) + os.execvp("tmux", ["tmux", "attach", "-t", session]) + + +def build_new_session_argv( + session: str, + *, + env: Optional[dict[str, str]] = None, +) -> list[str]: + """Build tmux new-session argv for remote/SSH use (pure, no I/O).""" + argv = ["tmux", "new-session", "-As", session] + for key, value in (env or {}).items(): + argv.extend(["-e", f"{key}={value}"]) + return argv diff --git a/src/flow/core/yaml.py b/src/flow/core/yaml.py new file mode 100644 index 0000000..c0654f2 --- /dev/null +++ b/src/flow/core/yaml.py @@ -0,0 +1,83 @@ +"""YAML loading and merging utilities.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from flow.core.errors import ConfigError + + +def load_yaml_file(path: Path) -> dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + except yaml.YAMLError as e: + raise ConfigError(f"Invalid YAML in {path}: {e}") from e + + if data is None: + return {} + + if not isinstance(data, dict): + raise ConfigError(f"YAML file must contain a mapping at root: {path}") + + return data + + +def merge_yaml_values(base: Any, overlay: Any) -> Any: + if isinstance(base, dict) and isinstance(overlay, dict): + merged = dict(base) + for key, value in overlay.items(): + if key in merged: + merged[key] = merge_yaml_values(merged[key], value) + else: + merged[key] = value + return merged + + if isinstance(base, list) and isinstance(overlay, list): + return [*base, *overlay] + + return overlay + + +def list_yaml_files(directory: Path) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + + return sorted( + ( + child for child in directory.iterdir() + if child.is_file() and child.suffix.lower() in {".yaml", ".yml"} + ), + key=lambda child: child.name, + ) + + +def load_yaml_source(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + + if path.is_file(): + return load_yaml_file(path) + + merged: dict[str, Any] = {} + for file_path in list_yaml_files(path): + merged = merge_yaml_values(merged, load_yaml_file(file_path)) + return merged + + +def load_yaml_documents(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + if path.is_file(): + return [load_yaml_file(path)] + return [load_yaml_file(file_path) for file_path in list_yaml_files(path)] + + +def load_yaml_sources(*source_paths: Path) -> dict[str, Any]: + merged: dict[str, Any] = {} + for path in source_paths: + merged = merge_yaml_values(merged, load_yaml_source(path)) + return merged diff --git a/src/flow/domain/bootstrap/models.py b/src/flow/domain/bootstrap/models.py index 36b54da..80ee530 100644 --- a/src/flow/domain/bootstrap/models.py +++ b/src/flow/domain/bootstrap/models.py @@ -1,7 +1,15 @@ """Bootstrap domain models.""" -from dataclasses import dataclass, field -from typing import Any, Optional +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Union + +from flow.domain.packages.models import PackageDef + + +# Profile package entries: either a string name or a dict with overrides +ProfilePackageEntry = Union[str, dict[str, Any]] @dataclass(frozen=True) @@ -15,16 +23,19 @@ class Profile: shell: Optional[str] ssh_keys: tuple[dict[str, str], ...] runcmd: tuple[str, ...] - packages: tuple[Any, ...] # Raw entries, resolved later + packages: tuple[ProfilePackageEntry, ...] env_required: tuple[str, ...] dotfiles_profile: Optional[str] = None post_link: Optional[str] = None +VALID_PHASES = {"setup", "packages", "shell", "dotfiles", "post-link"} + + @dataclass(frozen=True) class BootstrapAction: """A single action in a bootstrap plan.""" - phase: str # "validate" | "setup" | "packages" | "shell" | "dotfiles" + phase: str description: str commands: tuple[str, ...] needs_sudo: bool = False @@ -39,7 +50,7 @@ class BootstrapPlan: """Complete bootstrap plan.""" profile: str actions: tuple[BootstrapAction, ...] - packages_to_install: tuple[Any, ...] # PackageDef tuple + packages_to_install: tuple[PackageDef, ...] @property def total_steps(self) -> int: diff --git a/src/flow/domain/bootstrap/modules.py b/src/flow/domain/bootstrap/modules.py index 13e6b60..7074cc5 100644 --- a/src/flow/domain/bootstrap/modules.py +++ b/src/flow/domain/bootstrap/modules.py @@ -2,7 +2,6 @@ import shlex from dataclasses import dataclass -from typing import Optional class SetupModule: diff --git a/src/flow/domain/bootstrap/planning.py b/src/flow/domain/bootstrap/planning.py index 729ebeb..2a30bab 100644 --- a/src/flow/domain/bootstrap/planning.py +++ b/src/flow/domain/bootstrap/planning.py @@ -38,13 +38,7 @@ def _normalize_ssh_keys(raw: Any) -> tuple[dict[str, str], ...]: def parse_profile(name: str, raw: dict[str, Any]) -> Profile: """Parse a profile definition from manifest.""" - ssh_keys = raw.get("ssh-keys") or raw.get("ssh_keys") - if ssh_keys is None: - ssh_keys = raw.get("ssh-keygen") or raw.get("ssh_keygen") - - env_required = raw.get("env-required") or raw.get("env_required") - if env_required is None: - env_required = raw.get("requires") + ssh_keys = raw.get("ssh-keys") return Profile( name=name, @@ -56,9 +50,9 @@ def parse_profile(name: str, raw: dict[str, Any]) -> Profile: ssh_keys=_normalize_ssh_keys(ssh_keys), runcmd=tuple(raw.get("runcmd") or []), packages=tuple(raw.get("packages") or []), - env_required=tuple(env_required or []), - dotfiles_profile=raw.get("dotfiles-profile") or raw.get("dotfiles_profile"), - post_link=raw.get("post-link") or raw.get("post_link"), + env_required=tuple(raw.get("env-required") or []), + dotfiles_profile=raw.get("dotfiles-profile"), + post_link=raw.get("post-link"), ) diff --git a/src/flow/domain/containers/models.py b/src/flow/domain/containers/models.py index cd36c43..bb04102 100644 --- a/src/flow/domain/containers/models.py +++ b/src/flow/domain/containers/models.py @@ -29,10 +29,6 @@ class Mount: target: str readonly: bool = False - def to_flag(self) -> str: - opt = ":ro" if self.readonly else "" - return f"-v {self.source}:{self.target}{opt}" - @dataclass(frozen=True) class ContainerSpec: diff --git a/src/flow/domain/containers/resolution.py b/src/flow/domain/containers/resolution.py index b526f6f..c794b24 100644 --- a/src/flow/domain/containers/resolution.py +++ b/src/flow/domain/containers/resolution.py @@ -48,6 +48,7 @@ def resolve_mounts( *, project_path: Optional[str] = None, dotfiles_dir: Optional[Path] = None, + socket_path: Optional[Path] = None, ) -> list[Mount]: """Resolve standard container mounts.""" mounts: list[Mount] = [] @@ -65,9 +66,8 @@ def resolve_mounts( if source.exists(): mounts.append(Mount(source=source, target=target, readonly=readonly)) - docker_sock = Path("/var/run/docker.sock") - if docker_sock.exists(): - mounts.append(Mount(source=docker_sock, target="/var/run/docker.sock")) + if socket_path: + mounts.append(Mount(source=socket_path, target="/var/run/docker.sock")) if dotfiles_dir and dotfiles_dir.exists(): mounts.append( diff --git a/src/flow/domain/dotfiles/models.py b/src/flow/domain/dotfiles/models.py index 01d612e..5a96e5a 100644 --- a/src/flow/domain/dotfiles/models.py +++ b/src/flow/domain/dotfiles/models.py @@ -60,6 +60,7 @@ class LinkOp: @dataclass(frozen=True) class PlanSummary: added: int + updated: int removed: int unchanged: int from_modules: int @@ -96,7 +97,7 @@ class LinkedState: from flow.core.errors import ConfigError raise ConfigError( f"Unsupported linked.json version {version}. " - "Delete ~/.local/state/flow/linked.json and relink." + "Delete ~/.local/state/flow/linked.json and re-run `flow dotfiles link`." ) links: dict[Path, LinkTarget] = {} raw_links = data.get("links", {}) @@ -119,3 +120,4 @@ class RepoInfo: path: Path source: str is_module: bool + module_ref: Optional[ModuleRef] = None diff --git a/src/flow/domain/dotfiles/planning.py b/src/flow/domain/dotfiles/planning.py index 0713bf5..a496174 100644 --- a/src/flow/domain/dotfiles/planning.py +++ b/src/flow/domain/dotfiles/planning.py @@ -19,11 +19,13 @@ def plan_link( ) -> LinkPlan: """Build reconciliation plan. - filesystem_check: injected by service. Returns "file", "dir", "symlink", or None. + filesystem_check: injected by service. Returns "file", "dir", "symlink", + "broken_symlink", or None. """ ops: list[LinkOp] = [] conflicts: list[str] = [] added = 0 + updated = 0 removed = 0 unchanged = 0 from_modules = 0 @@ -41,18 +43,37 @@ def plan_link( )) removed += 1 - # Additions, updates, and unchanged + # Additions, updates, broken symlink repair, and unchanged for target in sorted(desired_targets): spec = desired_map[target] if target in current.links: cur = current.links[target] + fs_state = filesystem_check(target) + + # Broken symlink: remove and recreate + if fs_state == "broken_symlink": + ops.append(LinkOp( + type="remove_link", target=target, source=cur.source, + package=cur.package, needs_sudo=cur.needs_sudo, + )) + ops.append(LinkOp( + type="create_link", target=target, source=spec.source, + package=spec.package, needs_sudo=spec.needs_sudo, + )) + updated += 1 + if spec.from_module: + from_modules += 1 + continue + + # Source unchanged: nothing to do if cur.source == spec.source: unchanged += 1 if spec.from_module: from_modules += 1 continue - # Source changed: remove old link, then create new one + + # Source changed: remove old link, create new one ops.append(LinkOp( type="remove_link", target=target, source=cur.source, package=cur.package, needs_sudo=cur.needs_sudo, @@ -61,13 +82,27 @@ def plan_link( type="create_link", target=target, source=spec.source, package=spec.package, needs_sudo=spec.needs_sudo, )) + updated += 1 + if spec.from_module: + from_modules += 1 + continue + + # New target: check filesystem for conflicts + fs_state = filesystem_check(target) + if fs_state == "broken_symlink": + # Broken symlink at target: remove it and create the correct link + ops.append(LinkOp( + type="remove_link", target=target, source=None, + package=spec.package, needs_sudo=spec.needs_sudo, + )) + ops.append(LinkOp( + type="create_link", target=target, source=spec.source, + package=spec.package, needs_sudo=spec.needs_sudo, + )) added += 1 if spec.from_module: from_modules += 1 continue - - # New target: check filesystem for conflicts - fs_state = filesystem_check(target) if fs_state is not None: conflicts.append( f"{target} already exists ({fs_state}) and is not managed by flow" @@ -86,7 +121,7 @@ def plan_link( operations=tuple(ops), conflicts=tuple(conflicts), summary=PlanSummary( - added=added, removed=removed, + added=added, updated=updated, removed=removed, unchanged=unchanged, from_modules=from_modules, ), ) @@ -115,5 +150,5 @@ def plan_unlink( return LinkPlan( operations=tuple(ops), conflicts=(), - summary=PlanSummary(added=0, removed=len(ops), unchanged=0, from_modules=0), + summary=PlanSummary(added=0, updated=0, removed=len(ops), unchanged=0, from_modules=0), ) diff --git a/src/flow/domain/dotfiles/resolution.py b/src/flow/domain/dotfiles/resolution.py index 796c9de..063a337 100644 --- a/src/flow/domain/dotfiles/resolution.py +++ b/src/flow/domain/dotfiles/resolution.py @@ -1,12 +1,13 @@ """Path resolution: package -> home-relative LinkTargets. Pure functions.""" +from __future__ import annotations + from pathlib import Path from flow.core.errors import PlanConflict from flow.domain.dotfiles.models import LinkTarget, Package RESERVED_ROOT = "_root" -MODULE_FILE = "_module.yaml" def resolve_package_targets( @@ -23,10 +24,6 @@ def resolve_package_targets( # Local files (from dotfiles repo) for abs_source, rel in package.local_files: - # Skip _module.yaml - if rel.name == MODULE_FILE: - continue - # If module exists, skip files inside mount_path (module provides those) if mount_path is not None: if mount_path == Path("."): @@ -82,19 +79,20 @@ def resolve_all_targets( """Resolve targets for all packages. Raises PlanConflict on duplicate targets.""" all_targets: list[LinkTarget] = [] seen: dict[Path, str] = {} + conflicts: list[str] = [] for pkg in packages: targets = resolve_package_targets(pkg, home, skip) for t in targets: if t.target in seen: - conflicts = [ + conflicts.append( f"{t.target} claimed by both {seen[t.target]} and {t.package}" - ] - raise PlanConflict( - f"Conflicting dotfile targets across packages", - conflicts, ) + continue seen[t.target] = t.package all_targets.append(t) + if conflicts: + raise PlanConflict("Conflicting dotfile targets across packages", conflicts) + return all_targets diff --git a/src/flow/domain/packages/catalog.py b/src/flow/domain/packages/catalog.py index 0ac1427..3e32d74 100644 --- a/src/flow/domain/packages/catalog.py +++ b/src/flow/domain/packages/catalog.py @@ -1,6 +1,6 @@ """Package catalog: parsing manifest into PackageDef objects.""" -from typing import Any, Optional +from typing import Any from flow.core.errors import ConfigError from flow.domain.packages.models import PackageDef, ProfilePackageRef @@ -54,12 +54,12 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef: sources=sources, source=entry.get("source"), version=entry.get("version"), - asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), - platform_map=entry.get("platform-map") or entry.get("platform_map") or {}, - extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), + asset_pattern=entry.get("asset-pattern"), + platform_map=entry.get("platform-map") or {}, + extract_dir=entry.get("extract-dir"), install=entry.get("install") or {}, - post_install=entry.get("post-install") or entry.get("post_install"), - allow_sudo=bool(entry.get("allow-sudo", entry.get("allow_sudo", False))), + post_install=entry.get("post-install"), + allow_sudo=bool(entry.get("allow-sudo", False)), ) @@ -101,12 +101,12 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef: type=entry.get("type"), source=entry.get("source"), version=entry.get("version"), - asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), - platform_map=entry.get("platform-map") or entry.get("platform_map"), - extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), + asset_pattern=entry.get("asset-pattern"), + platform_map=entry.get("platform-map"), + extract_dir=entry.get("extract-dir"), install=entry.get("install"), - post_install=entry.get("post-install") or entry.get("post_install"), - allow_sudo=entry.get("allow-sudo", entry.get("allow_sudo")), + post_install=entry.get("post-install"), + allow_sudo=entry.get("allow-sudo"), ) raise ConfigError(f"Invalid profile package entry: {entry}") diff --git a/src/flow/domain/packages/resolution.py b/src/flow/domain/packages/resolution.py index 39bbd6e..8d54c85 100644 --- a/src/flow/domain/packages/resolution.py +++ b/src/flow/domain/packages/resolution.py @@ -1,7 +1,7 @@ """Package resolution: resolving what to install and how.""" import shutil -from typing import Any, Optional +from typing import Optional from flow.core.template import substitute_template from flow.core.errors import FlowError @@ -71,10 +71,7 @@ def binary_template_context(pkg: PackageDef, platform_str: str) -> dict[str, str def _render_template_value(template: str, context: dict[str, str]) -> str: - rendered = substitute_template(template, context) - for key, value in context.items(): - rendered = rendered.replace(f"{{{key}}}", value) - return rendered + return substitute_template(template, context) def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str: diff --git a/src/flow/domain/remote/resolution.py b/src/flow/domain/remote/resolution.py index 50a4903..9ee0d55 100644 --- a/src/flow/domain/remote/resolution.py +++ b/src/flow/domain/remote/resolution.py @@ -4,6 +4,7 @@ from typing import Optional from flow.core.config import TargetConfig from flow.core.errors import FlowError +from flow.core.tmux import build_new_session_argv from flow.domain.remote.models import SSHCommand, Target @@ -86,7 +87,7 @@ def build_ssh_command( argv.extend(["-i", target.identity]) argv.extend(["-o", "StrictHostKeyChecking=accept-new"]) - destination = _build_destination(target.user, target.host) + destination = build_destination(target.user, target.host) argv.append(destination) env = { @@ -95,16 +96,10 @@ def build_ssh_command( } if not no_tmux: - argv.extend([ - "tmux", - "new-session", - "-As", + argv.extend(build_new_session_argv( tmux_session, - "-e", - f"DF_NAMESPACE={target.namespace}", - "-e", - f"DF_PLATFORM={target.platform}", - ]) + env=env, + )) return SSHCommand( argv=tuple(argv), @@ -114,7 +109,7 @@ def build_ssh_command( ) -def _build_destination(user: str, host: str) -> str: +def build_destination(user: str, host: str) -> str: if "@" in host: return host if not user: @@ -125,7 +120,7 @@ def _build_destination(user: str, host: str) -> str: def terminfo_fix_command( term: Optional[str] = "xterm-256color", destination: str = "TARGET", -) -> Optional[str]: +) -> str: normalized_term = (term or "").strip().lower() if normalized_term == "xterm-ghostty": diff --git a/src/flow/services/bootstrap.py b/src/flow/services/bootstrap.py index 8da3faf..02d0a49 100644 --- a/src/flow/services/bootstrap.py +++ b/src/flow/services/bootstrap.py @@ -8,6 +8,7 @@ from typing import Optional from flow.core.config import FlowContext from flow.core.errors import FlowError +from flow.domain.bootstrap.models import VALID_PHASES, BootstrapAction from flow.domain.bootstrap.planning import parse_profile, plan_bootstrap @@ -50,27 +51,10 @@ class BootstrapService: if dry_run: return + dotfiles_profile = profile.dotfiles_profile or profile_name for action in plan.actions: self.ctx.console.info(f" {action}") - - if action.phase == "packages": - # Delegate to PackageService - from flow.services.packages import PackageService - pkg_svc = PackageService(self.ctx) - if plan.packages_to_install: - pkg_svc.install(list(plan.packages_to_install)) - continue - - if action.phase == "dotfiles": - # Delegate to DotfilesService - from flow.services.dotfiles import DotfilesService - dot_svc = DotfilesService(self.ctx) - dot_svc.link(profile=profile.dotfiles_profile or profile_name) - continue - - # Execute shell commands - for cmd in action.commands: - self.ctx.runtime.runner.run_shell(cmd, check=True) + self._execute_action(action, plan, dotfiles_profile) self.ctx.console.success(f"Bootstrap complete for {profile_name}.") @@ -90,3 +74,27 @@ class BootstrapService: for name, data in sorted(profiles.items()) ] self.ctx.console.table(["PROFILE", "OS", "HOSTNAME"], rows) + + def _execute_action( + self, action: BootstrapAction, plan, dotfiles_profile: str, + ) -> None: + """Execute a single bootstrap action by phase.""" + if action.phase not in VALID_PHASES: + raise FlowError(f"Unknown bootstrap phase: {action.phase!r}") + + if action.phase == "packages": + from flow.services.packages import PackageService + pkg_svc = PackageService(self.ctx) + if plan.packages_to_install: + pkg_svc.install(list(plan.packages_to_install)) + return + + if action.phase == "dotfiles": + from flow.services.dotfiles import DotfilesService + dot_svc = DotfilesService(self.ctx) + dot_svc.link(profile=dotfiles_profile) + return + + # Shell phases: setup, shell, post-link + for cmd in action.commands: + self.ctx.runtime.runner.run_shell(cmd, check=True) diff --git a/src/flow/services/containers.py b/src/flow/services/containers.py index f31c145..e749374 100644 --- a/src/flow/services/containers.py +++ b/src/flow/services/containers.py @@ -16,17 +16,12 @@ from flow.domain.containers.resolution import ( ) -def runtime() -> str: - for name in ("docker", "podman"): - if shutil.which(name): - return name - raise FlowError("No container runtime found (docker or podman)") - - class ContainerService: def __init__(self, ctx: FlowContext): self.ctx = ctx self.runner = ctx.runtime.runner + self.rt = ctx.runtime.containers + self.tmux = ctx.runtime.tmux def create( self, @@ -37,7 +32,6 @@ class ContainerService: dry_run: bool = False, ) -> None: """Create and start a development container.""" - rt = runtime() spec = build_container_spec( name, parse_image_ref( @@ -49,11 +43,12 @@ class ContainerService: paths.HOME, project_path=project_path, dotfiles_dir=paths.DOTFILES_DIR, + socket_path=self.rt.socket_path, ), project_path=project_path, ) - if self._container_exists(rt, spec.name): + if not dry_run and self.rt.container_exists(spec.name): raise FlowError(f"Container already exists: {spec.name}") self.ctx.console.info(f"Creating container: {spec.name}") @@ -62,116 +57,87 @@ class ContainerService: if dry_run: return - cmd = [ - rt, - "run", - "-d", - "--name", - spec.name, - "--network", - spec.network, - "--init", + mount_flags = [ + f"{m.source}:{m.target}{':ro' if m.readonly else ''}" + for m in spec.mounts ] - for key, value in spec.labels.items(): - cmd.extend(["--label", f"{key}={value}"]) - for mount in spec.mounts: - cmd.extend(["-v", f"{mount.source}:{mount.target}{':ro' if mount.readonly else ''}"]) - cmd.extend([spec.image.full, "sleep", "infinity"]) - - self.runner.run(cmd, capture_output=False, check=True) + security_opts = self.rt.socket_security_opts if self.rt.socket_path else [] + self.rt.run_container( + spec.name, + spec.image.full, + network=spec.network, + labels=spec.labels, + mounts=mount_flags, + security_opts=security_opts, + command=["sleep", "infinity"], + detach=True, + ) self.ctx.console.success(f"Created and started container: {spec.name}") def exec(self, name: str, command: list[str] | None = None) -> None: """Run a command or interactive shell inside a container.""" - rt = runtime() cname = container_name(name) - if not self._container_running(rt, cname): + if not self.rt.container_running(cname): raise FlowError(f"Container {cname} not running") if command: - argv = [rt, "exec"] - if os.isatty(0): - argv.extend(["-it"]) - argv.append(cname) - argv.extend(command) - result = self.runner.run(argv, capture_output=False) - raise SystemExit(result.returncode) + rc = self.rt.exec_in(cname, command, interactive=os.isatty(0)) + raise SystemExit(rc) for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]): - argv = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell] - result = self.runner.run(argv, capture_output=False) - if result.returncode not in (126, 127): - raise SystemExit(result.returncode) + rc = self.rt.exec_in( + cname, shell, interactive=True, detach_keys="ctrl-q,ctrl-p", + ) + if rc not in (126, 127): + raise SystemExit(rc) raise FlowError(f"Unable to start an interactive shell in {cname}") def connect(self, name: str) -> None: """Attach to the container tmux session.""" - rt = runtime() cname = container_name(name) - if not self._container_exists(rt, cname): + if not self.rt.container_exists(cname): raise FlowError(f"Container does not exist: {cname}") - if not self._container_running(rt, cname): - self.runner.run([rt, "start", cname], capture_output=True, check=True) + if not self.rt.container_running(cname): + self.rt.start(cname) if not shutil.which("tmux"): self.ctx.console.warn("tmux not found; falling back to direct exec") self.exec(name) return - inspect = self.runner.run( - [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], - check=True, - ) - image_ref = parse_image_ref(inspect.stdout.strip()) + image_str = self.rt.inspect(cname, "{{ .Config.Image }}") + image_ref = parse_image_ref(image_str) - has_session = self.runner.run(["tmux", "has-session", "-t", cname], check=False) - if has_session.returncode != 0: - self.runner.run( - [ - "tmux", - "new-session", - "-ds", - cname, - "-e", - f"DF_IMAGE={image_ref.label}", - f"flow dev exec {name}", - ], - capture_output=True, - check=True, - ) - self.runner.run( - ["tmux", "set-option", "-t", cname, "default-command", f"flow dev exec {name}"], - capture_output=True, - check=True, + if not self.tmux.has_session(cname): + self.tmux.new_session( + cname, + detached=True, + env={"DF_IMAGE": image_ref.label}, + command=f"flow dev exec {name}", ) + self.tmux.set_option(cname, "default-command", f"flow dev exec {name}") - if os.environ.get("TMUX"): - os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) - os.execvp("tmux", ["tmux", "attach", "-t", cname]) + self.tmux.attach_or_switch(cname) def stop(self, name: str, *, kill: bool = False) -> None: """Stop a running container.""" - rt = runtime() cname = container_name(name) - if not self._container_exists(rt, cname): + if not self.rt.container_exists(cname): raise FlowError(f"Container {cname} does not exist") - argv = [rt, "kill" if kill else "stop", cname] - self.runner.run(argv, capture_output=False, check=True) + if kill: + self.rt.kill(cname) + else: + self.rt.stop(cname) self.ctx.console.success(f"Container {cname} stopped.") def remove(self, name: str, *, force: bool = False) -> None: """Remove a container.""" - rt = runtime() cname = container_name(name) - if not self._container_exists(rt, cname): + if not self.rt.container_exists(cname): raise FlowError(f"Container {cname} does not exist") - argv = [rt, "rm"] - if force: - argv.append("-f") - argv.append(cname) - self.runner.run(argv, capture_output=False, check=True) + self.rt.rm(cname, force=force) self.ctx.console.success(f"Container {cname} removed.") def respawn(self, name: str) -> None: @@ -180,63 +146,27 @@ class ContainerService: raise FlowError("tmux is required for respawn but was not found") cname = container_name(name) - panes = self.runner.run( - [ - "tmux", - "list-panes", - "-t", - cname, - "-s", - "-F", - "#{session_name}:#{window_index}.#{pane_index}", - ], - check=True, - ) - for pane in panes.stdout.strip().splitlines(): - if not pane: - continue + for pane in self.tmux.list_panes(cname): self.ctx.console.info(f"Respawning {pane}...") - self.runner.run(["tmux", "respawn-pane", "-t", pane], capture_output=False, check=True) + self.tmux.respawn_pane(pane) def list(self) -> None: """List flow-managed containers.""" - rt = runtime() - result = self.runner.run( - [ - rt, - "ps", - "-a", - "--filter", - "label=dev=true", - "--format", - '{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}', - ], - check=True, + result = self.rt.ps( + all=True, + filter="label=dev=true", + format='{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}', ) - if not result.stdout.strip(): + if not result: self.ctx.console.info("No flow containers found.") return rows = [] home = str(paths.HOME) - for line in result.stdout.strip().splitlines(): + for line in result.splitlines(): name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4] if project.startswith(home): project = "~" + project[len(home):] rows.append([name, image, project or "-", status]) self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows) - - def _container_exists(self, rt: str, name: str) -> bool: - result = self.runner.run( - [rt, "container", "ls", "-a", "--format", "{{.Names}}"], - capture_output=True, - ) - return name in result.stdout.strip().splitlines() - - def _container_running(self, rt: str, name: str) -> bool: - result = self.runner.run( - [rt, "container", "ls", "--format", "{{.Names}}"], - capture_output=True, - ) - return name in result.stdout.strip().splitlines() diff --git a/src/flow/services/dotfiles.py b/src/flow/services/dotfiles.py index 42ea489..b542659 100644 --- a/src/flow/services/dotfiles.py +++ b/src/flow/services/dotfiles.py @@ -2,32 +2,50 @@ from __future__ import annotations +import os from pathlib import Path -from typing import Any, Optional - -import yaml +from typing import Optional from flow.core.config import FlowContext -from flow.core.errors import FlowError, PlanConflict +from flow.core.errors import FlowError +from flow.core.yaml import load_yaml_file from flow.core import paths from flow.domain.dotfiles.models import ( LinkedState, + LinkPlan, LinkTarget, ModuleRef, Package, + RepoInfo, ) from flow.domain.dotfiles.modules import ( compute_mount_path, - normalize_source, parse_module_ref, ) from flow.domain.dotfiles.planning import plan_link, plan_unlink from flow.domain.dotfiles.resolution import resolve_all_targets +# Maps ref_type to its git checkout prefix. Branch and commit use the +# value directly; tags need the "tags/" prefix for detached checkout. +_REF_TYPE_PREFIXES = { + "branch": "", + "tag": "tags/", + "commit": "", +} + + +def _git_checkout_ref(module: ModuleRef) -> str: + """Resolve a ModuleRef to its git checkout ref string.""" + prefix = _REF_TYPE_PREFIXES.get(module.ref_type) + if prefix is None: + raise FlowError(f"Unknown ref_type {module.ref_type!r}") + return f"{prefix}{module.ref_value}" + + MODULE_FILE = "_module.yaml" SKIP_DIRS = {".git", ".github", "__pycache__", "flow"} -SKIP_FILES = {".DS_Store", ".gitkeep"} +SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"} class DotfilesService: @@ -36,6 +54,8 @@ class DotfilesService: self.dotfiles_dir = paths.DOTFILES_DIR self.modules_dir = paths.MODULES_DIR + # ── Linking ────────────────────────────────────────────────────────── + def link( self, *, @@ -43,7 +63,8 @@ class DotfilesService: dry_run: bool = False, skip: Optional[set[str]] = None, ) -> None: - """Link dotfiles to home directory.""" + """Reconcile dotfile symlinks. Creates, updates, removes stale, and + repairs broken symlinks in a single pass.""" skip_set = skip or set() packages = self._discover_packages(profile) @@ -51,13 +72,8 @@ class DotfilesService: self.ctx.console.info("No packages found.") return - # Resolve all targets targets = resolve_all_targets(packages, paths.HOME, skip_set) - - # Load current state current = self._load_state() - - # Build plan plan = plan_link(targets, current, self._filesystem_check) if plan.conflicts: @@ -76,15 +92,17 @@ class DotfilesService: if dry_run: return - self._save_backup(current) new_state = self._apply_plan(plan, targets, current) - self._save_state(new_state) - self.ctx.console.success( - f"Linked: {plan.summary.added} added, " - f"{plan.summary.removed} removed, " - f"{plan.summary.unchanged} unchanged" - ) + parts = [] + if plan.summary.added: + parts.append(f"{plan.summary.added} added") + if plan.summary.updated: + parts.append(f"{plan.summary.updated} updated") + if plan.summary.removed: + parts.append(f"{plan.summary.removed} removed") + parts.append(f"{plan.summary.unchanged} unchanged") + self.ctx.console.success(f"Linked: {', '.join(parts)}") def unlink( self, @@ -109,7 +127,6 @@ class DotfilesService: if dry_run: return - self._save_backup(current) new_state = LinkedState(links=dict(current.links)) for op in plan.operations: self.ctx.runtime.fs.remove_file( @@ -123,35 +140,94 @@ class DotfilesService: self._save_state(new_state) self.ctx.console.success(f"Unlinked {plan.summary.removed} file(s).") - def status(self) -> None: - """Show linked dotfiles status.""" + # ── Status ─────────────────────────────────────────────────────────── + + def status(self, package_filter: Optional[list[str]] = None) -> None: + """Show linked dotfiles status with module info and link health.""" state = self._load_state() - if not state.links: - self.ctx.console.info("No managed links.") + all_packages = self._discover_packages(profile=None, include_all_layers=True) + + if not state.links and not all_packages: + self.ctx.console.info("No managed links or packages.") return - # Group by package by_package: dict[str, list[LinkTarget]] = {} for lt in state.links.values(): by_package.setdefault(lt.package, []).append(lt) rows: list[list[str]] = [] - for pkg_id in sorted(by_package): - links = by_package[pkg_id] - rows.append([pkg_id, str(len(links)), "linked"]) + for pkg in all_packages: + if package_filter: + name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id + if pkg.package_id not in package_filter and name not in package_filter: + continue - self.ctx.console.table(["PACKAGE", "FILES", "STATUS"], rows) + links = by_package.get(pkg.package_id, []) + link_count = str(len(links)) - def edit(self, package_name: str) -> None: - """Open package directory in editor.""" - pkg_dir = self.dotfiles_dir / "_shared" / package_name - if not pkg_dir.is_dir(): - raise FlowError(f"Package not found: {package_name}") + module_col = "" + if pkg.module: + module_col = f"{pkg.module.ref_type}:{pkg.module.ref_value}" - self.ctx.console.info(f"Package directory: {pkg_dir}") + if not links: + health = "not linked" + else: + broken = sum( + 1 for lt in links + if lt.target.is_symlink() and not lt.target.exists() + ) + health = f"{broken} broken" if broken else "ok" + + rows.append([pkg.package_id, link_count, health, module_col]) + + if not rows: + self.ctx.console.info("No packages match filter.") + return + + self.ctx.console.table(["PACKAGE", "FILES", "HEALTH", "MODULE"], rows) + + # ── Edit ───────────────────────────────────────────────────────────── + + def edit(self, package_name: str, *, no_commit: bool = False) -> None: + """Pull repo -> open $EDITOR -> commit+push if changed.""" + pkg, repo = self._find_package_repo(package_name) + + # Pull before editing (if configured) + if self.ctx.config.dotfiles_pull_before_edit and repo.path.is_dir(): + self._pull_or_clone_repo(repo) + + # Open editor + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi") + edit_dir = repo.path if repo.is_module else pkg.source_dir + result = self.ctx.runtime.runner.run( + [editor, str(edit_dir)], capture_output=False, + ) + if result.returncode != 0: + raise FlowError(f"Editor exited with code {result.returncode}") + + if no_commit: + return + + # Check for changes and auto-commit+push + status = self.ctx.runtime.git.run( + repo.path, "status", "--porcelain", check=True, + ) + if not status.stdout.strip(): + self.ctx.console.info("No changes.") + return + + self.ctx.runtime.git.run(repo.path, "add", str(edit_dir), check=True) + self.ctx.runtime.git.run( + repo.path, "commit", "-m", f"dotfiles: update {package_name}", + check=True, + ) + self.ctx.runtime.git.run(repo.path, "push", check=True) + self.ctx.console.success(f"Changes to {package_name} committed and pushed.") + + # ── Init ───────────────────────────────────────────────────────────── def init(self, repo_url: Optional[str] = None) -> None: - """Clone the dotfiles repository.""" + """Clone the dotfiles repository and all module repos.""" remote = repo_url or self.ctx.config.dotfiles_url if not remote: raise FlowError("No dotfiles URL configured") @@ -172,185 +248,176 @@ class DotfilesService: str(self.dotfiles_dir), check=True, ) - self.sync_modules() + self.repos_pull() self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}") - def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None: - """Pull latest dotfiles and sync modules.""" - if not self.dotfiles_dir.is_dir(): - self.init() - else: - self.ctx.console.info("Pulling latest dotfiles...") - self.ctx.runtime.git.run( - self.dotfiles_dir, "pull", "--ff-only", check=True, - ) + # ── Repos (unified: dotfiles + modules) ────────────────────────────── - self.sync_modules(profile=profile) - if relink: - self.relink(profile=profile) - - def list_modules(self, *, profile: Optional[str] = None) -> None: - """List detected module packages.""" - packages = self._discover_packages( - profile=profile, - include_all_layers=profile is None, - ) - module_packages = [pkg for pkg in packages if pkg.module is not None] - if not module_packages: - self.ctx.console.info("No module packages found.") + def repos_list(self) -> None: + """List all managed repos (dotfiles + modules).""" + repos = self._discover_repos() + if not repos: + self.ctx.console.info("No managed repos.") return rows = [] - for pkg in module_packages: - assert pkg.module is not None - status = "ready" if pkg.module.cache_dir.exists() else "missing" - rows.append([ - pkg.package_id, - f"{pkg.module.ref_type}:{pkg.module.ref_value}", - pkg.module.source, - status, - ]) - self.ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows) + for repo in repos: + status = "cloned" if repo.path.is_dir() else "missing" + kind = "module" if repo.is_module else "dotfiles" + rows.append([repo.name, kind, str(repo.path), status]) - def sync_modules(self, *, profile: Optional[str] = None) -> None: - """Clone or update module repositories.""" - packages = self._discover_packages( - profile=profile, - include_all_layers=profile is None, - ) - for pkg in packages: - if pkg.module: - self._sync_module(pkg) + self.ctx.console.table(["NAME", "TYPE", "PATH", "STATUS"], rows) - def repo_status(self) -> None: - """Show git status for the dotfiles repository.""" - if not self.dotfiles_dir.is_dir(): - raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") - result = self.ctx.runtime.git.run( - self.dotfiles_dir, - "status", - "--short", - "--branch", - check=True, - ) - output = result.stdout.strip() - if output: - print(output) - return - self.ctx.console.info("Dotfiles repository is clean.") - - def repo_pull( - self, - *, - profile: Optional[str] = None, - relink: bool = False, - rebase: bool = True, - ) -> None: - """Pull the dotfiles repository and refresh modules.""" - if not self.dotfiles_dir.is_dir(): - raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") - argv = ["pull"] - argv.append("--rebase" if rebase else "--ff-only") - self.ctx.runtime.git.run(self.dotfiles_dir, *argv, check=True) - self.sync_modules(profile=profile) - if relink: - self.relink(profile=profile) - - def repo_push(self) -> None: - """Push the dotfiles repository.""" - if not self.dotfiles_dir.is_dir(): - raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") - self.ctx.runtime.git.run(self.dotfiles_dir, "push", check=True) - self.ctx.console.success("Dotfiles pushed.") - - def relink(self, *, profile: Optional[str] = None) -> None: - """Refresh symlinks for the selected profile.""" - self.link(profile=profile) - - def clean(self, *, dry_run: bool = False) -> None: - """Remove broken symlinks from managed state.""" - current = self._load_state() - broken = [ - target for target in sorted(current.links) - if target.is_symlink() and not target.exists() - ] - if not broken: - self.ctx.console.info("No broken symlinks found.") - return - - if dry_run: - for target in broken: - self.ctx.console.info(f"Would remove broken symlink: {target}") - return - - self._save_backup(current) - for target in broken: - link = current.links[target] - self.ctx.runtime.fs.remove_file( - target, - sudo=link.needs_sudo, - runner=self.ctx.runtime.runner if link.needs_sudo else None, - missing_ok=True, + def repos_status(self, repo_filter: Optional[str] = None) -> None: + """Show git status for managed repos.""" + for repo in self._filter_repos(repo_filter): + if not repo.path.is_dir(): + self.ctx.console.warn(f"{repo.name}: not cloned") + continue + self.ctx.console.info(f"[{repo.name}]") + result = self.ctx.runtime.git.run( + repo.path, "status", "--short", "--branch", check=True, ) - current.links.pop(target, None) - self._save_state(current) - self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).") + output = result.stdout.strip() + if output: + self.ctx.console.info(output) + else: + self.ctx.console.info(" clean") - def undo(self) -> None: - """Restore the previous linked state.""" - previous = self._load_backup() - if previous is None: - self.ctx.console.info("No dotfiles link transaction to undo.") - return + def repos_pull( + self, + repo_filter: Optional[str] = None, + *, + dry_run: bool = False, + ) -> None: + """Pull (or clone) managed repos.""" + for repo in self._filter_repos(repo_filter): + if dry_run: + action = "pull" if repo.path.is_dir() else "clone" + self.ctx.console.info(f"Would {action}: {repo.name} ({repo.source})") + continue + self._pull_or_clone_repo(repo) - current = self._load_state() - desired = list(previous.links.values()) - plan = plan_link(desired, current, self._filesystem_check) - if not plan.operations: - self.ctx.console.info("Nothing to undo.") - return + def repos_push( + self, + repo_filter: Optional[str] = None, + *, + dry_run: bool = False, + ) -> None: + """Push managed repos.""" + for repo in self._filter_repos(repo_filter): + if not repo.path.is_dir(): + self.ctx.console.warn(f"{repo.name}: not cloned, skipping") + continue + if dry_run: + self.ctx.console.info(f"Would push: {repo.name}") + continue + self.ctx.runtime.git.run(repo.path, "push", check=True) + self.ctx.console.success(f"Pushed: {repo.name}") - self.ctx.console.print_plan(plan.operations, verb="undo") - self._save_backup(current) - restored = self._apply_plan(plan, desired, current) - self._save_state(restored) - self.ctx.console.success("Dotfiles state restored.") + # ── Repo discovery ─────────────────────────────────────────────────── - def _sync_module(self, pkg: Package) -> None: - """Clone or update a module.""" - module = pkg.module - assert module is not None - cache_dir = module.cache_dir + def _discover_repos(self) -> list[RepoInfo]: + """Return all managed repos: dotfiles repo + module repos.""" + repos: list[RepoInfo] = [] - if cache_dir.is_dir(): - self.ctx.console.info(f" Updating module: {pkg.package_id}") - self.ctx.runtime.git.run(cache_dir, "fetch", "--all", check=True) - if module.ref_type == "branch": + remote = self.ctx.config.dotfiles_url + if remote or self.dotfiles_dir.is_dir(): + repos.append(RepoInfo( + name="dotfiles", + path=self.dotfiles_dir, + source=remote or "", + is_module=False, + )) + + packages = self._discover_packages(profile=None, include_all_layers=True) + seen: set[str] = set() + for pkg in packages: + if pkg.module and pkg.module.source not in seen: + seen.add(pkg.module.source) + repos.append(RepoInfo( + name=pkg.name, + path=pkg.module.cache_dir, + source=pkg.module.source, + is_module=True, + module_ref=pkg.module, + )) + + return repos + + def _filter_repos(self, repo_filter: Optional[str]) -> list[RepoInfo]: + """Filter repos by name. Returns all if filter is None.""" + repos = self._discover_repos() + if repo_filter is None: + return repos + matched = [r for r in repos if r.name == repo_filter] + if not matched: + raise FlowError(f"No repo named {repo_filter!r}. " + f"Available: {', '.join(r.name for r in repos)}") + return matched + + def _pull_or_clone_repo(self, repo: RepoInfo) -> None: + """Pull an existing repo or clone it if missing.""" + if repo.path.is_dir(): + self.ctx.console.info(f"Pulling: {repo.name}") + if repo.is_module: + self._pull_module_repo(repo) + else: self.ctx.runtime.git.run( - cache_dir, "checkout", module.ref_value, check=True, - ) - self.ctx.runtime.git.run( - cache_dir, "pull", "--ff-only", check=True, - ) - elif module.ref_type == "tag": - self.ctx.runtime.git.run( - cache_dir, "checkout", f"tags/{module.ref_value}", check=True, - ) - elif module.ref_type == "commit": - self.ctx.runtime.git.run( - cache_dir, "checkout", module.ref_value, check=True, + repo.path, "pull", "--ff-only", check=True, ) else: - self.ctx.console.info(f" Cloning module: {pkg.package_id}") + if not repo.source: + raise FlowError(f"No source URL for repo {repo.name!r}") + self.ctx.console.info(f"Cloning: {repo.name}") self.ctx.runtime.runner.run( - ["git", "clone", module.source, str(cache_dir)], + ["git", "clone", repo.source, str(repo.path)], check=True, ) - if module.ref_type != "branch" or module.ref_value != "main": - ref = module.ref_value - if module.ref_type == "tag": - ref = f"tags/{ref}" - self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True) + if repo.is_module: + self._checkout_module_ref(repo) + + def _pull_module_repo(self, repo: RepoInfo) -> None: + """Pull a module repo, respecting its ref type.""" + module = repo.module_ref + if module is None: + self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True) + return + + self.ctx.runtime.git.run(repo.path, "fetch", "--all", check=True) + ref = _git_checkout_ref(module) + self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True) + if module.ref_type == "branch": + self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True) + + def _checkout_module_ref(self, repo: RepoInfo) -> None: + """Checkout the correct ref after cloning a module repo.""" + module = repo.module_ref + if module is None: + return + if module.ref_type == "branch" and module.ref_value == "main": + return + ref = _git_checkout_ref(module) + self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True) + + def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]: + """Find a package and its owning repo.""" + repos = self._discover_repos() + repo_by_name = {r.name: r for r in repos} + dotfiles_repo = repo_by_name.get("dotfiles") + + packages = self._discover_packages(profile=None, include_all_layers=True) + for pkg in packages: + name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id + if name == package_name or pkg.package_id == package_name: + if pkg.module and pkg.name in repo_by_name: + return pkg, repo_by_name[pkg.name] + if dotfiles_repo is not None: + return pkg, dotfiles_repo + raise FlowError(f"No repo found for package: {package_name}") + raise FlowError(f"Package not found: {package_name}") + + # ── Package discovery ──────────────────────────────────────────────── def _discover_packages( self, @@ -388,11 +455,7 @@ class DotfilesService: continue package_id = f"{layer}/{pkg_dir.name}" - - # Find _module.yaml (if any) module_ref = self._find_module(pkg_dir, package_id) - - # Collect local files local_files = self._collect_files(pkg_dir) packages.append(Package( @@ -407,20 +470,13 @@ class DotfilesService: return packages def _find_module(self, pkg_dir: Path, package_id: str) -> Optional[ModuleRef]: - """Find and parse _module.yaml in a package directory.""" - for module_yaml in pkg_dir.rglob(MODULE_FILE): - try: - with open(module_yaml, encoding="utf-8") as f: - raw = yaml.safe_load(f) or {} - except (OSError, yaml.YAMLError) as e: - from flow.core.errors import ConfigError - raise ConfigError(f"Failed to read {module_yaml}: {e}") from e + """Find and parse _module.yaml in a package directory (first match wins).""" + for module_yaml in sorted(pkg_dir.rglob(MODULE_FILE)): + raw = load_yaml_file(module_yaml) mount_path = compute_mount_path(module_yaml, pkg_dir) - ref = parse_module_ref(raw, package_id, mount_path, self.modules_dir) - # If module is cloned, populate module_files if ref.cache_dir.is_dir(): module_files = self._collect_files(ref.cache_dir) ref = ModuleRef( @@ -443,31 +499,27 @@ class DotfilesService: continue if path.name in SKIP_FILES: continue - # Skip .git contents - try: - path.relative_to(root_dir / ".git") - continue - except ValueError: - pass rel = path.relative_to(root_dir) + if any(part in SKIP_DIRS for part in rel.parts): + continue files.append((path, rel)) return files def _filesystem_check(self, path: Path) -> Optional[str]: """Check what exists at a path. Returns type or None.""" if path.is_symlink(): - return "symlink" + return "broken_symlink" if not path.exists() else "symlink" if path.is_file(): return "file" if path.is_dir(): return "dir" return None + # ── State persistence ──────────────────────────────────────────────── + def _load_state(self) -> LinkedState: """Load linked state from disk.""" data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={}) - if data is None: - data = {} state = LinkedState.from_dict(data) reconciled = LinkedState( links={ @@ -484,21 +536,9 @@ class DotfilesService: """Save linked state to disk.""" self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict()) - def _save_backup(self, state: LinkedState) -> None: - self.ctx.runtime.fs.write_json(self._backup_path(), state.as_dict()) - - def _load_backup(self) -> Optional[LinkedState]: - data = self.ctx.runtime.fs.read_json(self._backup_path(), default=None) - if data is None: - return None - return LinkedState.from_dict(data) - - def _backup_path(self) -> Path: - return paths.LINKED_STATE.with_name("linked.previous.json") - def _apply_plan( self, - plan, + plan: LinkPlan, targets: list[LinkTarget], current: LinkedState, ) -> LinkedState: diff --git a/src/flow/services/packages.py b/src/flow/services/packages.py index 43394e6..6c87cb0 100644 --- a/src/flow/services/packages.py +++ b/src/flow/services/packages.py @@ -7,7 +7,7 @@ import shutil import tempfile import urllib.request from pathlib import Path -from typing import Optional +from typing import Any, Optional from flow.core.config import FlowContext from flow.core.errors import FlowError @@ -18,7 +18,6 @@ from flow.domain.packages.models import ( InstalledPackage, InstalledState, PackageDef, - PackagePlan, ) from flow.domain.packages.planning import plan_install, plan_remove from flow.domain.packages.resolution import ( @@ -27,10 +26,7 @@ from flow.domain.packages.resolution import ( pm_cask_install_command, pm_install_command, pm_update_command, - resolve_binary_asset, resolve_extract_dir, - resolve_download_url, - resolve_source_name, resolve_spec, ) @@ -286,14 +282,12 @@ class PackageService: def _load_state(self) -> InstalledState: data = self.ctx.runtime.fs.read_json(paths.INSTALLED_STATE, default={}) - if data is None: - data = {} return InstalledState.from_dict(data) def _save_state(self, state: InstalledState) -> None: self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict()) - def _binary_context(self, pkg: PackageDef) -> dict[str, str]: + def _binary_context(self, pkg: PackageDef) -> dict[str, Any]: return { "env": dict(os.environ), "name": pkg.name, diff --git a/src/flow/services/projects.py b/src/flow/services/projects.py index f50d86c..271e6a4 100644 --- a/src/flow/services/projects.py +++ b/src/flow/services/projects.py @@ -3,10 +3,8 @@ from __future__ import annotations from pathlib import Path -from typing import Optional from flow.core.config import FlowContext -from flow.core.errors import FlowError class ProjectService: diff --git a/src/flow/services/remote.py b/src/flow/services/remote.py index 1795cca..b51674f 100644 --- a/src/flow/services/remote.py +++ b/src/flow/services/remote.py @@ -7,8 +7,8 @@ import os from typing import Optional from flow.core.config import FlowContext -from flow.core.errors import FlowError from flow.domain.remote.resolution import ( + build_destination, build_ssh_command, list_targets, resolve_target, @@ -78,10 +78,7 @@ class RemoteService: self.ctx.config.targets, default_user=os.environ.get("USER") or getpass.getuser(), ) - destination = f"{target.user}@{target.host}" if target.user else target.host + destination = build_destination(target.user, target.host) cmd = terminfo_fix_command(os.environ.get("TERM"), destination) - if cmd is None: - self.ctx.console.info("No terminfo workaround needed for the current TERM.") - return self.ctx.console.info("Run this command to fix terminfo:") self.ctx.console.info(f" {cmd}") diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..2091bc5 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,36 @@ +"""Shared test fixtures.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from flow.core.runtime import CommandRunner + + +class FakeRunner(CommandRunner): + """CommandRunner that captures calls instead of executing. + + Response matching uses keyword containment: a response keyed by + ``("ps", "{{.Names}}")`` matches any command whose argv contains + both ``"ps"`` and ``"{{.Names}}"``. + """ + + def __init__(self, responses: dict[tuple[str, ...], Any] | None = None): + self.calls: list[list[str]] = [] + self.timeouts: list[float | None] = [] + self._responses: dict[tuple[str, ...], Any] = responses or {} + + def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): + parts = list(argv) + self.calls.append(parts) + self.timeouts.append(timeout) + for key, resp in self._responses.items(): + if all(k in parts for k in key): + return resp + return subprocess.CompletedProcess(parts, 0, stdout="", stderr="") + + def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): + self.calls.append(["__shell__", command]) + self.timeouts.append(timeout) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") diff --git a/tests/test_cli.py b/tests/test_cli.py index 03d6ad5..e60e4ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,6 +27,15 @@ def test_help_flag(): assert "setup" in result.stdout +def test_no_color_flag(): + """Test --no-color flag is accepted.""" + result = subprocess.run( + [sys.executable, "-m", "flow", "--no-color", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_dotfiles_help(): result = subprocess.run( [sys.executable, "-m", "flow", "dotfiles", "--help"], @@ -35,6 +44,12 @@ def test_dotfiles_help(): assert result.returncode == 0 assert "link" in result.stdout assert "unlink" in result.stdout + # Removed commands should not appear + assert "relink" not in result.stdout + assert "undo" not in result.stdout + assert "clean" not in result.stdout + assert "sync" not in result.stdout + assert "modules" not in result.stdout def test_packages_help(): diff --git a/tests/test_completion.py b/tests/test_completion.py index aed1d05..648b428 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1,5 +1,7 @@ """Tests for zsh completion.""" +import subprocess + from flow.commands.completion import complete @@ -23,6 +25,33 @@ def test_complete_dotfiles_subcommands(): assert "link" in result assert "unlink" in result assert "status" in result + assert "edit" in result + assert "repos" in result + # Removed commands should not appear + assert "sync" not in result + assert "relink" not in result + assert "undo" not in result + assert "clean" not in result + assert "modules" not in result + + +def test_complete_dotfiles_repos_subcommands(): + result = complete(["flow", "dotfiles", "repos", ""], 3) + assert "list" in result + assert "status" in result + assert "pull" in result + assert "push" in result + + +def test_complete_dotfiles_repos_pull_flags(): + result = complete(["flow", "dotfiles", "repos", "pull", "--"], 4) + assert "--repo" in result + assert "--dry-run" in result + + +def test_complete_dotfiles_edit_packages(): + result = complete(["flow", "dotfiles", "edit", "--"], 3) + assert "--no-commit" in result def test_complete_dotfiles_link_flags(): @@ -41,3 +70,17 @@ def test_complete_packages_subcommands(): assert "install" in result assert "remove" in result assert "list" in result + + +def test_complete_dev_attach_returns_empty_on_timeout(monkeypatch): + class FakeRuntime: + def __init__(self, runner, *, mode="auto"): + self.runner = runner + + def ps(self, **kwargs): + assert kwargs["timeout"] == 1.0 + raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"]) + + monkeypatch.setattr("flow.commands.completion.ContainerRuntime", FakeRuntime) + result = complete(["flow", "dev", "attach", ""], 3) + assert result == [] diff --git a/tests/test_core_config.py b/tests/test_core_config.py index f9b8114..1153cfc 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -12,6 +12,7 @@ def test_load_config_missing_path(tmp_path): assert isinstance(cfg, AppConfig) assert cfg.dotfiles_url == "" assert cfg.container_registry == "registry.tomastm.com" + assert cfg.container_runtime == "auto" def test_load_config_from_yaml(tmp_path): @@ -131,3 +132,12 @@ def test_load_manifest_merges_local_and_overlay(tmp_path): data = load_manifest(local, overlay) assert "profiles" in data assert "packages" in data + + +def test_load_config_container_runtime(tmp_path): + (tmp_path / "config.yaml").write_text( + "defaults:\n" + " container-runtime: podman-rootful\n" + ) + cfg = load_config(tmp_path) + assert cfg.container_runtime == "podman-rootful" diff --git a/tests/test_core_config_parse.py b/tests/test_core_config_parse.py new file mode 100644 index 0000000..3eb54cd --- /dev/null +++ b/tests/test_core_config_parse.py @@ -0,0 +1,71 @@ +"""Tests for flow.core.config_parse.""" + +import pytest + +from flow.core.config import TargetConfig +from flow.core.config_parse import as_bool, parse_target_shorthand, parse_targets +from flow.core.errors import ConfigError + + +class TestAsBool: + @pytest.mark.parametrize("value", [True, "true", "True", "YES", "1", "on", "y"]) + def test_truthy(self, value): + assert as_bool(value) is True + + @pytest.mark.parametrize("value", [False, "false", "False", "NO", "0", "off", "n"]) + def test_falsy(self, value): + assert as_bool(value) is False + + def test_invalid_raises(self): + with pytest.raises(ConfigError, match="Expected boolean"): + as_bool("maybe") + + +class TestParseTargetShorthand: + def test_at_key(self): + t = parse_target_shorthand("personal@orb", "personal.orb") + assert t == TargetConfig(namespace="personal", platform="orb", host="personal.orb") + + def test_at_key_with_identity(self): + t = parse_target_shorthand("work@ec2", "work.ec2 ~/.ssh/id_work") + assert t.identity == "~/.ssh/id_work" + + def test_plain_key(self): + t = parse_target_shorthand("personal", "orb personal.orb ~/.ssh/id") + assert t == TargetConfig(namespace="personal", platform="orb", host="personal.orb", identity="~/.ssh/id") + + def test_empty_value_raises(self): + with pytest.raises(ConfigError, match="must define a host"): + parse_target_shorthand("x@y", "") + + def test_plain_key_too_few_parts_raises(self): + with pytest.raises(ConfigError, match="expected 'platform host"): + parse_target_shorthand("personal", "onlyone") + + +class TestParseTargets: + def test_none_returns_empty(self): + assert parse_targets(None) == [] + + def test_dict_shorthand(self): + targets = parse_targets({"personal@orb": "personal.orb"}) + assert len(targets) == 1 + assert targets[0].host == "personal.orb" + + def test_dict_mapping(self): + targets = parse_targets({ + "work@ec2": {"host": "work.ec2", "identity": "~/.ssh/id"}, + }) + assert targets[0].host == "work.ec2" + assert targets[0].identity == "~/.ssh/id" + + def test_list_format(self): + targets = parse_targets([ + {"namespace": "a", "platform": "b", "host": "a.b"}, + ]) + assert len(targets) == 1 + assert targets[0].namespace == "a" + + def test_invalid_type_raises(self): + with pytest.raises(ConfigError, match="mapping or list"): + parse_targets("invalid") diff --git a/tests/test_core_containers.py b/tests/test_core_containers.py new file mode 100644 index 0000000..9b068ac --- /dev/null +++ b/tests/test_core_containers.py @@ -0,0 +1,271 @@ +"""Tests for flow.core.containers.""" + +import subprocess +from pathlib import Path + +import pytest + +from flow.core.containers import ContainerRuntime +from flow.core.errors import FlowError + +from tests.fakes import FakeRunner + + +class TestBinaryDetection: + def test_explicit_binary(self): + rt = ContainerRuntime(FakeRunner(), binary="podman") + assert rt.binary == "podman" + + def test_no_runtime_raises(self, monkeypatch): + monkeypatch.setattr("shutil.which", lambda _: None) + rt = ContainerRuntime(FakeRunner()) + with pytest.raises(FlowError, match="No container runtime"): + _ = rt.binary + + def test_invalid_mode_raises(self): + with pytest.raises(FlowError, match="Unknown container runtime mode"): + ContainerRuntime(FakeRunner(), mode="nope") + + +class TestMode: + def test_mode_docker_forces_binary(self): + rt = ContainerRuntime(FakeRunner(), mode="docker") + assert rt.binary == "docker" + + def test_mode_podman_forces_binary(self): + rt = ContainerRuntime(FakeRunner(), mode="podman") + assert rt.binary == "podman" + + def test_mode_podman_rootful_forces_binary(self): + rt = ContainerRuntime(FakeRunner(), mode="podman-rootful") + assert rt.binary == "podman" + + def test_mode_auto_detects(self, monkeypatch): + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/podman" if name == "podman" else None) + rt = ContainerRuntime(FakeRunner(), mode="auto") + assert rt.binary == "podman" + + def test_podman_rootful_prefers_rootful_socket(self, tmp_path, monkeypatch): + rootless = tmp_path / "rootless.sock" + rootful = tmp_path / "rootful.sock" + compat = tmp_path / "compat.sock" + rootless.write_text("") + rootful.write_text("") + compat.write_text("") + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [rootful, rootless, compat], + ) + rt = ContainerRuntime(FakeRunner(), mode="podman-rootful", binary="podman") + assert rt.socket_path == rootful + + def test_podman_rootless_prefers_rootless_socket(self, tmp_path, monkeypatch): + rootless = tmp_path / "rootless.sock" + rootful = tmp_path / "rootful.sock" + rootless.write_text("") + rootful.write_text("") + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [rootless, rootful], + ) + rt = ContainerRuntime(FakeRunner(), mode="podman", binary="podman") + assert rt.socket_path == rootless + + +class TestSocketPath: + def test_docker_socket(self, tmp_path, monkeypatch): + sock = tmp_path / "docker.sock" + sock.write_text("") + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [sock], + ) + rt = ContainerRuntime(FakeRunner(), binary="docker") + assert rt.socket_path == sock + + def test_docker_socket_missing(self, monkeypatch): + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [Path("/nonexistent/docker.sock")], + ) + rt = ContainerRuntime(FakeRunner(), binary="docker") + assert rt.socket_path is None + + def test_podman_rootless_preferred(self, tmp_path, monkeypatch): + rootless = tmp_path / "rootless.sock" + rootful = tmp_path / "rootful.sock" + rootless.write_text("") + rootful.write_text("") + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [rootless, rootful], + ) + rt = ContainerRuntime(FakeRunner(), binary="podman") + assert rt.socket_path == rootless + + def test_podman_falls_back_to_rootful(self, tmp_path, monkeypatch): + rootful = tmp_path / "rootful.sock" + rootful.write_text("") + monkeypatch.setattr( + "flow.core.containers.ContainerRuntime._socket_candidates", + lambda self: [Path("/nonexistent"), rootful], + ) + rt = ContainerRuntime(FakeRunner(), binary="podman") + assert rt.socket_path == rootful + + def test_podman_uses_xdg_runtime_dir(self, monkeypatch): + monkeypatch.setenv("XDG_RUNTIME_DIR", "/custom/run") + rt = ContainerRuntime(FakeRunner(), binary="podman") + candidates = rt._socket_candidates() + assert candidates[0] == Path("/custom/run/podman/podman.sock") + + +class TestSocketSecurityOpts: + def test_podman_returns_label_disable(self): + rt = ContainerRuntime(FakeRunner(), binary="podman") + assert rt.socket_security_opts == ["label=disable"] + + def test_docker_returns_empty(self): + rt = ContainerRuntime(FakeRunner(), binary="docker") + assert rt.socket_security_opts == [] + + +class TestRunContainer: + def test_basic(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.run_container( + "dev-api", + "reg/img:latest", + labels={"dev": "true"}, + mounts=["/src:/dst"], + command=["sleep", "infinity"], + detach=True, + ) + call = runner.calls[-1] + assert call[0] == "docker" + assert call[1] == "run" + assert "-d" in call + assert "--name" in call + idx = call.index("--name") + assert call[idx + 1] == "dev-api" + assert "-v" in call + assert "/src:/dst" in call + assert call[-2:] == ["sleep", "infinity"] + + def test_with_security_opts(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="podman") + rt.run_container( + "dev-api", + "reg/img:latest", + security_opts=["label=disable"], + detach=True, + ) + call = runner.calls[-1] + idx = call.index("--security-opt") + assert call[idx + 1] == "label=disable" + + +class TestExecIn: + def test_interactive(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rc = rt.exec_in("dev-api", ["zsh", "-l"], interactive=True, detach_keys="ctrl-q,ctrl-p") + assert rc == 0 + call = runner.calls[-1] + assert "-it" in call + assert "--detach-keys" in call + assert "ctrl-q,ctrl-p" in call + assert call[-2:] == ["zsh", "-l"] + + +class TestLifecycle: + def test_start(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.start("dev-api") + assert runner.calls[-1] == ["docker", "start", "dev-api"] + + def test_stop(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.stop("dev-api") + assert runner.calls[-1] == ["docker", "stop", "dev-api"] + + def test_kill(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.kill("dev-api") + assert runner.calls[-1] == ["docker", "kill", "dev-api"] + + def test_rm(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.rm("dev-api") + assert runner.calls[-1] == ["docker", "rm", "dev-api"] + + def test_rm_force(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + rt.rm("dev-api", force=True) + assert runner.calls[-1] == ["docker", "rm", "-f", "dev-api"] + + +class TestInspect: + def test_returns_stdout(self): + runner = FakeRunner({ + ("inspect",): subprocess.CompletedProcess([], 0, stdout="reg/img:latest\n"), + }) + rt = ContainerRuntime(runner, binary="docker") + result = rt.inspect("dev-api", "{{ .Config.Image }}") + assert result == "reg/img:latest" + + +class TestPs: + def test_all_with_filter(self): + runner = FakeRunner({ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + rt = ContainerRuntime(runner, binary="docker") + output = rt.ps(all=True, filter="label=dev=true", format="{{.Names}}") + assert output == "dev-api" + call = runner.calls[-1] + assert "-a" in call + assert "--filter" in call + + def test_forwards_timeout(self): + runner = FakeRunner({ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + rt = ContainerRuntime(runner, binary="docker") + rt.ps(format="{{.Names}}", timeout=1.0) + assert runner.timeouts[-1] == 1.0 + + +class TestContainerExists: + def test_exists(self): + runner = FakeRunner({ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + rt = ContainerRuntime(runner, binary="docker") + assert rt.container_exists("dev-api") is True + + def test_not_exists(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + assert rt.container_exists("dev-missing") is False + + +class TestContainerRunning: + def test_running(self): + runner = FakeRunner({ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + rt = ContainerRuntime(runner, binary="docker") + assert rt.container_running("dev-api") is True + + def test_not_running(self): + runner = FakeRunner() + rt = ContainerRuntime(runner, binary="docker") + assert rt.container_running("dev-api") is False diff --git a/tests/test_core_runtime.py b/tests/test_core_runtime.py index ca6bfa2..9462216 100644 --- a/tests/test_core_runtime.py +++ b/tests/test_core_runtime.py @@ -2,7 +2,9 @@ from pathlib import Path +from flow.core.containers import ContainerRuntime from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime +from flow.core.tmux import TmuxClient class TestFileSystem: @@ -93,3 +95,13 @@ class TestSystemRuntime: rt = SystemRuntime() assert isinstance(rt.git, GitClient) assert rt.git.runner is rt.runner + + def test_creates_tmux_client(self): + rt = SystemRuntime() + assert isinstance(rt.tmux, TmuxClient) + assert rt.tmux.runner is rt.runner + + def test_creates_container_runtime(self): + rt = SystemRuntime() + assert isinstance(rt.containers, ContainerRuntime) + assert rt.containers.runner is rt.runner diff --git a/tests/test_core_tmux.py b/tests/test_core_tmux.py new file mode 100644 index 0000000..0038651 --- /dev/null +++ b/tests/test_core_tmux.py @@ -0,0 +1,97 @@ +"""Tests for flow.core.tmux.""" + +import subprocess + +from flow.core.tmux import TmuxClient, build_new_session_argv + +from tests.fakes import FakeRunner + + +class TestTmuxClient: + def test_has_session_true(self): + runner = FakeRunner({ + ("tmux", "has-session", "-t"): subprocess.CompletedProcess([], 0), + }) + tmux = TmuxClient(runner) + assert tmux.has_session("dev-api") is True + assert runner.calls[-1] == ["tmux", "has-session", "-t", "dev-api"] + + def test_has_session_false(self): + runner = FakeRunner({ + ("tmux", "has-session", "-t"): subprocess.CompletedProcess([], 1), + }) + tmux = TmuxClient(runner) + assert tmux.has_session("dev-api") is False + + def test_new_session_detached_with_env_and_command(self): + runner = FakeRunner() + tmux = TmuxClient(runner) + tmux.new_session( + "dev-api", + detached=True, + env={"DF_IMAGE": "reg/img"}, + command="flow dev exec api", + ) + call = runner.calls[-1] + assert call[:4] == ["tmux", "new-session", "-ds", "dev-api"] + assert "-e" in call + assert "DF_IMAGE=reg/img" in call + assert call[-1] == "flow dev exec api" + + def test_new_session_minimal(self): + runner = FakeRunner() + tmux = TmuxClient(runner) + tmux.new_session("sess") + assert runner.calls[-1] == ["tmux", "new-session", "-s", "sess"] + + def test_set_option(self): + runner = FakeRunner() + tmux = TmuxClient(runner) + tmux.set_option("dev-api", "default-command", "flow dev exec api") + assert runner.calls[-1] == [ + "tmux", "set-option", "-t", "dev-api", "default-command", "flow dev exec api", + ] + + def test_list_panes(self): + runner = FakeRunner({ + ("tmux", "list-panes", "-t"): subprocess.CompletedProcess( + [], 0, stdout="dev-api:0.0\ndev-api:0.1\n", + ), + }) + tmux = TmuxClient(runner) + panes = tmux.list_panes("dev-api") + assert panes == ["dev-api:0.0", "dev-api:0.1"] + + def test_list_panes_empty(self): + runner = FakeRunner({ + ("tmux", "list-panes", "-t"): subprocess.CompletedProcess([], 0, stdout=""), + }) + tmux = TmuxClient(runner) + assert tmux.list_panes("dev-api") == [] + + def test_respawn_pane(self): + runner = FakeRunner() + tmux = TmuxClient(runner) + tmux.respawn_pane("dev-api:0.0") + assert runner.calls[-1] == ["tmux", "respawn-pane", "-t", "dev-api:0.0"] + + +class TestBuildNewSessionArgv: + def test_basic(self): + argv = build_new_session_argv("default") + assert argv == ["tmux", "new-session", "-As", "default"] + + def test_with_env(self): + argv = build_new_session_argv( + "main", + env={"DF_NAMESPACE": "personal", "DF_PLATFORM": "orb"}, + ) + assert argv == [ + "tmux", "new-session", "-As", "main", + "-e", "DF_NAMESPACE=personal", + "-e", "DF_PLATFORM=orb", + ] + + def test_empty_env(self): + argv = build_new_session_argv("sess", env={}) + assert argv == ["tmux", "new-session", "-As", "sess"] diff --git a/tests/test_core_yaml.py b/tests/test_core_yaml.py new file mode 100644 index 0000000..bf8909a --- /dev/null +++ b/tests/test_core_yaml.py @@ -0,0 +1,113 @@ +"""Tests for flow.core.yaml.""" + +from pathlib import Path + +import pytest + +from flow.core.errors import ConfigError +from flow.core.yaml import ( + list_yaml_files, + load_yaml_documents, + load_yaml_file, + load_yaml_source, + load_yaml_sources, + merge_yaml_values, +) + + +class TestLoadYamlFile: + def test_loads_mapping(self, tmp_path): + f = tmp_path / "a.yaml" + f.write_text("key: value\n") + assert load_yaml_file(f) == {"key": "value"} + + def test_empty_file_returns_empty_dict(self, tmp_path): + f = tmp_path / "empty.yaml" + f.write_text("") + assert load_yaml_file(f) == {} + + def test_non_mapping_raises(self, tmp_path): + f = tmp_path / "list.yaml" + f.write_text("- one\n- two\n") + with pytest.raises(ConfigError, match="mapping at root"): + load_yaml_file(f) + + def test_invalid_yaml_raises(self, tmp_path): + f = tmp_path / "bad.yaml" + f.write_text(":\n :\n [invalid") + with pytest.raises(ConfigError, match="Invalid YAML"): + load_yaml_file(f) + + +class TestMergeYamlValues: + def test_dict_merge(self): + base = {"a": 1, "b": {"x": 10}} + overlay = {"b": {"y": 20}, "c": 3} + result = merge_yaml_values(base, overlay) + assert result == {"a": 1, "b": {"x": 10, "y": 20}, "c": 3} + + def test_list_concat(self): + assert merge_yaml_values([1, 2], [3, 4]) == [1, 2, 3, 4] + + def test_scalar_override(self): + assert merge_yaml_values("old", "new") == "new" + + def test_overlay_wins_type_mismatch(self): + assert merge_yaml_values({"a": 1}, "scalar") == "scalar" + + +class TestListYamlFiles: + def test_lists_sorted(self, tmp_path): + (tmp_path / "b.yaml").write_text("b: 1\n") + (tmp_path / "a.yml").write_text("a: 1\n") + (tmp_path / "c.txt").write_text("ignored") + files = list_yaml_files(tmp_path) + assert [f.name for f in files] == ["a.yml", "b.yaml"] + + def test_missing_dir_returns_empty(self, tmp_path): + assert list_yaml_files(tmp_path / "nope") == [] + + +class TestLoadYamlSource: + def test_file(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text("key: val\n") + assert load_yaml_source(f) == {"key": "val"} + + def test_directory_merges(self, tmp_path): + (tmp_path / "01.yaml").write_text("a: 1\n") + (tmp_path / "02.yaml").write_text("b: 2\n") + result = load_yaml_source(tmp_path) + assert result == {"a": 1, "b": 2} + + def test_missing_returns_empty(self, tmp_path): + assert load_yaml_source(tmp_path / "gone") == {} + + +class TestLoadYamlDocuments: + def test_single_file(self, tmp_path): + f = tmp_path / "doc.yaml" + f.write_text("x: 1\n") + docs = load_yaml_documents(f) + assert docs == [{"x": 1}] + + def test_directory(self, tmp_path): + (tmp_path / "a.yaml").write_text("a: 1\n") + (tmp_path / "b.yaml").write_text("b: 2\n") + docs = load_yaml_documents(tmp_path) + assert docs == [{"a": 1}, {"b": 2}] + + def test_missing_returns_empty(self, tmp_path): + assert load_yaml_documents(tmp_path / "gone") == [] + + +class TestLoadYamlSources: + def test_merges_multiple_paths(self, tmp_path): + d1 = tmp_path / "d1" + d2 = tmp_path / "d2" + d1.mkdir() + d2.mkdir() + (d1 / "a.yaml").write_text("a: 1\n") + (d2 / "b.yaml").write_text("b: 2\n") + result = load_yaml_sources(d1, d2) + assert result == {"a": 1, "b": 2} diff --git a/tests/test_domain_bootstrap_planning.py b/tests/test_domain_bootstrap_planning.py index d934d9b..59278a0 100644 --- a/tests/test_domain_bootstrap_planning.py +++ b/tests/test_domain_bootstrap_planning.py @@ -33,13 +33,13 @@ class TestParseProfile: profile = parse_profile("test", raw) assert len(profile.ssh_keys) == 1 - def test_ssh_keygen_alias(self): - raw = {"ssh-keygen": [{"filename": "id_work", "type": "ed25519"}]} + def test_ssh_keys_with_filename(self): + raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]} profile = parse_profile("test", raw) assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work" - def test_requires_alias(self): - profile = parse_profile("test", {"requires": ["USER_EMAIL"]}) + def test_env_required(self): + profile = parse_profile("test", {"env-required": ["USER_EMAIL"]}) assert profile.env_required == ("USER_EMAIL",) def test_post_link_and_dotfiles_profile(self): diff --git a/tests/test_domain_containers.py b/tests/test_domain_containers.py index cb29b42..2e56a8e 100644 --- a/tests/test_domain_containers.py +++ b/tests/test_domain_containers.py @@ -52,6 +52,18 @@ class TestResolveMounts: mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles) assert any(m.target.endswith("/flow/dotfiles") for m in mounts) + def test_socket_path_mount(self, tmp_path): + sock = tmp_path / "docker.sock" + sock.write_text("") + mounts = resolve_mounts(tmp_path, socket_path=sock) + socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"] + assert len(socket_mounts) == 1 + assert socket_mounts[0].source == sock + + def test_no_socket_path(self, tmp_path): + mounts = resolve_mounts(tmp_path) + assert not any(m.target == "/var/run/docker.sock" for m in mounts) + class TestBuildContainerSpec: def test_basic(self): @@ -68,10 +80,12 @@ class TestBuildContainerSpec: class TestMount: - def test_to_flag(self): + def test_fields(self): m = Mount(source=Path("/src"), target="/dst") - assert m.to_flag() == "-v /src:/dst" + assert m.source == Path("/src") + assert m.target == "/dst" + assert m.readonly is False - def test_to_flag_readonly(self): + def test_readonly(self): m = Mount(source=Path("/src"), target="/dst", readonly=True) - assert ":ro" in m.to_flag() + assert m.readonly is True diff --git a/tests/test_domain_dotfiles_planning.py b/tests/test_domain_dotfiles_planning.py index 1341fb1..842acf4 100644 --- a/tests/test_domain_dotfiles_planning.py +++ b/tests/test_domain_dotfiles_planning.py @@ -58,6 +58,9 @@ class TestPlanLink: plan = plan_link([new], current, _fs_check_none) types = [op.type for op in plan.operations] assert types == ["remove_link", "create_link"] + assert plan.summary.updated == 1 + assert plan.summary.added == 0 + assert plan.summary.removed == 0 def test_unmanaged_file_at_target_is_conflict(self): desired = [_lt("/home/x/.zshrc")] @@ -71,6 +74,16 @@ class TestPlanLink: assert plan.summary.from_modules == 1 + def test_broken_symlink_is_repaired(self): + lt = _lt("/home/x/.zshrc") + current = LinkedState(links={Path("/home/x/.zshrc"): lt}) + plan = plan_link([lt], current, lambda p: "broken_symlink") + types = [op.type for op in plan.operations] + assert types == ["remove_link", "create_link"] + assert plan.summary.updated == 1 + assert plan.summary.unchanged == 0 + + class TestPlanUnlink: def test_unlink_all(self): lt = _lt("/home/x/.zshrc") diff --git a/tests/test_domain_packages.py b/tests/test_domain_packages.py index ea4d0f2..8f1a235 100644 --- a/tests/test_domain_packages.py +++ b/tests/test_domain_packages.py @@ -140,7 +140,7 @@ class TestResolveBinaryAsset: name="fd", type="binary", sources={}, source="github:sharkdp/fd", version="v10.2.0", - asset_pattern="fd-v10.2.0-{arch}-unknown-{os}-gnu.tar.gz", + asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz", platform_map={}, extract_dir=None, install={}, post_install=None, allow_sudo=False, diff --git a/tests/test_errors.py b/tests/test_errors.py index c142f62..4961828 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,6 +1,6 @@ """Tests for flow.core.errors.""" -from flow.core.errors import ConfigError, ExecutionError, FlowError, PlanConflict +from flow.core.errors import ConfigError, FlowError, PlanConflict def test_flow_error_is_exception(): @@ -15,7 +15,3 @@ def test_plan_conflict_carries_conflicts(): err = PlanConflict("2 conflicts", ["a exists", "b exists"]) assert str(err) == "2 conflicts" assert err.conflicts == ["a exists", "b exists"] - - -def test_execution_error_is_flow_error(): - assert issubclass(ExecutionError, FlowError) diff --git a/tests/test_service_containers.py b/tests/test_service_containers.py index 2ec34d3..0eea04a 100644 --- a/tests/test_service_containers.py +++ b/tests/test_service_containers.py @@ -4,35 +4,20 @@ import subprocess from flow.core.config import AppConfig, FlowContext from flow.core.console import Console +from flow.core.containers import ContainerRuntime from flow.core.platform import PlatformInfo -from flow.core.runtime import CommandRunner, SystemRuntime +from flow.core.runtime import SystemRuntime from flow.core import paths from flow.services.containers import ContainerService - -class FakeRunner(CommandRunner): - """CommandRunner that captures calls instead of executing.""" - def __init__(self): - self.calls: list[tuple] = [] - - def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): - self.calls.append(("run", list(argv))) - command = list(argv) - if command[:4] == ["docker", "container", "ls", "-a"]: - return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="") - if command[:3] == ["docker", "container", "ls"]: - return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="") - return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - - def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): - self.calls.append(("run_shell", command)) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") +from tests.fakes import FakeRunner def _make_ctx(tmp_path, runner=None): rt = SystemRuntime() if runner: rt.runner = runner + rt.containers = ContainerRuntime(runner, binary="docker") return FlowContext( config=AppConfig(), manifest={}, @@ -46,34 +31,37 @@ class TestContainerService: def test_create_dry_run(self, tmp_path, capsys, monkeypatch): monkeypatch.setattr(paths, "HOME", tmp_path) monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles") - monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") - ctx = _make_ctx(tmp_path) + runner = FakeRunner(responses={ + ("ps", "{{.Names}}"): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) + ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) svc.create("api", "tm0/node", dry_run=True) output = capsys.readouterr().out assert "dev-api" in output + assert runner.calls == [] - def test_list_no_containers(self, tmp_path, capsys, monkeypatch): + def test_list_no_containers(self, tmp_path, capsys): runner = FakeRunner() - monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") - runner.run = lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout="", stderr="") ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) svc.list() output = capsys.readouterr().out assert "No flow containers" in output - def test_stop_calls_docker(self, tmp_path, monkeypatch): - runner = FakeRunner() - monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") + def test_stop_calls_docker(self, tmp_path): + runner = FakeRunner(responses={ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) svc.stop("api") assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls) - def test_remove_calls_docker(self, tmp_path, monkeypatch): - runner = FakeRunner() - monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") + def test_remove_calls_docker(self, tmp_path): + runner = FakeRunner(responses={ + ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"), + }) ctx = _make_ctx(tmp_path, runner=runner) svc = ContainerService(ctx) svc.remove("api") diff --git a/tests/test_service_dotfiles.py b/tests/test_service_dotfiles.py index fdd8038..c35d50a 100644 --- a/tests/test_service_dotfiles.py +++ b/tests/test_service_dotfiles.py @@ -1,6 +1,5 @@ """Tests for DotfilesService.""" -import subprocess from pathlib import Path import yaml @@ -8,19 +7,10 @@ import yaml from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.platform import PlatformInfo -from flow.core.runtime import CommandRunner, SystemRuntime +from flow.core.runtime import SystemRuntime from flow.core import paths from flow.services.dotfiles import DotfilesService - - -class FakeRunner(CommandRunner): - def __init__(self): - self.calls: list[list[str]] = [] - - def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): - command = [str(part) for part in argv] - self.calls.append(command) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") +from tests.fakes import FakeRunner def _make_ctx(tmp_path, console=None): @@ -206,7 +196,69 @@ class TestDotfilesServiceLink: assert target.read_text() == "user managed file" assert not target.is_symlink() - def test_sync_modules_includes_profile_layers(self, tmp_path, monkeypatch): + def test_status_shows_module_info(self, tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + + dotfiles = tmp_path / "dotfiles" + modules = tmp_path / "modules" + + # Set up package with _module.yaml + pkg_dir = dotfiles / "_shared" / "nvim" + config_dir = pkg_dir / ".config" / "nvim" + config_dir.mkdir(parents=True) + (config_dir / "_module.yaml").write_text(yaml.dump({ + "source": "github:test/nvim-config", + "ref": {"branch": "main"}, + })) + + # Set up cloned module + module_dir = modules / "_shared--nvim" + module_dir.mkdir(parents=True) + (module_dir / "init.lua").write_text("-- init") + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", modules) + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + svc.link() + svc.status() + output = capsys.readouterr().out + assert "nvim" in output + assert "branch:main" in output + + def test_repos_list_shows_dotfiles_and_modules(self, tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + + dotfiles = tmp_path / "dotfiles" + modules = tmp_path / "modules" + + pkg_dir = dotfiles / "_shared" / "nvim" + config_dir = pkg_dir / ".config" / "nvim" + config_dir.mkdir(parents=True) + (config_dir / "_module.yaml").write_text(yaml.dump({ + "source": "github:test/nvim-config", + "ref": {"branch": "main"}, + })) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", modules) + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + svc.repos_list() + output = capsys.readouterr().out + assert "dotfiles" in output + assert "nvim" in output + assert "module" in output + + def test_repos_pull_includes_profile_module_repos(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" @@ -234,5 +286,150 @@ class TestDotfilesServiceLink: runtime=runtime, ) - DotfilesService(ctx).sync_modules() + DotfilesService(ctx).repos_pull() assert any("linux-work--nvim" in " ".join(call) for call in runner.calls) + + def test_repos_status_shows_repo_names(self, tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + # Make dotfiles dir look like a git repo for status + (dotfiles / ".git").mkdir() + + runtime = SystemRuntime() + runner = FakeRunner() + runtime.runner = runner + runtime.git.runner = runner + ctx = FlowContext( + config=AppConfig(), + manifest={}, + platform=PlatformInfo(), + console=Console(color=False), + runtime=runtime, + ) + + DotfilesService(ctx).repos_status() + output = capsys.readouterr().out + assert "dotfiles" in output + + def test_repos_push_calls_git_push(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + runtime = SystemRuntime() + runner = FakeRunner() + runtime.runner = runner + runtime.git.runner = runner + ctx = FlowContext( + config=AppConfig(), + manifest={}, + platform=PlatformInfo(), + console=Console(color=False), + runtime=runtime, + ) + + DotfilesService(ctx).repos_push() + assert any("push" in " ".join(call) for call in runner.calls) + + def test_repos_pull_dry_run_no_calls(self, tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + runtime = SystemRuntime() + runner = FakeRunner() + runtime.runner = runner + runtime.git.runner = runner + ctx = FlowContext( + config=AppConfig(), + manifest={}, + platform=PlatformInfo(), + console=Console(color=False), + runtime=runtime, + ) + + DotfilesService(ctx).repos_pull(dry_run=True) + output = capsys.readouterr().out + assert "Would" in output + # No git calls should be made in dry run + assert not runner.calls + + def test_status_filter_by_package(self, tmp_path, monkeypatch, capsys): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh"}, + "git": {".gitconfig": "[user]"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + svc.link() + capsys.readouterr() # discard link output + svc.status(package_filter=["zsh"]) + output = capsys.readouterr().out + assert "zsh" in output + # Only zsh should appear, not git + assert "_shared/git" not in output + + def test_link_repairs_broken_symlinks(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + + dotfiles = _setup_dotfiles(tmp_path, { + "zsh": {".zshrc": "# zsh config"}, + }) + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + + # Link normally + svc.link() + assert (home / ".zshrc").is_symlink() + + # Break the symlink by removing its target + real_target = (home / ".zshrc").resolve() + (home / ".zshrc").unlink() + (home / ".zshrc").symlink_to("/nonexistent/path") + + # Re-link should repair the broken symlink + svc.link() + assert (home / ".zshrc").is_symlink() + assert (home / ".zshrc").resolve() == real_target.resolve() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..69d2aed --- /dev/null +++ b/uv.lock @@ -0,0 +1,393 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flow" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.optional-dependencies] +build = [ + { name = "pyinstaller" }, +] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, +] +provides-extras = ["build", "dev"] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +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 = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/63/fd62472b6371d89dc138d40c36d87a50dc2de18a035803bbdc376b4ffac4/pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865", size = 4036072, upload-time = "2026-02-14T18:06:28.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/eb/23374721fecfa72677e79800921cb6aceefa6ba48574dc404f3f6c6c3be7/pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134", size = 1040563, upload-time = "2026-02-14T18:05:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7e/dfd724b0b533f5aaec0ee5df406fe2319987ed6964480a706f85478b12ea/pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11", size = 735477, upload-time = "2026-02-14T18:05:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/88/c9/ee3a4101c31f26344e66896c73c1fd6ed8282bf871473365b7f8674af406/pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf", size = 747143, upload-time = "2026-02-14T18:05:31.488Z" }, + { url = "https://files.pythonhosted.org/packages/da/0a/fc77e9f861be8cf300ac37155f59cc92aff99b29f2ddd78546f563a5b5a6/pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122", size = 744849, upload-time = "2026-02-14T18:05:35.492Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e3/6872e020ee758afe0b821663858492c10745608b07150e5e2c824a5b3e1c/pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63", size = 741590, upload-time = "2026-02-14T18:05:39.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/b8db5f1a4b0fb228175f2ea0aa33f949adcc097fbe981cc524f9faf85777/pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe", size = 741448, upload-time = "2026-02-14T18:05:45.636Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/63b0600f2694e9141b83129fbc1c488ec84d5a0770b1448ec154dcd0fee9/pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83", size = 740613, upload-time = "2026-02-14T18:05:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/01/d4/e812ad36178093a0e9fd4b8127577748dd85b0cb71de912229dca21fd741/pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6", size = 740350, upload-time = "2026-02-14T18:05:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/52/03/b2c2ee41fb8e10fd2a45d21f5ec2ef25852cfb978dbf762972eed59e3d63/pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2", size = 1324317, upload-time = "2026-02-14T18:06:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d3/6d5e62b8270e2b53a6065e281b3a7785079b00e9019c8019952828dd1669/pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33", size = 1384894, upload-time = "2026-02-14T18:06:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/458cd523308a101a22fd2742893405030cc24994cc74b1b767cecf137160/pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea", size = 1325374, upload-time = "2026-02-14T18:06:12.804Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/17/716326f6ba18d0663f7995ae369c23e50efebc22fbb054e9710a45688f61/pyinstaller_hooks_contrib-2026.3.tar.gz", hash = "sha256:800d3a198a49a6cd0de2d7fb795005fdca7a0222ed9cb47c0691abd1c27b9310", size = 172323, upload-time = "2026-03-09T22:44:06.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/19/781352446af28755f16ce52b2d97f7a6f2d7974ac34c00ca5cd8c40c9098/pyinstaller_hooks_contrib-2026.3-py3-none-any.whl", hash = "sha256:5ecd1068ad262afecadf07556279d2be52ca93a88b049fae17f1a2eb2969254a", size = 454625, upload-time = "2026-03-09T22:44:04.717Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]