This commit is contained in:
2026-05-13 23:02:47 +03:00
parent d0f8315cf1
commit 78f95bc88e
49 changed files with 2747 additions and 987 deletions

113
README.md
View File

@@ -1,66 +1,84 @@
# flow # 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 ```bash
make build make build && make install-local # installs to ~/.local/bin/flow
make install-local 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 ## Commands
```bash ```bash
# Dotfiles # Dotfiles
flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...] flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...]
flow dotfiles unlink [PACKAGES...] [--dry-run] flow dotfiles unlink [PACKAGES...] [--dry-run]
flow dotfiles status flow dotfiles status [PACKAGES...]
flow dotfiles sync 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 # Packages
flow packages install [NAMES...] [--profile NAME] [--dry-run] flow packages install [NAMES...] [--profile NAME] [--dry-run]
flow packages remove NAMES... [--dry-run] flow packages remove NAMES...
flow packages list flow packages list [--all]
# Bootstrap # Bootstrap
flow setup run PROFILE [--dry-run] flow setup run PROFILE [--dry-run] # run a full bootstrap profile
flow setup show PROFILE flow setup show PROFILE # preview profile steps
flow setup list flow setup list
# Remote targets # Remote targets
flow remote enter NAMESPACE@PLATFORM [--dry-run] flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target
flow remote list flow remote list
# Dev containers # Dev containers (docker or podman)
flow dev create IMAGE [--namespace NS] [--dry-run] flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run]
flow dev enter NAME [--shell PATH] flow dev attach NAME # tmux session into container
flow dev stop NAME flow dev exec NAME [-- CMD...] # run a command in container
flow dev remove NAME 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 flow dev list
# Projects # 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 --version
flow --quiet flow --quiet # suppress info output
flow completion 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 ## 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 ```yaml
repository: repository:
@@ -71,7 +89,9 @@ paths:
projects: ~/projects projects: ~/projects
defaults: defaults:
container-runtime: auto # auto | docker | podman | podman-rootful
container-registry: registry.example.com container-registry: registry.example.com
container-tag: latest
tmux-session: main tmux-session: main
targets: targets:
@@ -81,6 +101,17 @@ targets:
identity: ~/.ssh/id_work 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 ## Dotfiles layout
``` ```
@@ -103,7 +134,7 @@ linux-work/ # Profile-specific layer
### External modules ### 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 ```yaml
source: github:org/nvim-config source: github:org/nvim-config
@@ -111,11 +142,11 @@ ref:
branch: main 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 ## 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 ```yaml
packages: packages:
@@ -148,15 +179,21 @@ profiles:
- echo "setup complete" - 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 ## Security
- `flow` must run as a regular user (root invocation is rejected) - Rejects root invocation
- `_root/` files require sudo for linking - `_root/` dotfile paths require sudo for linking
- Package post-install hooks run without sudo by default - Package post-install hooks run without sudo by default
## Development ## Development
```bash ```bash
make deps make deps # create .venv + install deps
.venv/bin/python -m pytest tests/ -v .venv/bin/python -m pytest tests/ -v # run tests
``` ```

281
docs/refactor-plan.md Normal file
View File

@@ -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 <target> # Host only. SSH+tmux into VM.
flow remote list # List configured targets.
flow dev create <name> -i <image> # VM only. Create+start container.
flow dev attach <name> # Attach to container tmux session.
flow dev exec <name> [cmd...] # Run command in container.
flow dev enter <name> # Interactive shell in container.
flow dev list # List dev containers.
flow dev stop <name> # Stop container.
flow dev rm <name> # Remove container.
flow dev respawn <name> # Respawn tmux panes.
flow dotfiles init --repo <url> # 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 <package> # 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 <profile> # Show profile plan.
flow packages install <name...> # Install packages from manifest.
flow packages list [--all] # List packages.
flow packages remove <name...> # 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=<name>` 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)

View File

@@ -1,10 +1,10 @@
repository: repository:
dotfiles-url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo
dotfiles-branch: main branch: main
pull-before-edit: true pull-before-edit: true
paths: paths:
projects-dir: ~/projects projects: ~/projects
defaults: defaults:
container-registry: registry.example.com container-registry: registry.example.com

View File

@@ -1,7 +1,7 @@
profiles: profiles:
linux-auto: linux-auto:
os: linux os: linux
requires: [TARGET_HOSTNAME, USER_EMAIL] env-required: [TARGET_HOSTNAME, USER_EMAIL]
hostname: "{{ env.TARGET_HOSTNAME }}" hostname: "{{ env.TARGET_HOSTNAME }}"
shell: zsh shell: zsh
packages: packages:
@@ -12,11 +12,11 @@ profiles:
- ripgrep - ripgrep
- binary/neovim - binary/neovim
- name: docker - name: docker
allow_sudo: true allow-sudo: true
post-install: | post-install: |
sudo groupadd docker || true sudo groupadd docker || true
sudo usermod -aG docker $USER sudo usermod -aG docker $USER
ssh-keygen: ssh-keys:
- type: ed25519 - type: ed25519
filename: id_ed25519 filename: id_ed25519
comment: "{{ env.USER_EMAIL }}" comment: "{{ env.USER_EMAIL }}"

View File

@@ -34,10 +34,11 @@ def main(argv: Optional[list[str]] = None) -> None:
return return
try: 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() platform_info = detect_platform()
context = detect_context() context = detect_context()
cmd_name = getattr(args, "command", "") cmd_name = args.command or ""
if context == "vm" and cmd_name == "remote": if context == "vm" and cmd_name == "remote":
raise FlowError("Command 'remote' is not available inside a VM.") 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.") raise FlowError(f"Command '{cmd_name}' is not available inside a container.")
paths.ensure_dirs() paths.ensure_dirs()
config = load_config()
ctx = FlowContext( ctx = FlowContext(
config=load_config(), config=config,
manifest=load_manifest(), manifest=load_manifest(),
platform=platform_info, platform=platform_info,
console=console, console=console,
runtime=SystemRuntime(), runtime=SystemRuntime(container_mode=config.container_runtime),
) )
args.handler(ctx, args) args.handler(ctx, args)
except FlowError as e: 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("--version", action="store_true", help="Show version")
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress info output") 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") subparsers = parser.add_subparsers(dest="command")

View File

@@ -4,17 +4,20 @@ from __future__ import annotations
import argparse import argparse
import json import json
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Sequence from typing import Sequence
from flow.core.config import load_config, load_manifest from flow.core.config import load_config, load_manifest
from flow.core import paths 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 from flow.domain.remote.resolution import HOST_TEMPLATES
ZSH_RC_START = "# >>> flow completion >>>" ZSH_RC_START = "# >>> flow completion >>>"
ZSH_RC_END = "# <<< flow completion <<<" ZSH_RC_END = "# <<< flow completion <<<"
CONTAINER_COMPLETION_TIMEOUT_SECONDS = 1.0
TOP_LEVEL_COMMANDS = [ TOP_LEVEL_COMMANDS = [
"enter", "enter",
@@ -56,23 +59,20 @@ def complete(words: Sequence[str], cword: int) -> list[str]:
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current) return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
command = _canonical_command(before[0]) command = _canonical_command(before[0])
completers = {
if command in {"enter", "remote"}: "enter": _complete_remote,
return _complete_remote(before, current) "remote": _complete_remote,
if command == "dev": "dev": _complete_dev,
return _complete_dev(before, current) "dotfiles": _complete_dotfiles,
if command == "dotfiles": "setup": _complete_setup,
return _complete_dotfiles(before, current) "packages": _complete_packages,
if command == "bootstrap": "projects": _complete_projects,
return _complete_bootstrap(before, current) "completion": _complete_completion,
if command == "packages": }
return _complete_packages(before, current) handler = completers.get(command)
if command == "projects": if handler is None:
return _complete_projects(before, current)
if command == "completion":
return _complete_completion(before, current)
return [] return []
return handler(before, current)
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]: 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: def _canonical_command(command: str) -> str:
aliases = { aliases = {
"dot": "dotfiles", "dot": "dotfiles",
"bootstrap": "bootstrap", "bootstrap": "setup",
"setup": "bootstrap", "setup": "setup",
"provision": "bootstrap", "provision": "setup",
"package": "packages", "package": "packages",
"pkg": "packages", "pkg": "packages",
"project": "projects", "project": "projects",
@@ -205,32 +205,18 @@ def _list_dotfiles_packages(profile: str | None = None) -> list[str]:
def _list_container_names() -> list[str]: def _list_container_names() -> list[str]:
runtime = None try:
for candidate in ("docker", "podman"): config = _config()
if shutil.which(candidate): rt = ContainerRuntime(CommandRunner(), mode=config.container_runtime)
runtime = candidate output = rt.ps(
break all=True,
if runtime is None: filter="label=dev=true",
return [] format='{{.Label "dev.name"}}',
timeout=CONTAINER_COMPLETION_TIMEOUT_SECONDS,
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: except (FlowError, subprocess.TimeoutExpired):
return [] 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: 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]: def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1: if len(before) <= 1:
return _filter( return _filter(
["init", "link", "relink", "unlink", "undo", "status", "clean", "sync", "modules", "repo", "repos", "edit"], ["init", "link", "unlink", "status", "edit", "repo", "repos"],
current, current,
) )
@@ -310,7 +296,7 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
if subcommand == "init": if subcommand == "init":
return _filter(["--repo"], current) if current.startswith("-") else [] return _filter(["--repo"], current) if current.startswith("-") else []
if subcommand in {"link", "relink"}: if subcommand == "link":
if before and before[-1] == "--profile": if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current) return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"): if current.startswith("-"):
@@ -322,43 +308,27 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
return _filter(["--dry-run"], current) return _filter(["--dry-run"], current)
return _filter(_list_dotfiles_packages(), 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": 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 subcommand in {"repo", "repos"}:
if len(before) <= 2: if len(before) <= 2:
return _filter(["status", "pull", "push"], current) return _filter(["list", "status", "pull", "push"], current)
repo_subcommand = before[2] repo_subcommand = before[2]
if repo_subcommand == "pull": if repo_subcommand in {"pull", "push"}:
if before and before[-1] == "--profile":
return _filter(_list_dotfiles_profiles(), current)
if current.startswith("-"): 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 [] 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 [] 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: if len(before) <= 1:
return _filter(["run", "show", "list"], current) return _filter(["run", "show", "list"], current)

View File

@@ -14,77 +14,54 @@ def register(subparsers):
init.add_argument("--repo", help="Override the configured repository URL") init.add_argument("--repo", help="Override the configured repository URL")
init.set_defaults(handler=_init) 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("--profile", help="Profile to include")
link.add_argument("--dry-run", "-n", action="store_true") link.add_argument("--dry-run", "-n", action="store_true")
link.add_argument("--skip", nargs="*", default=[]) link.add_argument("--skip", nargs="*", default=[])
link.set_defaults(handler=_link) 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 = sub.add_parser("unlink", help="Remove managed symlinks")
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)") unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
unlink.add_argument("--dry-run", "-n", action="store_true") unlink.add_argument("--dry-run", "-n", action="store_true")
unlink.set_defaults(handler=_unlink) unlink.set_defaults(handler=_unlink)
undo = sub.add_parser("undo", help="Restore the previous linked state") status = sub.add_parser("status", help="Show package and link status")
undo.set_defaults(handler=_undo) status.add_argument("packages", nargs="*", help="Filter by package name")
status = sub.add_parser("status", help="Show link status")
status.set_defaults(handler=_status) status.set_defaults(handler=_status)
clean = sub.add_parser("clean", help="Remove broken symlinks") edit = sub.add_parser("edit", help="Edit a package (pull -> editor -> commit+push)")
clean.add_argument("--dry-run", action="store_true") edit.add_argument("package", help="Package name")
clean.set_defaults(handler=_clean) 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") # repos subcommand group (unified: dotfiles repo + module repos)
sync.add_argument("--relink", action="store_true") repo = sub.add_parser("repos", aliases=["repo"], help="Manage dotfiles and module repos")
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")
repo_sub = repo.add_subparsers(dest="dotfiles_repo_action") 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 = 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 = repo_sub.add_parser("pull", help="Pull (or clone) repos")
repo_pull.add_argument("--rebase", dest="rebase", action="store_true") repo_pull.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false") repo_pull.add_argument("--dry-run", "-n", action="store_true")
repo_pull.add_argument("--relink", action="store_true") repo_pull.set_defaults(handler=_repos_pull)
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_push = repo_sub.add_parser("push", help="Push local changes") repo_push = repo_sub.add_parser("push", help="Push repos")
repo_push.set_defaults(handler=_repo_push) 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) repo.set_defaults(handler=_repos_list)
edit = sub.add_parser("edit", help="Show the package directory")
edit.add_argument("package", help="Package name")
edit.set_defaults(handler=_edit)
p.set_defaults(handler=_default) p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args): def _default(ctx: FlowContext, args):
_status(ctx, args) DotfilesService(ctx).status()
def _init(ctx: FlowContext, args): 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): def _unlink(ctx: FlowContext, args):
DotfilesService(ctx).unlink( DotfilesService(ctx).unlink(
packages=args.packages if args.packages else None, 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): def _status(ctx: FlowContext, args):
DotfilesService(ctx).status() DotfilesService(ctx).status(
package_filter=args.packages if args.packages else None,
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,
) )
def _repo_push(ctx: FlowContext, args):
DotfilesService(ctx).repo_push()
def _edit(ctx: FlowContext, args): 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,
)

View File

@@ -15,7 +15,7 @@ def _default(ctx: FlowContext, args):
def _check(ctx: FlowContext, args): def _check(ctx: FlowContext, args):
svc = ProjectService(ctx) svc = ProjectService(ctx)
svc.check(fetch=getattr(args, "fetch", False)) svc.check(fetch=args.fetch)
def _fetch(ctx: FlowContext, args): 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 = sub.add_parser("summary", help="Show a summary without fetching")
summary.set_defaults(handler=_summary) summary.set_defaults(handler=_summary)
parser.set_defaults(handler=_default) parser.set_defaults(handler=_default, fetch=default_fetch)

View File

@@ -6,11 +6,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import yaml
from flow.core import paths from flow.core import paths
from flow.core.console import Console from flow.core.console import Console
from flow.core.errors import ConfigError
from flow.core.platform import PlatformInfo from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime from flow.core.runtime import SystemRuntime
@@ -29,6 +26,7 @@ class AppConfig:
dotfiles_branch: str = "main" dotfiles_branch: str = "main"
dotfiles_pull_before_edit: bool = True dotfiles_pull_before_edit: bool = True
projects_dir: str = "~/projects" projects_dir: str = "~/projects"
container_runtime: str = "auto"
container_registry: str = "registry.tomastm.com" container_registry: str = "registry.tomastm.com"
container_tag: str = "latest" container_tag: str = "latest"
tmux_session: str = "default" tmux_session: str = "default"
@@ -44,182 +42,26 @@ class FlowContext:
runtime: SystemRuntime = field(default_factory=SystemRuntime) runtime: SystemRuntime = field(default_factory=SystemRuntime)
def _load_yaml_file(path: Path) -> dict[str, Any]: def _resolve_source_paths(
try: config_dir: Optional[Path],
with open(path, "r", encoding="utf-8") as handle: overlay_dir: Optional[Path],
data = yaml.safe_load(handle) ) -> tuple[Path, ...]:
except yaml.YAMLError as e: """Resolve config/overlay paths to a tuple of source directories."""
raise ConfigError(f"Invalid YAML in {path}: {e}") from e if config_dir is None and overlay_dir is None:
return (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
if data is None: if overlay_dir is None:
return {} return (config_dir,)
if config_dir is None:
if not isinstance(data, dict): return (paths.CONFIG_DIR, overlay_dir)
raise ConfigError(f"YAML file must contain a mapping at root: {path}") return (config_dir, overlay_dir)
return data
def _merge_yaml_values(base: Any, overlay: Any) -> Any: def _section_get(data: dict[str, Any], section: str, key: str, default: Any = None) -> Any:
if isinstance(base, dict) and isinstance(overlay, dict): """Get a value from a nested config section."""
merged = dict(base) s = data.get(section)
for key, value in overlay.items(): if isinstance(s, dict) and key in s:
if key in merged: return s[key]
merged[key] = _merge_yaml_values(merged[key], value) return default
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 load_config( def load_config(
@@ -227,88 +69,37 @@ def load_config(
overlay_dir: Optional[Path] = None, overlay_dir: Optional[Path] = None,
) -> AppConfig: ) -> AppConfig:
"""Load config into AppConfig.""" """Load config into AppConfig."""
if config_dir is None and overlay_dir is None: from flow.core.config_parse import as_bool, parse_targets
source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG) from flow.core.yaml import load_yaml_documents, merge_yaml_values
elif overlay_dir is None:
source_paths = (config_dir,) source_paths = _resolve_source_paths(config_dir, overlay_dir)
elif config_dir is None:
source_paths = (paths.CONFIG_DIR, overlay_dir)
else:
source_paths = (config_dir, overlay_dir)
loaded_sources = [ loaded_sources = [
source source
for path in source_paths for path in source_paths
for source in _load_yaml_documents(path) for source in load_yaml_documents(path)
] ]
data: dict[str, Any] = {} data: dict[str, Any] = {}
for source in loaded_sources: for source in loaded_sources:
data = _merge_yaml_values(data, source) data = merge_yaml_values(data, source)
repository = data.get("repository") pull_raw = _section_get(data, "repository", "pull-before-edit")
paths_section = data.get("paths")
defaults = data.get("defaults")
return AppConfig( return AppConfig(
dotfiles_url=( dotfiles_url=str(_section_get(data, "repository", "url", "")),
str(repository["url"]) dotfiles_branch=str(_section_get(data, "repository", "branch", "main")),
if isinstance(repository, dict) and "url" in repository dotfiles_pull_before_edit=as_bool(pull_raw) if pull_raw is not None else True,
else str(data["dotfiles_url"]) if "dotfiles_url" in data projects_dir=str(_section_get(data, "paths", "projects", "~/projects")),
else "" container_runtime=str(_section_get(data, "defaults", "container-runtime", "auto")),
), container_registry=str(_section_get(data, "defaults", "container-registry", "registry.tomastm.com")),
dotfiles_branch=( container_tag=str(_section_get(data, "defaults", "container-tag", "latest")),
str(repository["branch"]) tmux_session=str(_section_get(data, "defaults", "tmux-session", "default")),
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"
),
targets=[ targets=[
target target
for source in loaded_sources for source in loaded_sources
if "targets" in source if "targets" in source
for target in _parse_targets(source["targets"]) for target in parse_targets(source["targets"])
] if any("targets" in source for source in loaded_sources) else [], ],
) )
@@ -317,13 +108,6 @@ def load_manifest(
overlay_dir: Optional[Path] = None, overlay_dir: Optional[Path] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Load merged manifest YAML.""" """Load merged manifest YAML."""
if config_dir is None and overlay_dir is None: from flow.core.yaml import load_yaml_sources
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)
return _load_yaml_sources(*source_paths) return load_yaml_sources(*_resolve_source_paths(config_dir, overlay_dir))

View File

@@ -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")

176
src/flow/core/containers.py Normal file
View File

@@ -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()

View File

@@ -15,7 +15,3 @@ class PlanConflict(FlowError):
def __init__(self, message: str, conflicts: list[str]): def __init__(self, message: str, conflicts: list[str]):
super().__init__(message) super().__init__(message)
self.conflicts = conflicts self.conflicts = conflicts
class ExecutionError(FlowError):
"""A plan step failed during execution."""

View File

@@ -9,7 +9,9 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Mapping, Optional, Sequence from typing import Any, Iterable, Mapping, Optional, Sequence
from flow.core.containers import ContainerRuntime
from flow.core.errors import FlowError from flow.core.errors import FlowError
from flow.core.tmux import TmuxClient
class CommandRunner: class CommandRunner:
@@ -135,6 +137,8 @@ class FileSystem:
runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True) runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True)
return return
self.ensure_dir(target.parent) self.ensure_dir(target.parent)
if target.is_symlink() or target.exists():
target.unlink()
target.symlink_to(source) target.symlink_to(source)
def same_symlink(self, target: Path, source: Path) -> bool: def same_symlink(self, target: Path, source: Path) -> bool:
@@ -190,7 +194,12 @@ class SystemRuntime:
"""Shared runtime dependencies.""" """Shared runtime dependencies."""
runner: CommandRunner = field(default_factory=CommandRunner) runner: CommandRunner = field(default_factory=CommandRunner)
fs: FileSystem = field(default_factory=FileSystem) fs: FileSystem = field(default_factory=FileSystem)
container_mode: str = "auto"
git: GitClient = field(init=False) git: GitClient = field(init=False)
tmux: TmuxClient = field(init=False)
containers: ContainerRuntime = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.git = GitClient(self.runner) self.git = GitClient(self.runner)
self.tmux = TmuxClient(self.runner)
self.containers = ContainerRuntime(self.runner, mode=self.container_mode)

86
src/flow/core/tmux.py Normal file
View File

@@ -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

83
src/flow/core/yaml.py Normal file
View File

@@ -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

View File

@@ -1,7 +1,15 @@
"""Bootstrap domain models.""" """Bootstrap domain models."""
from dataclasses import dataclass, field from __future__ import annotations
from typing import Any, Optional
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) @dataclass(frozen=True)
@@ -15,16 +23,19 @@ class Profile:
shell: Optional[str] shell: Optional[str]
ssh_keys: tuple[dict[str, str], ...] ssh_keys: tuple[dict[str, str], ...]
runcmd: tuple[str, ...] runcmd: tuple[str, ...]
packages: tuple[Any, ...] # Raw entries, resolved later packages: tuple[ProfilePackageEntry, ...]
env_required: tuple[str, ...] env_required: tuple[str, ...]
dotfiles_profile: Optional[str] = None dotfiles_profile: Optional[str] = None
post_link: Optional[str] = None post_link: Optional[str] = None
VALID_PHASES = {"setup", "packages", "shell", "dotfiles", "post-link"}
@dataclass(frozen=True) @dataclass(frozen=True)
class BootstrapAction: class BootstrapAction:
"""A single action in a bootstrap plan.""" """A single action in a bootstrap plan."""
phase: str # "validate" | "setup" | "packages" | "shell" | "dotfiles" phase: str
description: str description: str
commands: tuple[str, ...] commands: tuple[str, ...]
needs_sudo: bool = False needs_sudo: bool = False
@@ -39,7 +50,7 @@ class BootstrapPlan:
"""Complete bootstrap plan.""" """Complete bootstrap plan."""
profile: str profile: str
actions: tuple[BootstrapAction, ...] actions: tuple[BootstrapAction, ...]
packages_to_install: tuple[Any, ...] # PackageDef tuple packages_to_install: tuple[PackageDef, ...]
@property @property
def total_steps(self) -> int: def total_steps(self) -> int:

View File

@@ -2,7 +2,6 @@
import shlex import shlex
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
class SetupModule: class SetupModule:

View File

@@ -38,13 +38,7 @@ def _normalize_ssh_keys(raw: Any) -> tuple[dict[str, str], ...]:
def parse_profile(name: str, raw: dict[str, Any]) -> Profile: def parse_profile(name: str, raw: dict[str, Any]) -> Profile:
"""Parse a profile definition from manifest.""" """Parse a profile definition from manifest."""
ssh_keys = raw.get("ssh-keys") or raw.get("ssh_keys") ssh_keys = 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")
return Profile( return Profile(
name=name, name=name,
@@ -56,9 +50,9 @@ def parse_profile(name: str, raw: dict[str, Any]) -> Profile:
ssh_keys=_normalize_ssh_keys(ssh_keys), ssh_keys=_normalize_ssh_keys(ssh_keys),
runcmd=tuple(raw.get("runcmd") or []), runcmd=tuple(raw.get("runcmd") or []),
packages=tuple(raw.get("packages") or []), packages=tuple(raw.get("packages") or []),
env_required=tuple(env_required or []), env_required=tuple(raw.get("env-required") or []),
dotfiles_profile=raw.get("dotfiles-profile") or raw.get("dotfiles_profile"), dotfiles_profile=raw.get("dotfiles-profile"),
post_link=raw.get("post-link") or raw.get("post_link"), post_link=raw.get("post-link"),
) )

View File

@@ -29,10 +29,6 @@ class Mount:
target: str target: str
readonly: bool = False 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) @dataclass(frozen=True)
class ContainerSpec: class ContainerSpec:

View File

@@ -48,6 +48,7 @@ def resolve_mounts(
*, *,
project_path: Optional[str] = None, project_path: Optional[str] = None,
dotfiles_dir: Optional[Path] = None, dotfiles_dir: Optional[Path] = None,
socket_path: Optional[Path] = None,
) -> list[Mount]: ) -> list[Mount]:
"""Resolve standard container mounts.""" """Resolve standard container mounts."""
mounts: list[Mount] = [] mounts: list[Mount] = []
@@ -65,9 +66,8 @@ def resolve_mounts(
if source.exists(): if source.exists():
mounts.append(Mount(source=source, target=target, readonly=readonly)) mounts.append(Mount(source=source, target=target, readonly=readonly))
docker_sock = Path("/var/run/docker.sock") if socket_path:
if docker_sock.exists(): mounts.append(Mount(source=socket_path, target="/var/run/docker.sock"))
mounts.append(Mount(source=docker_sock, target="/var/run/docker.sock"))
if dotfiles_dir and dotfiles_dir.exists(): if dotfiles_dir and dotfiles_dir.exists():
mounts.append( mounts.append(

View File

@@ -60,6 +60,7 @@ class LinkOp:
@dataclass(frozen=True) @dataclass(frozen=True)
class PlanSummary: class PlanSummary:
added: int added: int
updated: int
removed: int removed: int
unchanged: int unchanged: int
from_modules: int from_modules: int
@@ -96,7 +97,7 @@ class LinkedState:
from flow.core.errors import ConfigError from flow.core.errors import ConfigError
raise ConfigError( raise ConfigError(
f"Unsupported linked.json version {version}. " 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] = {} links: dict[Path, LinkTarget] = {}
raw_links = data.get("links", {}) raw_links = data.get("links", {})
@@ -119,3 +120,4 @@ class RepoInfo:
path: Path path: Path
source: str source: str
is_module: bool is_module: bool
module_ref: Optional[ModuleRef] = None

View File

@@ -19,11 +19,13 @@ def plan_link(
) -> LinkPlan: ) -> LinkPlan:
"""Build reconciliation plan. """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] = [] ops: list[LinkOp] = []
conflicts: list[str] = [] conflicts: list[str] = []
added = 0 added = 0
updated = 0
removed = 0 removed = 0
unchanged = 0 unchanged = 0
from_modules = 0 from_modules = 0
@@ -41,18 +43,37 @@ def plan_link(
)) ))
removed += 1 removed += 1
# Additions, updates, and unchanged # Additions, updates, broken symlink repair, and unchanged
for target in sorted(desired_targets): for target in sorted(desired_targets):
spec = desired_map[target] spec = desired_map[target]
if target in current.links: if target in current.links:
cur = current.links[target] 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: if cur.source == spec.source:
unchanged += 1 unchanged += 1
if spec.from_module: if spec.from_module:
from_modules += 1 from_modules += 1
continue continue
# Source changed: remove old link, then create new one
# Source changed: remove old link, create new one
ops.append(LinkOp( ops.append(LinkOp(
type="remove_link", target=target, source=cur.source, type="remove_link", target=target, source=cur.source,
package=cur.package, needs_sudo=cur.needs_sudo, package=cur.package, needs_sudo=cur.needs_sudo,
@@ -61,13 +82,27 @@ def plan_link(
type="create_link", target=target, source=spec.source, type="create_link", target=target, source=spec.source,
package=spec.package, needs_sudo=spec.needs_sudo, 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 added += 1
if spec.from_module: if spec.from_module:
from_modules += 1 from_modules += 1
continue continue
# New target: check filesystem for conflicts
fs_state = filesystem_check(target)
if fs_state is not None: if fs_state is not None:
conflicts.append( conflicts.append(
f"{target} already exists ({fs_state}) and is not managed by flow" f"{target} already exists ({fs_state}) and is not managed by flow"
@@ -86,7 +121,7 @@ def plan_link(
operations=tuple(ops), operations=tuple(ops),
conflicts=tuple(conflicts), conflicts=tuple(conflicts),
summary=PlanSummary( summary=PlanSummary(
added=added, removed=removed, added=added, updated=updated, removed=removed,
unchanged=unchanged, from_modules=from_modules, unchanged=unchanged, from_modules=from_modules,
), ),
) )
@@ -115,5 +150,5 @@ def plan_unlink(
return LinkPlan( return LinkPlan(
operations=tuple(ops), operations=tuple(ops),
conflicts=(), 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),
) )

View File

@@ -1,12 +1,13 @@
"""Path resolution: package -> home-relative LinkTargets. Pure functions.""" """Path resolution: package -> home-relative LinkTargets. Pure functions."""
from __future__ import annotations
from pathlib import Path from pathlib import Path
from flow.core.errors import PlanConflict from flow.core.errors import PlanConflict
from flow.domain.dotfiles.models import LinkTarget, Package from flow.domain.dotfiles.models import LinkTarget, Package
RESERVED_ROOT = "_root" RESERVED_ROOT = "_root"
MODULE_FILE = "_module.yaml"
def resolve_package_targets( def resolve_package_targets(
@@ -23,10 +24,6 @@ def resolve_package_targets(
# Local files (from dotfiles repo) # Local files (from dotfiles repo)
for abs_source, rel in package.local_files: 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 module exists, skip files inside mount_path (module provides those)
if mount_path is not None: if mount_path is not None:
if mount_path == Path("."): if mount_path == Path("."):
@@ -82,19 +79,20 @@ def resolve_all_targets(
"""Resolve targets for all packages. Raises PlanConflict on duplicate targets.""" """Resolve targets for all packages. Raises PlanConflict on duplicate targets."""
all_targets: list[LinkTarget] = [] all_targets: list[LinkTarget] = []
seen: dict[Path, str] = {} seen: dict[Path, str] = {}
conflicts: list[str] = []
for pkg in packages: for pkg in packages:
targets = resolve_package_targets(pkg, home, skip) targets = resolve_package_targets(pkg, home, skip)
for t in targets: for t in targets:
if t.target in seen: if t.target in seen:
conflicts = [ conflicts.append(
f"{t.target} claimed by both {seen[t.target]} and {t.package}" 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 seen[t.target] = t.package
all_targets.append(t) all_targets.append(t)
if conflicts:
raise PlanConflict("Conflicting dotfile targets across packages", conflicts)
return all_targets return all_targets

View File

@@ -1,6 +1,6 @@
"""Package catalog: parsing manifest into PackageDef objects.""" """Package catalog: parsing manifest into PackageDef objects."""
from typing import Any, Optional from typing import Any
from flow.core.errors import ConfigError from flow.core.errors import ConfigError
from flow.domain.packages.models import PackageDef, ProfilePackageRef from flow.domain.packages.models import PackageDef, ProfilePackageRef
@@ -54,12 +54,12 @@ def _parse_package_entry(entry: dict[str, Any]) -> PackageDef:
sources=sources, sources=sources,
source=entry.get("source"), source=entry.get("source"),
version=entry.get("version"), version=entry.get("version"),
asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), asset_pattern=entry.get("asset-pattern"),
platform_map=entry.get("platform-map") or entry.get("platform_map") or {}, platform_map=entry.get("platform-map") or {},
extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), extract_dir=entry.get("extract-dir"),
install=entry.get("install") or {}, install=entry.get("install") or {},
post_install=entry.get("post-install") or entry.get("post_install"), post_install=entry.get("post-install"),
allow_sudo=bool(entry.get("allow-sudo", entry.get("allow_sudo", False))), allow_sudo=bool(entry.get("allow-sudo", False)),
) )
@@ -101,12 +101,12 @@ def normalize_profile_entry(entry: Any) -> ProfilePackageRef:
type=entry.get("type"), type=entry.get("type"),
source=entry.get("source"), source=entry.get("source"),
version=entry.get("version"), version=entry.get("version"),
asset_pattern=entry.get("asset-pattern") or entry.get("asset_pattern"), asset_pattern=entry.get("asset-pattern"),
platform_map=entry.get("platform-map") or entry.get("platform_map"), platform_map=entry.get("platform-map"),
extract_dir=entry.get("extract-dir") or entry.get("extract_dir"), extract_dir=entry.get("extract-dir"),
install=entry.get("install"), install=entry.get("install"),
post_install=entry.get("post-install") or entry.get("post_install"), post_install=entry.get("post-install"),
allow_sudo=entry.get("allow-sudo", entry.get("allow_sudo")), allow_sudo=entry.get("allow-sudo"),
) )
raise ConfigError(f"Invalid profile package entry: {entry}") raise ConfigError(f"Invalid profile package entry: {entry}")

View File

@@ -1,7 +1,7 @@
"""Package resolution: resolving what to install and how.""" """Package resolution: resolving what to install and how."""
import shutil import shutil
from typing import Any, Optional from typing import Optional
from flow.core.template import substitute_template from flow.core.template import substitute_template
from flow.core.errors import FlowError 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: def _render_template_value(template: str, context: dict[str, str]) -> str:
rendered = substitute_template(template, context) return substitute_template(template, context)
for key, value in context.items():
rendered = rendered.replace(f"{{{key}}}", value)
return rendered
def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str: def resolve_binary_asset(pkg: PackageDef, platform_str: str) -> str:

View File

@@ -4,6 +4,7 @@ from typing import Optional
from flow.core.config import TargetConfig from flow.core.config import TargetConfig
from flow.core.errors import FlowError from flow.core.errors import FlowError
from flow.core.tmux import build_new_session_argv
from flow.domain.remote.models import SSHCommand, Target from flow.domain.remote.models import SSHCommand, Target
@@ -86,7 +87,7 @@ def build_ssh_command(
argv.extend(["-i", target.identity]) argv.extend(["-i", target.identity])
argv.extend(["-o", "StrictHostKeyChecking=accept-new"]) argv.extend(["-o", "StrictHostKeyChecking=accept-new"])
destination = _build_destination(target.user, target.host) destination = build_destination(target.user, target.host)
argv.append(destination) argv.append(destination)
env = { env = {
@@ -95,16 +96,10 @@ def build_ssh_command(
} }
if not no_tmux: if not no_tmux:
argv.extend([ argv.extend(build_new_session_argv(
"tmux",
"new-session",
"-As",
tmux_session, tmux_session,
"-e", env=env,
f"DF_NAMESPACE={target.namespace}", ))
"-e",
f"DF_PLATFORM={target.platform}",
])
return SSHCommand( return SSHCommand(
argv=tuple(argv), 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: if "@" in host:
return host return host
if not user: if not user:
@@ -125,7 +120,7 @@ def _build_destination(user: str, host: str) -> str:
def terminfo_fix_command( def terminfo_fix_command(
term: Optional[str] = "xterm-256color", term: Optional[str] = "xterm-256color",
destination: str = "TARGET", destination: str = "TARGET",
) -> Optional[str]: ) -> str:
normalized_term = (term or "").strip().lower() normalized_term = (term or "").strip().lower()
if normalized_term == "xterm-ghostty": if normalized_term == "xterm-ghostty":

View File

@@ -8,6 +8,7 @@ from typing import Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError 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 from flow.domain.bootstrap.planning import parse_profile, plan_bootstrap
@@ -50,27 +51,10 @@ class BootstrapService:
if dry_run: if dry_run:
return return
dotfiles_profile = profile.dotfiles_profile or profile_name
for action in plan.actions: for action in plan.actions:
self.ctx.console.info(f" {action}") self.ctx.console.info(f" {action}")
self._execute_action(action, plan, dotfiles_profile)
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.ctx.console.success(f"Bootstrap complete for {profile_name}.") self.ctx.console.success(f"Bootstrap complete for {profile_name}.")
@@ -90,3 +74,27 @@ class BootstrapService:
for name, data in sorted(profiles.items()) for name, data in sorted(profiles.items())
] ]
self.ctx.console.table(["PROFILE", "OS", "HOSTNAME"], rows) 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)

View File

@@ -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: class ContainerService:
def __init__(self, ctx: FlowContext): def __init__(self, ctx: FlowContext):
self.ctx = ctx self.ctx = ctx
self.runner = ctx.runtime.runner self.runner = ctx.runtime.runner
self.rt = ctx.runtime.containers
self.tmux = ctx.runtime.tmux
def create( def create(
self, self,
@@ -37,7 +32,6 @@ class ContainerService:
dry_run: bool = False, dry_run: bool = False,
) -> None: ) -> None:
"""Create and start a development container.""" """Create and start a development container."""
rt = runtime()
spec = build_container_spec( spec = build_container_spec(
name, name,
parse_image_ref( parse_image_ref(
@@ -49,11 +43,12 @@ class ContainerService:
paths.HOME, paths.HOME,
project_path=project_path, project_path=project_path,
dotfiles_dir=paths.DOTFILES_DIR, dotfiles_dir=paths.DOTFILES_DIR,
socket_path=self.rt.socket_path,
), ),
project_path=project_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}") raise FlowError(f"Container already exists: {spec.name}")
self.ctx.console.info(f"Creating container: {spec.name}") self.ctx.console.info(f"Creating container: {spec.name}")
@@ -62,116 +57,87 @@ class ContainerService:
if dry_run: if dry_run:
return return
cmd = [ mount_flags = [
rt, f"{m.source}:{m.target}{':ro' if m.readonly else ''}"
"run", for m in spec.mounts
"-d",
"--name",
spec.name,
"--network",
spec.network,
"--init",
] ]
for key, value in spec.labels.items(): security_opts = self.rt.socket_security_opts if self.rt.socket_path else []
cmd.extend(["--label", f"{key}={value}"]) self.rt.run_container(
for mount in spec.mounts: spec.name,
cmd.extend(["-v", f"{mount.source}:{mount.target}{':ro' if mount.readonly else ''}"]) spec.image.full,
cmd.extend([spec.image.full, "sleep", "infinity"]) network=spec.network,
labels=spec.labels,
self.runner.run(cmd, capture_output=False, check=True) mounts=mount_flags,
security_opts=security_opts,
command=["sleep", "infinity"],
detach=True,
)
self.ctx.console.success(f"Created and started container: {spec.name}") self.ctx.console.success(f"Created and started container: {spec.name}")
def exec(self, name: str, command: list[str] | None = None) -> None: def exec(self, name: str, command: list[str] | None = None) -> None:
"""Run a command or interactive shell inside a container.""" """Run a command or interactive shell inside a container."""
rt = runtime()
cname = container_name(name) 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") raise FlowError(f"Container {cname} not running")
if command: if command:
argv = [rt, "exec"] rc = self.rt.exec_in(cname, command, interactive=os.isatty(0))
if os.isatty(0): raise SystemExit(rc)
argv.extend(["-it"])
argv.append(cname)
argv.extend(command)
result = self.runner.run(argv, capture_output=False)
raise SystemExit(result.returncode)
for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]): for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]):
argv = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell] rc = self.rt.exec_in(
result = self.runner.run(argv, capture_output=False) cname, shell, interactive=True, detach_keys="ctrl-q,ctrl-p",
if result.returncode not in (126, 127): )
raise SystemExit(result.returncode) if rc not in (126, 127):
raise SystemExit(rc)
raise FlowError(f"Unable to start an interactive shell in {cname}") raise FlowError(f"Unable to start an interactive shell in {cname}")
def connect(self, name: str) -> None: def connect(self, name: str) -> None:
"""Attach to the container tmux session.""" """Attach to the container tmux session."""
rt = runtime()
cname = container_name(name) 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}") raise FlowError(f"Container does not exist: {cname}")
if not self._container_running(rt, cname): if not self.rt.container_running(cname):
self.runner.run([rt, "start", cname], capture_output=True, check=True) self.rt.start(cname)
if not shutil.which("tmux"): if not shutil.which("tmux"):
self.ctx.console.warn("tmux not found; falling back to direct exec") self.ctx.console.warn("tmux not found; falling back to direct exec")
self.exec(name) self.exec(name)
return return
inspect = self.runner.run( image_str = self.rt.inspect(cname, "{{ .Config.Image }}")
[rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], image_ref = parse_image_ref(image_str)
check=True,
)
image_ref = parse_image_ref(inspect.stdout.strip())
has_session = self.runner.run(["tmux", "has-session", "-t", cname], check=False) if not self.tmux.has_session(cname):
if has_session.returncode != 0: self.tmux.new_session(
self.runner.run(
[
"tmux",
"new-session",
"-ds",
cname, cname,
"-e", detached=True,
f"DF_IMAGE={image_ref.label}", env={"DF_IMAGE": image_ref.label},
f"flow dev exec {name}", command=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,
) )
self.tmux.set_option(cname, "default-command", f"flow dev exec {name}")
if os.environ.get("TMUX"): self.tmux.attach_or_switch(cname)
os.execvp("tmux", ["tmux", "switch-client", "-t", cname])
os.execvp("tmux", ["tmux", "attach", "-t", cname])
def stop(self, name: str, *, kill: bool = False) -> None: def stop(self, name: str, *, kill: bool = False) -> None:
"""Stop a running container.""" """Stop a running container."""
rt = runtime()
cname = container_name(name) 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") raise FlowError(f"Container {cname} does not exist")
argv = [rt, "kill" if kill else "stop", cname] if kill:
self.runner.run(argv, capture_output=False, check=True) self.rt.kill(cname)
else:
self.rt.stop(cname)
self.ctx.console.success(f"Container {cname} stopped.") self.ctx.console.success(f"Container {cname} stopped.")
def remove(self, name: str, *, force: bool = False) -> None: def remove(self, name: str, *, force: bool = False) -> None:
"""Remove a container.""" """Remove a container."""
rt = runtime()
cname = container_name(name) 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") raise FlowError(f"Container {cname} does not exist")
argv = [rt, "rm"] self.rt.rm(cname, force=force)
if force:
argv.append("-f")
argv.append(cname)
self.runner.run(argv, capture_output=False, check=True)
self.ctx.console.success(f"Container {cname} removed.") self.ctx.console.success(f"Container {cname} removed.")
def respawn(self, name: str) -> None: def respawn(self, name: str) -> None:
@@ -180,63 +146,27 @@ class ContainerService:
raise FlowError("tmux is required for respawn but was not found") raise FlowError("tmux is required for respawn but was not found")
cname = container_name(name) cname = container_name(name)
panes = self.runner.run( for pane in self.tmux.list_panes(cname):
[
"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
self.ctx.console.info(f"Respawning {pane}...") 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: def list(self) -> None:
"""List flow-managed containers.""" """List flow-managed containers."""
rt = runtime() result = self.rt.ps(
result = self.runner.run( all=True,
[ filter="label=dev=true",
rt, format='{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}',
"ps",
"-a",
"--filter",
"label=dev=true",
"--format",
'{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}',
],
check=True,
) )
if not result.stdout.strip(): if not result:
self.ctx.console.info("No flow containers found.") self.ctx.console.info("No flow containers found.")
return return
rows = [] rows = []
home = str(paths.HOME) home = str(paths.HOME)
for line in result.stdout.strip().splitlines(): for line in result.splitlines():
name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4] name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4]
if project.startswith(home): if project.startswith(home):
project = "~" + project[len(home):] project = "~" + project[len(home):]
rows.append([name, image, project or "-", status]) rows.append([name, image, project or "-", status])
self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows) 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()

View File

@@ -2,32 +2,50 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Optional
import yaml
from flow.core.config import FlowContext 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.core import paths
from flow.domain.dotfiles.models import ( from flow.domain.dotfiles.models import (
LinkedState, LinkedState,
LinkPlan,
LinkTarget, LinkTarget,
ModuleRef, ModuleRef,
Package, Package,
RepoInfo,
) )
from flow.domain.dotfiles.modules import ( from flow.domain.dotfiles.modules import (
compute_mount_path, compute_mount_path,
normalize_source,
parse_module_ref, parse_module_ref,
) )
from flow.domain.dotfiles.planning import plan_link, plan_unlink from flow.domain.dotfiles.planning import plan_link, plan_unlink
from flow.domain.dotfiles.resolution import resolve_all_targets 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" MODULE_FILE = "_module.yaml"
SKIP_DIRS = {".git", ".github", "__pycache__", "flow"} SKIP_DIRS = {".git", ".github", "__pycache__", "flow"}
SKIP_FILES = {".DS_Store", ".gitkeep"} SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"}
class DotfilesService: class DotfilesService:
@@ -36,6 +54,8 @@ class DotfilesService:
self.dotfiles_dir = paths.DOTFILES_DIR self.dotfiles_dir = paths.DOTFILES_DIR
self.modules_dir = paths.MODULES_DIR self.modules_dir = paths.MODULES_DIR
# ── Linking ──────────────────────────────────────────────────────────
def link( def link(
self, self,
*, *,
@@ -43,7 +63,8 @@ class DotfilesService:
dry_run: bool = False, dry_run: bool = False,
skip: Optional[set[str]] = None, skip: Optional[set[str]] = None,
) -> 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() skip_set = skip or set()
packages = self._discover_packages(profile) packages = self._discover_packages(profile)
@@ -51,13 +72,8 @@ class DotfilesService:
self.ctx.console.info("No packages found.") self.ctx.console.info("No packages found.")
return return
# Resolve all targets
targets = resolve_all_targets(packages, paths.HOME, skip_set) targets = resolve_all_targets(packages, paths.HOME, skip_set)
# Load current state
current = self._load_state() current = self._load_state()
# Build plan
plan = plan_link(targets, current, self._filesystem_check) plan = plan_link(targets, current, self._filesystem_check)
if plan.conflicts: if plan.conflicts:
@@ -76,15 +92,17 @@ class DotfilesService:
if dry_run: if dry_run:
return return
self._save_backup(current)
new_state = self._apply_plan(plan, targets, current) new_state = self._apply_plan(plan, targets, current)
self._save_state(new_state) self._save_state(new_state)
self.ctx.console.success( parts = []
f"Linked: {plan.summary.added} added, " if plan.summary.added:
f"{plan.summary.removed} removed, " parts.append(f"{plan.summary.added} added")
f"{plan.summary.unchanged} unchanged" 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( def unlink(
self, self,
@@ -109,7 +127,6 @@ class DotfilesService:
if dry_run: if dry_run:
return return
self._save_backup(current)
new_state = LinkedState(links=dict(current.links)) new_state = LinkedState(links=dict(current.links))
for op in plan.operations: for op in plan.operations:
self.ctx.runtime.fs.remove_file( self.ctx.runtime.fs.remove_file(
@@ -123,35 +140,94 @@ class DotfilesService:
self._save_state(new_state) self._save_state(new_state)
self.ctx.console.success(f"Unlinked {plan.summary.removed} file(s).") self.ctx.console.success(f"Unlinked {plan.summary.removed} file(s).")
def status(self) -> None: # ── Status ───────────────────────────────────────────────────────────
"""Show linked dotfiles 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() state = self._load_state()
if not state.links: all_packages = self._discover_packages(profile=None, include_all_layers=True)
self.ctx.console.info("No managed links.")
if not state.links and not all_packages:
self.ctx.console.info("No managed links or packages.")
return return
# Group by package
by_package: dict[str, list[LinkTarget]] = {} by_package: dict[str, list[LinkTarget]] = {}
for lt in state.links.values(): for lt in state.links.values():
by_package.setdefault(lt.package, []).append(lt) by_package.setdefault(lt.package, []).append(lt)
rows: list[list[str]] = [] rows: list[list[str]] = []
for pkg_id in sorted(by_package): for pkg in all_packages:
links = by_package[pkg_id] if package_filter:
rows.append([pkg_id, str(len(links)), "linked"]) 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: module_col = ""
"""Open package directory in editor.""" if pkg.module:
pkg_dir = self.dotfiles_dir / "_shared" / package_name module_col = f"{pkg.module.ref_type}:{pkg.module.ref_value}"
if not pkg_dir.is_dir():
raise FlowError(f"Package not found: {package_name}")
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: 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 remote = repo_url or self.ctx.config.dotfiles_url
if not remote: if not remote:
raise FlowError("No dotfiles URL configured") raise FlowError("No dotfiles URL configured")
@@ -172,185 +248,176 @@ class DotfilesService:
str(self.dotfiles_dir), str(self.dotfiles_dir),
check=True, check=True,
) )
self.sync_modules() self.repos_pull()
self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}") self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}")
def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None: # ── Repos (unified: dotfiles + modules) ──────────────────────────────
"""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,
)
self.sync_modules(profile=profile) def repos_list(self) -> None:
if relink: """List all managed repos (dotfiles + modules)."""
self.relink(profile=profile) repos = self._discover_repos()
if not repos:
def list_modules(self, *, profile: Optional[str] = None) -> None: self.ctx.console.info("No managed repos.")
"""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.")
return return
rows = [] rows = []
for pkg in module_packages: for repo in repos:
assert pkg.module is not None status = "cloned" if repo.path.is_dir() else "missing"
status = "ready" if pkg.module.cache_dir.exists() else "missing" kind = "module" if repo.is_module else "dotfiles"
rows.append([ rows.append([repo.name, kind, str(repo.path), status])
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)
def sync_modules(self, *, profile: Optional[str] = None) -> None: self.ctx.console.table(["NAME", "TYPE", "PATH", "STATUS"], rows)
"""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)
def repo_status(self) -> None: def repos_status(self, repo_filter: Optional[str] = None) -> None:
"""Show git status for the dotfiles repository.""" """Show git status for managed repos."""
if not self.dotfiles_dir.is_dir(): for repo in self._filter_repos(repo_filter):
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}") 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( result = self.ctx.runtime.git.run(
self.dotfiles_dir, repo.path, "status", "--short", "--branch", check=True,
"status",
"--short",
"--branch",
check=True,
) )
output = result.stdout.strip() output = result.stdout.strip()
if output: if output:
print(output) self.ctx.console.info(output)
return else:
self.ctx.console.info("Dotfiles repository is clean.") self.ctx.console.info(" clean")
def repo_pull( def repos_pull(
self, self,
repo_filter: Optional[str] = None,
*, *,
profile: Optional[str] = None, dry_run: bool = False,
relink: bool = False,
rebase: bool = True,
) -> None: ) -> None:
"""Pull the dotfiles repository and refresh modules.""" """Pull (or clone) managed repos."""
if not self.dotfiles_dir.is_dir(): for repo in self._filter_repos(repo_filter):
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: if dry_run:
for target in broken: action = "pull" if repo.path.is_dir() else "clone"
self.ctx.console.info(f"Would remove broken symlink: {target}") self.ctx.console.info(f"Would {action}: {repo.name} ({repo.source})")
return continue
self._pull_or_clone_repo(repo)
self._save_backup(current) def repos_push(
for target in broken: self,
link = current.links[target] repo_filter: Optional[str] = None,
self.ctx.runtime.fs.remove_file( *,
target, dry_run: bool = False,
sudo=link.needs_sudo, ) -> None:
runner=self.ctx.runtime.runner if link.needs_sudo else None, """Push managed repos."""
missing_ok=True, for repo in self._filter_repos(repo_filter):
) if not repo.path.is_dir():
current.links.pop(target, None) self.ctx.console.warn(f"{repo.name}: not cloned, skipping")
self._save_state(current) continue
self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).") 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}")
def undo(self) -> None: # ── Repo discovery ───────────────────────────────────────────────────
"""Restore the previous linked state."""
previous = self._load_backup()
if previous is None:
self.ctx.console.info("No dotfiles link transaction to undo.")
return
current = self._load_state() def _discover_repos(self) -> list[RepoInfo]:
desired = list(previous.links.values()) """Return all managed repos: dotfiles repo + module repos."""
plan = plan_link(desired, current, self._filesystem_check) repos: list[RepoInfo] = []
if not plan.operations:
self.ctx.console.info("Nothing to undo.")
return
self.ctx.console.print_plan(plan.operations, verb="undo") remote = self.ctx.config.dotfiles_url
self._save_backup(current) if remote or self.dotfiles_dir.is_dir():
restored = self._apply_plan(plan, desired, current) repos.append(RepoInfo(
self._save_state(restored) name="dotfiles",
self.ctx.console.success("Dotfiles state restored.") path=self.dotfiles_dir,
source=remote or "",
is_module=False,
))
def _sync_module(self, pkg: Package) -> None: packages = self._discover_packages(profile=None, include_all_layers=True)
"""Clone or update a module.""" seen: set[str] = set()
module = pkg.module for pkg in packages:
assert module is not None if pkg.module and pkg.module.source not in seen:
cache_dir = module.cache_dir 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,
))
if cache_dir.is_dir(): return repos
self.ctx.console.info(f" Updating module: {pkg.package_id}")
self.ctx.runtime.git.run(cache_dir, "fetch", "--all", check=True) def _filter_repos(self, repo_filter: Optional[str]) -> list[RepoInfo]:
if module.ref_type == "branch": """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( self.ctx.runtime.git.run(
cache_dir, "checkout", module.ref_value, check=True, repo.path, "pull", "--ff-only", 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,
) )
else: 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( self.ctx.runtime.runner.run(
["git", "clone", module.source, str(cache_dir)], ["git", "clone", repo.source, str(repo.path)],
check=True, check=True,
) )
if module.ref_type != "branch" or module.ref_value != "main": if repo.is_module:
ref = module.ref_value self._checkout_module_ref(repo)
if module.ref_type == "tag":
ref = f"tags/{ref}" def _pull_module_repo(self, repo: RepoInfo) -> None:
self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True) """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( def _discover_packages(
self, self,
@@ -388,11 +455,7 @@ class DotfilesService:
continue continue
package_id = f"{layer}/{pkg_dir.name}" package_id = f"{layer}/{pkg_dir.name}"
# Find _module.yaml (if any)
module_ref = self._find_module(pkg_dir, package_id) module_ref = self._find_module(pkg_dir, package_id)
# Collect local files
local_files = self._collect_files(pkg_dir) local_files = self._collect_files(pkg_dir)
packages.append(Package( packages.append(Package(
@@ -407,20 +470,13 @@ class DotfilesService:
return packages return packages
def _find_module(self, pkg_dir: Path, package_id: str) -> Optional[ModuleRef]: def _find_module(self, pkg_dir: Path, package_id: str) -> Optional[ModuleRef]:
"""Find and parse _module.yaml in a package directory.""" """Find and parse _module.yaml in a package directory (first match wins)."""
for module_yaml in pkg_dir.rglob(MODULE_FILE): for module_yaml in sorted(pkg_dir.rglob(MODULE_FILE)):
try: raw = load_yaml_file(module_yaml)
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
mount_path = compute_mount_path(module_yaml, pkg_dir) mount_path = compute_mount_path(module_yaml, pkg_dir)
ref = parse_module_ref(raw, package_id, mount_path, self.modules_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(): if ref.cache_dir.is_dir():
module_files = self._collect_files(ref.cache_dir) module_files = self._collect_files(ref.cache_dir)
ref = ModuleRef( ref = ModuleRef(
@@ -443,31 +499,27 @@ class DotfilesService:
continue continue
if path.name in SKIP_FILES: if path.name in SKIP_FILES:
continue continue
# Skip .git contents
try:
path.relative_to(root_dir / ".git")
continue
except ValueError:
pass
rel = path.relative_to(root_dir) rel = path.relative_to(root_dir)
if any(part in SKIP_DIRS for part in rel.parts):
continue
files.append((path, rel)) files.append((path, rel))
return files return files
def _filesystem_check(self, path: Path) -> Optional[str]: def _filesystem_check(self, path: Path) -> Optional[str]:
"""Check what exists at a path. Returns type or None.""" """Check what exists at a path. Returns type or None."""
if path.is_symlink(): if path.is_symlink():
return "symlink" return "broken_symlink" if not path.exists() else "symlink"
if path.is_file(): if path.is_file():
return "file" return "file"
if path.is_dir(): if path.is_dir():
return "dir" return "dir"
return None return None
# ── State persistence ────────────────────────────────────────────────
def _load_state(self) -> LinkedState: def _load_state(self) -> LinkedState:
"""Load linked state from disk.""" """Load linked state from disk."""
data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={}) data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={})
if data is None:
data = {}
state = LinkedState.from_dict(data) state = LinkedState.from_dict(data)
reconciled = LinkedState( reconciled = LinkedState(
links={ links={
@@ -484,21 +536,9 @@ class DotfilesService:
"""Save linked state to disk.""" """Save linked state to disk."""
self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict()) 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( def _apply_plan(
self, self,
plan, plan: LinkPlan,
targets: list[LinkTarget], targets: list[LinkTarget],
current: LinkedState, current: LinkedState,
) -> LinkedState: ) -> LinkedState:

View File

@@ -7,7 +7,7 @@ import shutil
import tempfile import tempfile
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError from flow.core.errors import FlowError
@@ -18,7 +18,6 @@ from flow.domain.packages.models import (
InstalledPackage, InstalledPackage,
InstalledState, InstalledState,
PackageDef, PackageDef,
PackagePlan,
) )
from flow.domain.packages.planning import plan_install, plan_remove from flow.domain.packages.planning import plan_install, plan_remove
from flow.domain.packages.resolution import ( from flow.domain.packages.resolution import (
@@ -27,10 +26,7 @@ from flow.domain.packages.resolution import (
pm_cask_install_command, pm_cask_install_command,
pm_install_command, pm_install_command,
pm_update_command, pm_update_command,
resolve_binary_asset,
resolve_extract_dir, resolve_extract_dir,
resolve_download_url,
resolve_source_name,
resolve_spec, resolve_spec,
) )
@@ -286,14 +282,12 @@ class PackageService:
def _load_state(self) -> InstalledState: def _load_state(self) -> InstalledState:
data = self.ctx.runtime.fs.read_json(paths.INSTALLED_STATE, default={}) data = self.ctx.runtime.fs.read_json(paths.INSTALLED_STATE, default={})
if data is None:
data = {}
return InstalledState.from_dict(data) return InstalledState.from_dict(data)
def _save_state(self, state: InstalledState) -> None: def _save_state(self, state: InstalledState) -> None:
self.ctx.runtime.fs.write_json(paths.INSTALLED_STATE, state.as_dict()) 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 { return {
"env": dict(os.environ), "env": dict(os.environ),
"name": pkg.name, "name": pkg.name,

View File

@@ -3,10 +3,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError
class ProjectService: class ProjectService:

View File

@@ -7,8 +7,8 @@ import os
from typing import Optional from typing import Optional
from flow.core.config import FlowContext from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.domain.remote.resolution import ( from flow.domain.remote.resolution import (
build_destination,
build_ssh_command, build_ssh_command,
list_targets, list_targets,
resolve_target, resolve_target,
@@ -78,10 +78,7 @@ class RemoteService:
self.ctx.config.targets, self.ctx.config.targets,
default_user=os.environ.get("USER") or getpass.getuser(), 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) 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("Run this command to fix terminfo:")
self.ctx.console.info(f" {cmd}") self.ctx.console.info(f" {cmd}")

36
tests/fakes.py Normal file
View File

@@ -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="")

View File

@@ -27,6 +27,15 @@ def test_help_flag():
assert "setup" in result.stdout 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(): def test_dotfiles_help():
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "flow", "dotfiles", "--help"], [sys.executable, "-m", "flow", "dotfiles", "--help"],
@@ -35,6 +44,12 @@ def test_dotfiles_help():
assert result.returncode == 0 assert result.returncode == 0
assert "link" in result.stdout assert "link" in result.stdout
assert "unlink" 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(): def test_packages_help():

View File

@@ -1,5 +1,7 @@
"""Tests for zsh completion.""" """Tests for zsh completion."""
import subprocess
from flow.commands.completion import complete from flow.commands.completion import complete
@@ -23,6 +25,33 @@ def test_complete_dotfiles_subcommands():
assert "link" in result assert "link" in result
assert "unlink" in result assert "unlink" in result
assert "status" 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(): def test_complete_dotfiles_link_flags():
@@ -41,3 +70,17 @@ def test_complete_packages_subcommands():
assert "install" in result assert "install" in result
assert "remove" in result assert "remove" in result
assert "list" 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 == []

View File

@@ -12,6 +12,7 @@ def test_load_config_missing_path(tmp_path):
assert isinstance(cfg, AppConfig) assert isinstance(cfg, AppConfig)
assert cfg.dotfiles_url == "" assert cfg.dotfiles_url == ""
assert cfg.container_registry == "registry.tomastm.com" assert cfg.container_registry == "registry.tomastm.com"
assert cfg.container_runtime == "auto"
def test_load_config_from_yaml(tmp_path): 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) data = load_manifest(local, overlay)
assert "profiles" in data assert "profiles" in data
assert "packages" 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"

View File

@@ -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")

View File

@@ -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

View File

@@ -2,7 +2,9 @@
from pathlib import Path from pathlib import Path
from flow.core.containers import ContainerRuntime
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
from flow.core.tmux import TmuxClient
class TestFileSystem: class TestFileSystem:
@@ -93,3 +95,13 @@ class TestSystemRuntime:
rt = SystemRuntime() rt = SystemRuntime()
assert isinstance(rt.git, GitClient) assert isinstance(rt.git, GitClient)
assert rt.git.runner is rt.runner 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

97
tests/test_core_tmux.py Normal file
View File

@@ -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"]

113
tests/test_core_yaml.py Normal file
View File

@@ -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}

View File

@@ -33,13 +33,13 @@ class TestParseProfile:
profile = parse_profile("test", raw) profile = parse_profile("test", raw)
assert len(profile.ssh_keys) == 1 assert len(profile.ssh_keys) == 1
def test_ssh_keygen_alias(self): def test_ssh_keys_with_filename(self):
raw = {"ssh-keygen": [{"filename": "id_work", "type": "ed25519"}]} raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
profile = parse_profile("test", raw) profile = parse_profile("test", raw)
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work" assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
def test_requires_alias(self): def test_env_required(self):
profile = parse_profile("test", {"requires": ["USER_EMAIL"]}) profile = parse_profile("test", {"env-required": ["USER_EMAIL"]})
assert profile.env_required == ("USER_EMAIL",) assert profile.env_required == ("USER_EMAIL",)
def test_post_link_and_dotfiles_profile(self): def test_post_link_and_dotfiles_profile(self):

View File

@@ -52,6 +52,18 @@ class TestResolveMounts:
mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles) mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
assert any(m.target.endswith("/flow/dotfiles") for m in mounts) 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: class TestBuildContainerSpec:
def test_basic(self): def test_basic(self):
@@ -68,10 +80,12 @@ class TestBuildContainerSpec:
class TestMount: class TestMount:
def test_to_flag(self): def test_fields(self):
m = Mount(source=Path("/src"), target="/dst") 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) m = Mount(source=Path("/src"), target="/dst", readonly=True)
assert ":ro" in m.to_flag() assert m.readonly is True

View File

@@ -58,6 +58,9 @@ class TestPlanLink:
plan = plan_link([new], current, _fs_check_none) plan = plan_link([new], current, _fs_check_none)
types = [op.type for op in plan.operations] types = [op.type for op in plan.operations]
assert types == ["remove_link", "create_link"] 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): def test_unmanaged_file_at_target_is_conflict(self):
desired = [_lt("/home/x/.zshrc")] desired = [_lt("/home/x/.zshrc")]
@@ -71,6 +74,16 @@ class TestPlanLink:
assert plan.summary.from_modules == 1 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: class TestPlanUnlink:
def test_unlink_all(self): def test_unlink_all(self):
lt = _lt("/home/x/.zshrc") lt = _lt("/home/x/.zshrc")

View File

@@ -140,7 +140,7 @@ class TestResolveBinaryAsset:
name="fd", type="binary", sources={}, name="fd", type="binary", sources={},
source="github:sharkdp/fd", source="github:sharkdp/fd",
version="v10.2.0", 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={}, platform_map={},
extract_dir=None, install={}, extract_dir=None, install={},
post_install=None, allow_sudo=False, post_install=None, allow_sudo=False,

View File

@@ -1,6 +1,6 @@
"""Tests for flow.core.errors.""" """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(): def test_flow_error_is_exception():
@@ -15,7 +15,3 @@ def test_plan_conflict_carries_conflicts():
err = PlanConflict("2 conflicts", ["a exists", "b exists"]) err = PlanConflict("2 conflicts", ["a exists", "b exists"])
assert str(err) == "2 conflicts" assert str(err) == "2 conflicts"
assert err.conflicts == ["a exists", "b exists"] assert err.conflicts == ["a exists", "b exists"]
def test_execution_error_is_flow_error():
assert issubclass(ExecutionError, FlowError)

View File

@@ -4,35 +4,20 @@ import subprocess
from flow.core.config import AppConfig, FlowContext from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console from flow.core.console import Console
from flow.core.containers import ContainerRuntime
from flow.core.platform import PlatformInfo 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.core import paths
from flow.services.containers import ContainerService from flow.services.containers import ContainerService
from tests.fakes import FakeRunner
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="")
def _make_ctx(tmp_path, runner=None): def _make_ctx(tmp_path, runner=None):
rt = SystemRuntime() rt = SystemRuntime()
if runner: if runner:
rt.runner = runner rt.runner = runner
rt.containers = ContainerRuntime(runner, binary="docker")
return FlowContext( return FlowContext(
config=AppConfig(), config=AppConfig(),
manifest={}, manifest={},
@@ -46,34 +31,37 @@ class TestContainerService:
def test_create_dry_run(self, tmp_path, capsys, monkeypatch): def test_create_dry_run(self, tmp_path, capsys, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path) monkeypatch.setattr(paths, "HOME", tmp_path)
monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles") monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles")
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") runner = FakeRunner(responses={
ctx = _make_ctx(tmp_path) ("ps", "{{.Names}}"): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.create("api", "tm0/node", dry_run=True) svc.create("api", "tm0/node", dry_run=True)
output = capsys.readouterr().out output = capsys.readouterr().out
assert "dev-api" in output 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() 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) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.list() svc.list()
output = capsys.readouterr().out output = capsys.readouterr().out
assert "No flow containers" in output assert "No flow containers" in output
def test_stop_calls_docker(self, tmp_path, monkeypatch): def test_stop_calls_docker(self, tmp_path):
runner = FakeRunner() runner = FakeRunner(responses={
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.stop("api") svc.stop("api")
assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls) 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): def test_remove_calls_docker(self, tmp_path):
runner = FakeRunner() runner = FakeRunner(responses={
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker") ("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner) ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx) svc = ContainerService(ctx)
svc.remove("api") svc.remove("api")

View File

@@ -1,6 +1,5 @@
"""Tests for DotfilesService.""" """Tests for DotfilesService."""
import subprocess
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -8,19 +7,10 @@ import yaml
from flow.core.config import AppConfig, FlowContext from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console from flow.core.console import Console
from flow.core.platform import PlatformInfo 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.core import paths
from flow.services.dotfiles import DotfilesService from flow.services.dotfiles import DotfilesService
from tests.fakes import FakeRunner
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="")
def _make_ctx(tmp_path, console=None): def _make_ctx(tmp_path, console=None):
@@ -206,7 +196,69 @@ class TestDotfilesServiceLink:
assert target.read_text() == "user managed file" assert target.read_text() == "user managed file"
assert not target.is_symlink() 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 = tmp_path / "home"
home.mkdir() home.mkdir()
dotfiles = tmp_path / "dotfiles" dotfiles = tmp_path / "dotfiles"
@@ -234,5 +286,150 @@ class TestDotfilesServiceLink:
runtime=runtime, runtime=runtime,
) )
DotfilesService(ctx).sync_modules() DotfilesService(ctx).repos_pull()
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls) 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()

393
uv.lock generated Normal file
View File

@@ -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" },
]