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` is a CLI for managing development environments: dotfiles, packages, containers, remote targets, and system bootstrap.
CLI for managing development environments: dotfiles, packages, dev containers, remote targets, and system bootstrap.
## Installation
## Quick start
```bash
make build
make install-local
make build && make install-local # installs to ~/.local/bin/flow
flow setup run linux-work # bootstrap a machine
flow dotfiles link # symlink dotfiles
flow dev create api -i tm0/node # spin up a dev container
flow dev attach api # attach via tmux
```
This installs `flow` to `~/.local/bin/flow`.
## Architecture
Four-layer design: **core** (runtime, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
- Domain layer is pure: no I/O, no side effects, fully testable
- Services perform all I/O and delegate logic to domain functions
- Commands are trivial dispatchers from argparse to services
## Commands
```bash
# Dotfiles
flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...]
flow dotfiles unlink [PACKAGES...] [--dry-run]
flow dotfiles status
flow dotfiles sync
flow dotfiles status [PACKAGES...]
flow dotfiles edit PACKAGE [--no-commit] # pull -> $EDITOR -> commit+push
# Dotfiles repos (unified: dotfiles repo + module repos)
flow dotfiles repos list
flow dotfiles repos status [--repo NAME]
flow dotfiles repos pull [--repo NAME] [--dry-run]
flow dotfiles repos push [--repo NAME] [--dry-run]
# Packages
flow packages install [NAMES...] [--profile NAME] [--dry-run]
flow packages remove NAMES... [--dry-run]
flow packages list
flow packages remove NAMES...
flow packages list [--all]
# Bootstrap
flow setup run PROFILE [--dry-run]
flow setup show PROFILE
flow setup run PROFILE [--dry-run] # run a full bootstrap profile
flow setup show PROFILE # preview profile steps
flow setup list
# Remote targets
flow remote enter NAMESPACE@PLATFORM [--dry-run]
flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target
flow remote list
# Dev containers
flow dev create IMAGE [--namespace NS] [--dry-run]
flow dev enter NAME [--shell PATH]
flow dev stop NAME
flow dev remove NAME
# Dev containers (docker or podman)
flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run]
flow dev attach NAME # tmux session into container
flow dev exec NAME [-- CMD...] # run a command in container
flow dev enter NAME # interactive shell
flow dev stop NAME [--kill]
flow dev remove NAME [-f]
flow dev respawn NAME # restart all tmux panes
flow dev list
# Projects
flow projects check [--fetch]
flow projects check [--fetch] # scan ~/projects for dirty repos
flow projects fetch # fetch all project remotes
flow projects summary # quick overview
# Other
# Shell completion
flow completion zsh # print zsh completion script
flow completion install-zsh # install to ~/.zsh/completions
# Global flags
flow --version
flow --quiet
flow completion
flow --quiet # suppress info output
flow --no-color # disable colored output
```
### Aliases
- `dotfiles` -> `dot`
- `dotfiles repos` -> `dotfiles repo`
- `packages` -> `package`, `pkg`
- `projects` -> `project`, `sync` (with `--fetch` default)
- `setup` -> `bootstrap`
- `dev attach` -> `dev connect`
- `dev remove` -> `dev rm`
## Configuration
Config is loaded from `~/.config/flow/config.yaml` and merged with self-hosted config from the dotfiles repo at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`.
Loaded from `~/.config/flow/config.yaml`, merged with dotfiles repo overlay at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`.
```yaml
repository:
@@ -71,7 +89,9 @@ paths:
projects: ~/projects
defaults:
container-runtime: auto # auto | docker | podman | podman-rootful
container-registry: registry.example.com
container-tag: latest
tmux-session: main
targets:
@@ -81,6 +101,17 @@ targets:
identity: ~/.ssh/id_work
```
### Container runtime
`container-runtime` controls which engine `flow dev` uses:
- `auto` -- detect docker or podman from PATH (default)
- `docker` -- force docker
- `podman` -- force podman, prefer rootless socket
- `podman-rootful` -- force podman, prefer rootful socket (`/run/podman/podman.sock`)
When using podman, `flow dev create` automatically adds `--security-opt label=disable` for engine socket access. The socket is always mounted to `/var/run/docker.sock` inside the container (Podman's Docker-compatible API).
## Dotfiles layout
```
@@ -103,7 +134,7 @@ linux-work/ # Profile-specific layer
### External modules
A `_module.yaml` inside a package mounts an external git repo at that location:
`_module.yaml` inside a package mounts an external git repo at that location:
```yaml
source: github:org/nvim-config
@@ -111,11 +142,11 @@ ref:
branch: main
```
Module files are linked from the clone cache, while local files outside the mount path are linked from the dotfiles repo.
Modules are regular git clones managed by flow (not git submodules). They appear alongside the dotfiles repo in `flow dotfiles repos list` and are pulled/pushed with the same commands.
## Manifest
Packages and profiles are defined in YAML files under the flow config directory.
Packages and profiles defined in YAML files under the flow config directory.
```yaml
packages:
@@ -148,15 +179,21 @@ profiles:
- echo "setup complete"
```
## Architecture
Four layers: **core** (runtime primitives, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`.
## Security
- `flow` must run as a regular user (root invocation is rejected)
- `_root/` files require sudo for linking
- Rejects root invocation
- `_root/` dotfile paths require sudo for linking
- Package post-install hooks run without sudo by default
## Development
```bash
make deps
.venv/bin/python -m pytest tests/ -v
make deps # create .venv + install deps
.venv/bin/python -m pytest tests/ -v # run tests
```

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:
dotfiles-url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo
dotfiles-branch: main
url: /ABSOLUTE/PATH/TO/flow-cli/example/dotfiles-repo
branch: main
pull-before-edit: true
paths:
projects-dir: ~/projects
projects: ~/projects
defaults:
container-registry: registry.example.com

View File

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

View File

@@ -34,10 +34,11 @@ def main(argv: Optional[list[str]] = None) -> None:
return
try:
console = Console(quiet=getattr(args, "quiet", False), color=None)
color = False if args.no_color else None
console = Console(quiet=args.quiet, color=color)
platform_info = detect_platform()
context = detect_context()
cmd_name = getattr(args, "command", "")
cmd_name = args.command or ""
if context == "vm" and cmd_name == "remote":
raise FlowError("Command 'remote' is not available inside a VM.")
@@ -45,12 +46,13 @@ def main(argv: Optional[list[str]] = None) -> None:
raise FlowError(f"Command '{cmd_name}' is not available inside a container.")
paths.ensure_dirs()
config = load_config()
ctx = FlowContext(
config=load_config(),
config=config,
manifest=load_manifest(),
platform=platform_info,
console=console,
runtime=SystemRuntime(),
runtime=SystemRuntime(container_mode=config.container_runtime),
)
args.handler(ctx, args)
except FlowError as e:
@@ -68,6 +70,7 @@ def _build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--version", action="store_true", help="Show version")
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress info output")
parser.add_argument("--no-color", action="store_true", help="Disable colored output")
subparsers = parser.add_subparsers(dest="command")

View File

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

View File

@@ -14,77 +14,54 @@ def register(subparsers):
init.add_argument("--repo", help="Override the configured repository URL")
init.set_defaults(handler=_init)
link = sub.add_parser("link", help="Link dotfiles to home")
link = sub.add_parser("link", help="Reconcile dotfile symlinks")
link.add_argument("--profile", help="Profile to include")
link.add_argument("--dry-run", "-n", action="store_true")
link.add_argument("--skip", nargs="*", default=[])
link.set_defaults(handler=_link)
relink = sub.add_parser("relink", help="Refresh managed symlinks")
relink.add_argument("--profile", help="Profile to include")
relink.set_defaults(handler=_relink)
unlink = sub.add_parser("unlink", help="Remove managed symlinks")
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
unlink.add_argument("--dry-run", "-n", action="store_true")
unlink.set_defaults(handler=_unlink)
undo = sub.add_parser("undo", help="Restore the previous linked state")
undo.set_defaults(handler=_undo)
status = sub.add_parser("status", help="Show link status")
status = sub.add_parser("status", help="Show package and link status")
status.add_argument("packages", nargs="*", help="Filter by package name")
status.set_defaults(handler=_status)
clean = sub.add_parser("clean", help="Remove broken symlinks")
clean.add_argument("--dry-run", action="store_true")
clean.set_defaults(handler=_clean)
edit = sub.add_parser("edit", help="Edit a package (pull -> editor -> commit+push)")
edit.add_argument("package", help="Package name")
edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit/push")
edit.set_defaults(handler=_edit)
sync = sub.add_parser("sync", help="Pull dotfiles and sync modules")
sync.add_argument("--relink", action="store_true")
sync.add_argument("--profile", help="Profile to relink after syncing")
sync.set_defaults(handler=_sync)
modules = sub.add_parser("modules", help="Inspect and refresh external modules")
modules_sub = modules.add_subparsers(dest="dotfiles_modules_action")
modules_list = modules_sub.add_parser("list", help="List module packages")
modules_list.add_argument("--profile", help="Limit to shared + one profile")
modules_list.set_defaults(handler=_modules_list)
modules_sync = modules_sub.add_parser("sync", help="Refresh module repositories")
modules_sync.add_argument("--profile", help="Limit to shared + one profile")
modules_sync.set_defaults(handler=_modules_sync)
modules.set_defaults(handler=_modules_list)
repo = sub.add_parser("repo", aliases=["repos"], help="Manage the dotfiles repository")
# repos subcommand group (unified: dotfiles repo + module repos)
repo = sub.add_parser("repos", aliases=["repo"], help="Manage dotfiles and module repos")
repo_sub = repo.add_subparsers(dest="dotfiles_repo_action")
repo_list = repo_sub.add_parser("list", help="List all managed repos")
repo_list.set_defaults(handler=_repos_list)
repo_status = repo_sub.add_parser("status", help="Show git status")
repo_status.set_defaults(handler=_repo_status)
repo_status.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_status.set_defaults(handler=_repos_status)
repo_pull = repo_sub.add_parser("pull", help="Pull latest changes")
repo_pull.add_argument("--rebase", dest="rebase", action="store_true")
repo_pull.add_argument("--no-rebase", dest="rebase", action="store_false")
repo_pull.add_argument("--relink", action="store_true")
repo_pull.add_argument("--profile", help="Profile to relink after pulling")
repo_pull.set_defaults(rebase=True)
repo_pull.set_defaults(handler=_repo_pull)
repo_pull = repo_sub.add_parser("pull", help="Pull (or clone) repos")
repo_pull.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_pull.add_argument("--dry-run", "-n", action="store_true")
repo_pull.set_defaults(handler=_repos_pull)
repo_push = repo_sub.add_parser("push", help="Push local changes")
repo_push.set_defaults(handler=_repo_push)
repo_push = repo_sub.add_parser("push", help="Push repos")
repo_push.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
repo_push.add_argument("--dry-run", "-n", action="store_true")
repo_push.set_defaults(handler=_repos_push)
repo.set_defaults(handler=_repo_status)
edit = sub.add_parser("edit", help="Show the package directory")
edit.add_argument("package", help="Package name")
edit.set_defaults(handler=_edit)
repo.set_defaults(handler=_repos_list)
p.set_defaults(handler=_default)
def _default(ctx: FlowContext, args):
_status(ctx, args)
DotfilesService(ctx).status()
def _init(ctx: FlowContext, args):
@@ -99,10 +76,6 @@ def _link(ctx: FlowContext, args):
)
def _relink(ctx: FlowContext, args):
DotfilesService(ctx).relink(profile=args.profile)
def _unlink(ctx: FlowContext, args):
DotfilesService(ctx).unlink(
packages=args.packages if args.packages else None,
@@ -110,45 +83,33 @@ def _unlink(ctx: FlowContext, args):
)
def _undo(ctx: FlowContext, args):
DotfilesService(ctx).undo()
def _status(ctx: FlowContext, args):
DotfilesService(ctx).status()
def _clean(ctx: FlowContext, args):
DotfilesService(ctx).clean(dry_run=args.dry_run)
def _sync(ctx: FlowContext, args):
DotfilesService(ctx).sync(profile=args.profile, relink=args.relink)
def _modules_list(ctx: FlowContext, args):
DotfilesService(ctx).list_modules(profile=getattr(args, "profile", None))
def _modules_sync(ctx: FlowContext, args):
DotfilesService(ctx).sync_modules(profile=args.profile)
def _repo_status(ctx: FlowContext, args):
DotfilesService(ctx).repo_status()
def _repo_pull(ctx: FlowContext, args):
DotfilesService(ctx).repo_pull(
profile=args.profile,
relink=args.relink,
rebase=args.rebase,
DotfilesService(ctx).status(
package_filter=args.packages if args.packages else None,
)
def _repo_push(ctx: FlowContext, args):
DotfilesService(ctx).repo_push()
def _edit(ctx: FlowContext, args):
DotfilesService(ctx).edit(args.package)
DotfilesService(ctx).edit(args.package, no_commit=args.no_commit)
def _repos_list(ctx: FlowContext, args):
DotfilesService(ctx).repos_list()
def _repos_status(ctx: FlowContext, args):
DotfilesService(ctx).repos_status(repo_filter=args.repo_filter)
def _repos_pull(ctx: FlowContext, args):
DotfilesService(ctx).repos_pull(
repo_filter=args.repo_filter,
dry_run=args.dry_run,
)
def _repos_push(ctx: FlowContext, args):
DotfilesService(ctx).repos_push(
repo_filter=args.repo_filter,
dry_run=args.dry_run,
)

View File

@@ -15,7 +15,7 @@ def _default(ctx: FlowContext, args):
def _check(ctx: FlowContext, args):
svc = ProjectService(ctx)
svc.check(fetch=getattr(args, "fetch", False))
svc.check(fetch=args.fetch)
def _fetch(ctx: FlowContext, args):
@@ -45,4 +45,4 @@ def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, ali
summary = sub.add_parser("summary", help="Show a summary without fetching")
summary.set_defaults(handler=_summary)
parser.set_defaults(handler=_default)
parser.set_defaults(handler=_default, fetch=default_fetch)

View File

@@ -6,11 +6,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import yaml
from flow.core import paths
from flow.core.console import Console
from flow.core.errors import ConfigError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
@@ -29,6 +26,7 @@ class AppConfig:
dotfiles_branch: str = "main"
dotfiles_pull_before_edit: bool = True
projects_dir: str = "~/projects"
container_runtime: str = "auto"
container_registry: str = "registry.tomastm.com"
container_tag: str = "latest"
tmux_session: str = "default"
@@ -44,182 +42,26 @@ class FlowContext:
runtime: SystemRuntime = field(default_factory=SystemRuntime)
def _load_yaml_file(path: Path) -> dict[str, Any]:
try:
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle)
except yaml.YAMLError as e:
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
if data is None:
return {}
if not isinstance(data, dict):
raise ConfigError(f"YAML file must contain a mapping at root: {path}")
return data
def _resolve_source_paths(
config_dir: Optional[Path],
overlay_dir: Optional[Path],
) -> tuple[Path, ...]:
"""Resolve config/overlay paths to a tuple of source directories."""
if config_dir is None and overlay_dir is None:
return (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
if overlay_dir is None:
return (config_dir,)
if config_dir is None:
return (paths.CONFIG_DIR, overlay_dir)
return (config_dir, overlay_dir)
def _merge_yaml_values(base: Any, overlay: Any) -> Any:
if isinstance(base, dict) and isinstance(overlay, dict):
merged = dict(base)
for key, value in overlay.items():
if key in merged:
merged[key] = _merge_yaml_values(merged[key], value)
else:
merged[key] = value
return merged
if isinstance(base, list) and isinstance(overlay, list):
return [*base, *overlay]
return overlay
def _list_yaml_files(directory: Path) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
return sorted(
(
child for child in directory.iterdir()
if child.is_file() and child.suffix.lower() in {".yaml", ".yml"}
),
key=lambda child: child.name,
)
def _load_yaml_source(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
if path.is_file():
return _load_yaml_file(path)
merged: dict[str, Any] = {}
for file_path in _list_yaml_files(path):
merged = _merge_yaml_values(merged, _load_yaml_file(file_path))
return merged
def _load_yaml_documents(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
if path.is_file():
return [_load_yaml_file(path)]
return [_load_yaml_file(file_path) for file_path in _list_yaml_files(path)]
def _load_yaml_sources(*source_paths: Path) -> dict[str, Any]:
merged: dict[str, Any] = {}
for path in source_paths:
merged = _merge_yaml_values(merged, _load_yaml_source(path))
return merged
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "y", "on"}:
return True
if normalized in {"0", "false", "no", "n", "off"}:
return False
raise ConfigError(f"Expected boolean value, got {value!r}")
def _parse_target_shorthand(key: str, value: str) -> TargetConfig:
parts = value.split()
if not parts:
raise ConfigError(f"Target '{key}' must define a host")
if "@" in key:
namespace, platform = key.split("@", 1)
if not namespace or not platform:
raise ConfigError(f"Invalid target key '{key}'")
return TargetConfig(
namespace=namespace,
platform=platform,
host=parts[0],
identity=parts[1] if len(parts) > 1 else None,
)
if len(parts) < 2:
raise ConfigError(
f"Invalid target value for '{key}': expected 'platform host [identity]'"
)
return TargetConfig(
namespace=key,
platform=parts[0],
host=parts[1],
identity=parts[2] if len(parts) > 2 else None,
)
def _parse_targets(raw: Any) -> list[TargetConfig]:
targets: list[TargetConfig] = []
if raw is None:
return targets
if isinstance(raw, dict):
for key, value in raw.items():
if isinstance(value, str):
targets.append(_parse_target_shorthand(key, value))
continue
if not isinstance(value, dict):
raise ConfigError(f"Target '{key}': value must be a string or mapping")
if "@" in key:
namespace, platform = key.split("@", 1)
else:
namespace = key
platform = value.get("platform")
host = value.get("host", value.get("ssh-host", value.get("ssh_host")))
if not namespace or not platform or not host:
raise ConfigError(
f"Target '{key}' must define namespace, platform, and host"
)
identity = value.get("identity", value.get("ssh-identity", value.get("ssh_identity")))
targets.append(
TargetConfig(
namespace=str(namespace),
platform=str(platform),
host=str(host),
identity=str(identity) if identity is not None else None,
)
)
return targets
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
raise ConfigError("Target list entries must be mappings")
namespace = item.get("namespace")
platform = item.get("platform")
host = item.get("host", item.get("ssh-host", item.get("ssh_host")))
if not namespace or not platform or not host:
raise ConfigError(
"Target list entries must define namespace, platform, and host"
)
identity = item.get("identity", item.get("ssh-identity", item.get("ssh_identity")))
targets.append(
TargetConfig(
namespace=str(namespace),
platform=str(platform),
host=str(host),
identity=str(identity) if identity is not None else None,
)
)
return targets
raise ConfigError("Targets must be a mapping or list of mappings")
return targets
def _section_get(data: dict[str, Any], section: str, key: str, default: Any = None) -> Any:
"""Get a value from a nested config section."""
s = data.get(section)
if isinstance(s, dict) and key in s:
return s[key]
return default
def load_config(
@@ -227,88 +69,37 @@ def load_config(
overlay_dir: Optional[Path] = None,
) -> AppConfig:
"""Load config into AppConfig."""
if config_dir is None and overlay_dir is None:
source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
elif overlay_dir is None:
source_paths = (config_dir,)
elif config_dir is None:
source_paths = (paths.CONFIG_DIR, overlay_dir)
else:
source_paths = (config_dir, overlay_dir)
from flow.core.config_parse import as_bool, parse_targets
from flow.core.yaml import load_yaml_documents, merge_yaml_values
source_paths = _resolve_source_paths(config_dir, overlay_dir)
loaded_sources = [
source
for path in source_paths
for source in _load_yaml_documents(path)
for source in load_yaml_documents(path)
]
data: dict[str, Any] = {}
for source in loaded_sources:
data = _merge_yaml_values(data, source)
data = merge_yaml_values(data, source)
repository = data.get("repository")
paths_section = data.get("paths")
defaults = data.get("defaults")
pull_raw = _section_get(data, "repository", "pull-before-edit")
return AppConfig(
dotfiles_url=(
str(repository["url"])
if isinstance(repository, dict) and "url" in repository
else str(data["dotfiles_url"]) if "dotfiles_url" in data
else ""
),
dotfiles_branch=(
str(repository["branch"])
if isinstance(repository, dict) and "branch" in repository
else str(data["dotfiles_branch"]) if "dotfiles_branch" in data
else "main"
),
dotfiles_pull_before_edit=(
_as_bool(repository["pull-before-edit"])
if isinstance(repository, dict) and "pull-before-edit" in repository
else _as_bool(repository["pull_before_edit"])
if isinstance(repository, dict) and "pull_before_edit" in repository
else _as_bool(data["dotfiles_pull_before_edit"])
if "dotfiles_pull_before_edit" in data
else True
),
projects_dir=(
str(paths_section["projects"])
if isinstance(paths_section, dict) and "projects" in paths_section
else str(paths_section["projects_dir"])
if isinstance(paths_section, dict) and "projects_dir" in paths_section
else str(data["projects_dir"]) if "projects_dir" in data
else "~/projects"
),
container_registry=(
str(defaults["container-registry"])
if isinstance(defaults, dict) and "container-registry" in defaults
else str(defaults["container_registry"])
if isinstance(defaults, dict) and "container_registry" in defaults
else str(data["container_registry"]) if "container_registry" in data
else "registry.tomastm.com"
),
container_tag=(
str(defaults["container-tag"])
if isinstance(defaults, dict) and "container-tag" in defaults
else str(defaults["container_tag"])
if isinstance(defaults, dict) and "container_tag" in defaults
else str(data["container_tag"]) if "container_tag" in data
else "latest"
),
tmux_session=(
str(defaults["tmux-session"])
if isinstance(defaults, dict) and "tmux-session" in defaults
else str(defaults["tmux_session"])
if isinstance(defaults, dict) and "tmux_session" in defaults
else str(data["tmux_session"]) if "tmux_session" in data
else "default"
),
dotfiles_url=str(_section_get(data, "repository", "url", "")),
dotfiles_branch=str(_section_get(data, "repository", "branch", "main")),
dotfiles_pull_before_edit=as_bool(pull_raw) if pull_raw is not None else True,
projects_dir=str(_section_get(data, "paths", "projects", "~/projects")),
container_runtime=str(_section_get(data, "defaults", "container-runtime", "auto")),
container_registry=str(_section_get(data, "defaults", "container-registry", "registry.tomastm.com")),
container_tag=str(_section_get(data, "defaults", "container-tag", "latest")),
tmux_session=str(_section_get(data, "defaults", "tmux-session", "default")),
targets=[
target
for source in loaded_sources
if "targets" in source
for target in _parse_targets(source["targets"])
] if any("targets" in source for source in loaded_sources) else [],
for target in parse_targets(source["targets"])
],
)
@@ -317,13 +108,6 @@ def load_manifest(
overlay_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Load merged manifest YAML."""
if config_dir is None and overlay_dir is None:
source_paths = (paths.CONFIG_DIR, paths.DOTFILES_FLOW_CONFIG)
elif overlay_dir is None:
source_paths = (config_dir,)
elif config_dir is None:
source_paths = (paths.CONFIG_DIR, overlay_dir)
else:
source_paths = (config_dir, overlay_dir)
from flow.core.yaml import load_yaml_sources
return _load_yaml_sources(*source_paths)
return load_yaml_sources(*_resolve_source_paths(config_dir, overlay_dir))

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

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

View File

@@ -2,7 +2,6 @@
import shlex
from dataclasses import dataclass
from typing import Optional
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:
"""Parse a profile definition from manifest."""
ssh_keys = raw.get("ssh-keys") or raw.get("ssh_keys")
if ssh_keys is None:
ssh_keys = raw.get("ssh-keygen") or raw.get("ssh_keygen")
env_required = raw.get("env-required") or raw.get("env_required")
if env_required is None:
env_required = raw.get("requires")
ssh_keys = raw.get("ssh-keys")
return Profile(
name=name,
@@ -56,9 +50,9 @@ def parse_profile(name: str, raw: dict[str, Any]) -> Profile:
ssh_keys=_normalize_ssh_keys(ssh_keys),
runcmd=tuple(raw.get("runcmd") or []),
packages=tuple(raw.get("packages") or []),
env_required=tuple(env_required or []),
dotfiles_profile=raw.get("dotfiles-profile") or raw.get("dotfiles_profile"),
post_link=raw.get("post-link") or raw.get("post_link"),
env_required=tuple(raw.get("env-required") or []),
dotfiles_profile=raw.get("dotfiles-profile"),
post_link=raw.get("post-link"),
)

View File

@@ -29,10 +29,6 @@ class Mount:
target: str
readonly: bool = False
def to_flag(self) -> str:
opt = ":ro" if self.readonly else ""
return f"-v {self.source}:{self.target}{opt}"
@dataclass(frozen=True)
class ContainerSpec:

View File

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

View File

@@ -60,6 +60,7 @@ class LinkOp:
@dataclass(frozen=True)
class PlanSummary:
added: int
updated: int
removed: int
unchanged: int
from_modules: int
@@ -96,7 +97,7 @@ class LinkedState:
from flow.core.errors import ConfigError
raise ConfigError(
f"Unsupported linked.json version {version}. "
"Delete ~/.local/state/flow/linked.json and relink."
"Delete ~/.local/state/flow/linked.json and re-run `flow dotfiles link`."
)
links: dict[Path, LinkTarget] = {}
raw_links = data.get("links", {})
@@ -119,3 +120,4 @@ class RepoInfo:
path: Path
source: str
is_module: bool
module_ref: Optional[ModuleRef] = None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.domain.bootstrap.models import VALID_PHASES, BootstrapAction
from flow.domain.bootstrap.planning import parse_profile, plan_bootstrap
@@ -50,27 +51,10 @@ class BootstrapService:
if dry_run:
return
dotfiles_profile = profile.dotfiles_profile or profile_name
for action in plan.actions:
self.ctx.console.info(f" {action}")
if action.phase == "packages":
# Delegate to PackageService
from flow.services.packages import PackageService
pkg_svc = PackageService(self.ctx)
if plan.packages_to_install:
pkg_svc.install(list(plan.packages_to_install))
continue
if action.phase == "dotfiles":
# Delegate to DotfilesService
from flow.services.dotfiles import DotfilesService
dot_svc = DotfilesService(self.ctx)
dot_svc.link(profile=profile.dotfiles_profile or profile_name)
continue
# Execute shell commands
for cmd in action.commands:
self.ctx.runtime.runner.run_shell(cmd, check=True)
self._execute_action(action, plan, dotfiles_profile)
self.ctx.console.success(f"Bootstrap complete for {profile_name}.")
@@ -90,3 +74,27 @@ class BootstrapService:
for name, data in sorted(profiles.items())
]
self.ctx.console.table(["PROFILE", "OS", "HOSTNAME"], rows)
def _execute_action(
self, action: BootstrapAction, plan, dotfiles_profile: str,
) -> None:
"""Execute a single bootstrap action by phase."""
if action.phase not in VALID_PHASES:
raise FlowError(f"Unknown bootstrap phase: {action.phase!r}")
if action.phase == "packages":
from flow.services.packages import PackageService
pkg_svc = PackageService(self.ctx)
if plan.packages_to_install:
pkg_svc.install(list(plan.packages_to_install))
return
if action.phase == "dotfiles":
from flow.services.dotfiles import DotfilesService
dot_svc = DotfilesService(self.ctx)
dot_svc.link(profile=dotfiles_profile)
return
# Shell phases: setup, shell, post-link
for cmd in action.commands:
self.ctx.runtime.runner.run_shell(cmd, check=True)

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

View File

@@ -2,32 +2,50 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Optional
import yaml
from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError, PlanConflict
from flow.core.errors import FlowError
from flow.core.yaml import load_yaml_file
from flow.core import paths
from flow.domain.dotfiles.models import (
LinkedState,
LinkPlan,
LinkTarget,
ModuleRef,
Package,
RepoInfo,
)
from flow.domain.dotfiles.modules import (
compute_mount_path,
normalize_source,
parse_module_ref,
)
from flow.domain.dotfiles.planning import plan_link, plan_unlink
from flow.domain.dotfiles.resolution import resolve_all_targets
# Maps ref_type to its git checkout prefix. Branch and commit use the
# value directly; tags need the "tags/" prefix for detached checkout.
_REF_TYPE_PREFIXES = {
"branch": "",
"tag": "tags/",
"commit": "",
}
def _git_checkout_ref(module: ModuleRef) -> str:
"""Resolve a ModuleRef to its git checkout ref string."""
prefix = _REF_TYPE_PREFIXES.get(module.ref_type)
if prefix is None:
raise FlowError(f"Unknown ref_type {module.ref_type!r}")
return f"{prefix}{module.ref_value}"
MODULE_FILE = "_module.yaml"
SKIP_DIRS = {".git", ".github", "__pycache__", "flow"}
SKIP_FILES = {".DS_Store", ".gitkeep"}
SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"}
class DotfilesService:
@@ -36,6 +54,8 @@ class DotfilesService:
self.dotfiles_dir = paths.DOTFILES_DIR
self.modules_dir = paths.MODULES_DIR
# ── Linking ──────────────────────────────────────────────────────────
def link(
self,
*,
@@ -43,7 +63,8 @@ class DotfilesService:
dry_run: bool = False,
skip: Optional[set[str]] = None,
) -> None:
"""Link dotfiles to home directory."""
"""Reconcile dotfile symlinks. Creates, updates, removes stale, and
repairs broken symlinks in a single pass."""
skip_set = skip or set()
packages = self._discover_packages(profile)
@@ -51,13 +72,8 @@ class DotfilesService:
self.ctx.console.info("No packages found.")
return
# Resolve all targets
targets = resolve_all_targets(packages, paths.HOME, skip_set)
# Load current state
current = self._load_state()
# Build plan
plan = plan_link(targets, current, self._filesystem_check)
if plan.conflicts:
@@ -76,15 +92,17 @@ class DotfilesService:
if dry_run:
return
self._save_backup(current)
new_state = self._apply_plan(plan, targets, current)
self._save_state(new_state)
self.ctx.console.success(
f"Linked: {plan.summary.added} added, "
f"{plan.summary.removed} removed, "
f"{plan.summary.unchanged} unchanged"
)
parts = []
if plan.summary.added:
parts.append(f"{plan.summary.added} added")
if plan.summary.updated:
parts.append(f"{plan.summary.updated} updated")
if plan.summary.removed:
parts.append(f"{plan.summary.removed} removed")
parts.append(f"{plan.summary.unchanged} unchanged")
self.ctx.console.success(f"Linked: {', '.join(parts)}")
def unlink(
self,
@@ -109,7 +127,6 @@ class DotfilesService:
if dry_run:
return
self._save_backup(current)
new_state = LinkedState(links=dict(current.links))
for op in plan.operations:
self.ctx.runtime.fs.remove_file(
@@ -123,35 +140,94 @@ class DotfilesService:
self._save_state(new_state)
self.ctx.console.success(f"Unlinked {plan.summary.removed} file(s).")
def status(self) -> None:
"""Show linked dotfiles status."""
# ── Status ───────────────────────────────────────────────────────────
def status(self, package_filter: Optional[list[str]] = None) -> None:
"""Show linked dotfiles status with module info and link health."""
state = self._load_state()
if not state.links:
self.ctx.console.info("No managed links.")
all_packages = self._discover_packages(profile=None, include_all_layers=True)
if not state.links and not all_packages:
self.ctx.console.info("No managed links or packages.")
return
# Group by package
by_package: dict[str, list[LinkTarget]] = {}
for lt in state.links.values():
by_package.setdefault(lt.package, []).append(lt)
rows: list[list[str]] = []
for pkg_id in sorted(by_package):
links = by_package[pkg_id]
rows.append([pkg_id, str(len(links)), "linked"])
for pkg in all_packages:
if package_filter:
name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id
if pkg.package_id not in package_filter and name not in package_filter:
continue
self.ctx.console.table(["PACKAGE", "FILES", "STATUS"], rows)
links = by_package.get(pkg.package_id, [])
link_count = str(len(links))
def edit(self, package_name: str) -> None:
"""Open package directory in editor."""
pkg_dir = self.dotfiles_dir / "_shared" / package_name
if not pkg_dir.is_dir():
raise FlowError(f"Package not found: {package_name}")
module_col = ""
if pkg.module:
module_col = f"{pkg.module.ref_type}:{pkg.module.ref_value}"
self.ctx.console.info(f"Package directory: {pkg_dir}")
if not links:
health = "not linked"
else:
broken = sum(
1 for lt in links
if lt.target.is_symlink() and not lt.target.exists()
)
health = f"{broken} broken" if broken else "ok"
rows.append([pkg.package_id, link_count, health, module_col])
if not rows:
self.ctx.console.info("No packages match filter.")
return
self.ctx.console.table(["PACKAGE", "FILES", "HEALTH", "MODULE"], rows)
# ── Edit ─────────────────────────────────────────────────────────────
def edit(self, package_name: str, *, no_commit: bool = False) -> None:
"""Pull repo -> open $EDITOR -> commit+push if changed."""
pkg, repo = self._find_package_repo(package_name)
# Pull before editing (if configured)
if self.ctx.config.dotfiles_pull_before_edit and repo.path.is_dir():
self._pull_or_clone_repo(repo)
# Open editor
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi")
edit_dir = repo.path if repo.is_module else pkg.source_dir
result = self.ctx.runtime.runner.run(
[editor, str(edit_dir)], capture_output=False,
)
if result.returncode != 0:
raise FlowError(f"Editor exited with code {result.returncode}")
if no_commit:
return
# Check for changes and auto-commit+push
status = self.ctx.runtime.git.run(
repo.path, "status", "--porcelain", check=True,
)
if not status.stdout.strip():
self.ctx.console.info("No changes.")
return
self.ctx.runtime.git.run(repo.path, "add", str(edit_dir), check=True)
self.ctx.runtime.git.run(
repo.path, "commit", "-m", f"dotfiles: update {package_name}",
check=True,
)
self.ctx.runtime.git.run(repo.path, "push", check=True)
self.ctx.console.success(f"Changes to {package_name} committed and pushed.")
# ── Init ─────────────────────────────────────────────────────────────
def init(self, repo_url: Optional[str] = None) -> None:
"""Clone the dotfiles repository."""
"""Clone the dotfiles repository and all module repos."""
remote = repo_url or self.ctx.config.dotfiles_url
if not remote:
raise FlowError("No dotfiles URL configured")
@@ -172,185 +248,176 @@ class DotfilesService:
str(self.dotfiles_dir),
check=True,
)
self.sync_modules()
self.repos_pull()
self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}")
def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None:
"""Pull latest dotfiles and sync modules."""
if not self.dotfiles_dir.is_dir():
self.init()
else:
self.ctx.console.info("Pulling latest dotfiles...")
self.ctx.runtime.git.run(
self.dotfiles_dir, "pull", "--ff-only", check=True,
)
# ── Repos (unified: dotfiles + modules) ──────────────────────────────
self.sync_modules(profile=profile)
if relink:
self.relink(profile=profile)
def list_modules(self, *, profile: Optional[str] = None) -> None:
"""List detected module packages."""
packages = self._discover_packages(
profile=profile,
include_all_layers=profile is None,
)
module_packages = [pkg for pkg in packages if pkg.module is not None]
if not module_packages:
self.ctx.console.info("No module packages found.")
def repos_list(self) -> None:
"""List all managed repos (dotfiles + modules)."""
repos = self._discover_repos()
if not repos:
self.ctx.console.info("No managed repos.")
return
rows = []
for pkg in module_packages:
assert pkg.module is not None
status = "ready" if pkg.module.cache_dir.exists() else "missing"
rows.append([
pkg.package_id,
f"{pkg.module.ref_type}:{pkg.module.ref_value}",
pkg.module.source,
status,
])
self.ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows)
for repo in repos:
status = "cloned" if repo.path.is_dir() else "missing"
kind = "module" if repo.is_module else "dotfiles"
rows.append([repo.name, kind, str(repo.path), status])
def sync_modules(self, *, profile: Optional[str] = None) -> None:
"""Clone or update module repositories."""
packages = self._discover_packages(
profile=profile,
include_all_layers=profile is None,
)
for pkg in packages:
if pkg.module:
self._sync_module(pkg)
self.ctx.console.table(["NAME", "TYPE", "PATH", "STATUS"], rows)
def repo_status(self) -> None:
"""Show git status for the dotfiles repository."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
def repos_status(self, repo_filter: Optional[str] = None) -> None:
"""Show git status for managed repos."""
for repo in self._filter_repos(repo_filter):
if not repo.path.is_dir():
self.ctx.console.warn(f"{repo.name}: not cloned")
continue
self.ctx.console.info(f"[{repo.name}]")
result = self.ctx.runtime.git.run(
self.dotfiles_dir,
"status",
"--short",
"--branch",
check=True,
repo.path, "status", "--short", "--branch", check=True,
)
output = result.stdout.strip()
if output:
print(output)
return
self.ctx.console.info("Dotfiles repository is clean.")
self.ctx.console.info(output)
else:
self.ctx.console.info(" clean")
def repo_pull(
def repos_pull(
self,
repo_filter: Optional[str] = None,
*,
profile: Optional[str] = None,
relink: bool = False,
rebase: bool = True,
dry_run: bool = False,
) -> None:
"""Pull the dotfiles repository and refresh modules."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
argv = ["pull"]
argv.append("--rebase" if rebase else "--ff-only")
self.ctx.runtime.git.run(self.dotfiles_dir, *argv, check=True)
self.sync_modules(profile=profile)
if relink:
self.relink(profile=profile)
def repo_push(self) -> None:
"""Push the dotfiles repository."""
if not self.dotfiles_dir.is_dir():
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
self.ctx.runtime.git.run(self.dotfiles_dir, "push", check=True)
self.ctx.console.success("Dotfiles pushed.")
def relink(self, *, profile: Optional[str] = None) -> None:
"""Refresh symlinks for the selected profile."""
self.link(profile=profile)
def clean(self, *, dry_run: bool = False) -> None:
"""Remove broken symlinks from managed state."""
current = self._load_state()
broken = [
target for target in sorted(current.links)
if target.is_symlink() and not target.exists()
]
if not broken:
self.ctx.console.info("No broken symlinks found.")
return
"""Pull (or clone) managed repos."""
for repo in self._filter_repos(repo_filter):
if dry_run:
for target in broken:
self.ctx.console.info(f"Would remove broken symlink: {target}")
return
action = "pull" if repo.path.is_dir() else "clone"
self.ctx.console.info(f"Would {action}: {repo.name} ({repo.source})")
continue
self._pull_or_clone_repo(repo)
self._save_backup(current)
for target in broken:
link = current.links[target]
self.ctx.runtime.fs.remove_file(
target,
sudo=link.needs_sudo,
runner=self.ctx.runtime.runner if link.needs_sudo else None,
missing_ok=True,
)
current.links.pop(target, None)
self._save_state(current)
self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).")
def repos_push(
self,
repo_filter: Optional[str] = None,
*,
dry_run: bool = False,
) -> None:
"""Push managed repos."""
for repo in self._filter_repos(repo_filter):
if not repo.path.is_dir():
self.ctx.console.warn(f"{repo.name}: not cloned, skipping")
continue
if dry_run:
self.ctx.console.info(f"Would push: {repo.name}")
continue
self.ctx.runtime.git.run(repo.path, "push", check=True)
self.ctx.console.success(f"Pushed: {repo.name}")
def undo(self) -> None:
"""Restore the previous linked state."""
previous = self._load_backup()
if previous is None:
self.ctx.console.info("No dotfiles link transaction to undo.")
return
# ── Repo discovery ───────────────────────────────────────────────────
current = self._load_state()
desired = list(previous.links.values())
plan = plan_link(desired, current, self._filesystem_check)
if not plan.operations:
self.ctx.console.info("Nothing to undo.")
return
def _discover_repos(self) -> list[RepoInfo]:
"""Return all managed repos: dotfiles repo + module repos."""
repos: list[RepoInfo] = []
self.ctx.console.print_plan(plan.operations, verb="undo")
self._save_backup(current)
restored = self._apply_plan(plan, desired, current)
self._save_state(restored)
self.ctx.console.success("Dotfiles state restored.")
remote = self.ctx.config.dotfiles_url
if remote or self.dotfiles_dir.is_dir():
repos.append(RepoInfo(
name="dotfiles",
path=self.dotfiles_dir,
source=remote or "",
is_module=False,
))
def _sync_module(self, pkg: Package) -> None:
"""Clone or update a module."""
module = pkg.module
assert module is not None
cache_dir = module.cache_dir
packages = self._discover_packages(profile=None, include_all_layers=True)
seen: set[str] = set()
for pkg in packages:
if pkg.module and pkg.module.source not in seen:
seen.add(pkg.module.source)
repos.append(RepoInfo(
name=pkg.name,
path=pkg.module.cache_dir,
source=pkg.module.source,
is_module=True,
module_ref=pkg.module,
))
if cache_dir.is_dir():
self.ctx.console.info(f" Updating module: {pkg.package_id}")
self.ctx.runtime.git.run(cache_dir, "fetch", "--all", check=True)
if module.ref_type == "branch":
return repos
def _filter_repos(self, repo_filter: Optional[str]) -> list[RepoInfo]:
"""Filter repos by name. Returns all if filter is None."""
repos = self._discover_repos()
if repo_filter is None:
return repos
matched = [r for r in repos if r.name == repo_filter]
if not matched:
raise FlowError(f"No repo named {repo_filter!r}. "
f"Available: {', '.join(r.name for r in repos)}")
return matched
def _pull_or_clone_repo(self, repo: RepoInfo) -> None:
"""Pull an existing repo or clone it if missing."""
if repo.path.is_dir():
self.ctx.console.info(f"Pulling: {repo.name}")
if repo.is_module:
self._pull_module_repo(repo)
else:
self.ctx.runtime.git.run(
cache_dir, "checkout", module.ref_value, check=True,
)
self.ctx.runtime.git.run(
cache_dir, "pull", "--ff-only", check=True,
)
elif module.ref_type == "tag":
self.ctx.runtime.git.run(
cache_dir, "checkout", f"tags/{module.ref_value}", check=True,
)
elif module.ref_type == "commit":
self.ctx.runtime.git.run(
cache_dir, "checkout", module.ref_value, check=True,
repo.path, "pull", "--ff-only", check=True,
)
else:
self.ctx.console.info(f" Cloning module: {pkg.package_id}")
if not repo.source:
raise FlowError(f"No source URL for repo {repo.name!r}")
self.ctx.console.info(f"Cloning: {repo.name}")
self.ctx.runtime.runner.run(
["git", "clone", module.source, str(cache_dir)],
["git", "clone", repo.source, str(repo.path)],
check=True,
)
if module.ref_type != "branch" or module.ref_value != "main":
ref = module.ref_value
if module.ref_type == "tag":
ref = f"tags/{ref}"
self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True)
if repo.is_module:
self._checkout_module_ref(repo)
def _pull_module_repo(self, repo: RepoInfo) -> None:
"""Pull a module repo, respecting its ref type."""
module = repo.module_ref
if module is None:
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
return
self.ctx.runtime.git.run(repo.path, "fetch", "--all", check=True)
ref = _git_checkout_ref(module)
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
if module.ref_type == "branch":
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
def _checkout_module_ref(self, repo: RepoInfo) -> None:
"""Checkout the correct ref after cloning a module repo."""
module = repo.module_ref
if module is None:
return
if module.ref_type == "branch" and module.ref_value == "main":
return
ref = _git_checkout_ref(module)
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]:
"""Find a package and its owning repo."""
repos = self._discover_repos()
repo_by_name = {r.name: r for r in repos}
dotfiles_repo = repo_by_name.get("dotfiles")
packages = self._discover_packages(profile=None, include_all_layers=True)
for pkg in packages:
name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id
if name == package_name or pkg.package_id == package_name:
if pkg.module and pkg.name in repo_by_name:
return pkg, repo_by_name[pkg.name]
if dotfiles_repo is not None:
return pkg, dotfiles_repo
raise FlowError(f"No repo found for package: {package_name}")
raise FlowError(f"Package not found: {package_name}")
# ── Package discovery ────────────────────────────────────────────────
def _discover_packages(
self,
@@ -388,11 +455,7 @@ class DotfilesService:
continue
package_id = f"{layer}/{pkg_dir.name}"
# Find _module.yaml (if any)
module_ref = self._find_module(pkg_dir, package_id)
# Collect local files
local_files = self._collect_files(pkg_dir)
packages.append(Package(
@@ -407,20 +470,13 @@ class DotfilesService:
return packages
def _find_module(self, pkg_dir: Path, package_id: str) -> Optional[ModuleRef]:
"""Find and parse _module.yaml in a package directory."""
for module_yaml in pkg_dir.rglob(MODULE_FILE):
try:
with open(module_yaml, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError) as e:
from flow.core.errors import ConfigError
raise ConfigError(f"Failed to read {module_yaml}: {e}") from e
"""Find and parse _module.yaml in a package directory (first match wins)."""
for module_yaml in sorted(pkg_dir.rglob(MODULE_FILE)):
raw = load_yaml_file(module_yaml)
mount_path = compute_mount_path(module_yaml, pkg_dir)
ref = parse_module_ref(raw, package_id, mount_path, self.modules_dir)
# If module is cloned, populate module_files
if ref.cache_dir.is_dir():
module_files = self._collect_files(ref.cache_dir)
ref = ModuleRef(
@@ -443,31 +499,27 @@ class DotfilesService:
continue
if path.name in SKIP_FILES:
continue
# Skip .git contents
try:
path.relative_to(root_dir / ".git")
continue
except ValueError:
pass
rel = path.relative_to(root_dir)
if any(part in SKIP_DIRS for part in rel.parts):
continue
files.append((path, rel))
return files
def _filesystem_check(self, path: Path) -> Optional[str]:
"""Check what exists at a path. Returns type or None."""
if path.is_symlink():
return "symlink"
return "broken_symlink" if not path.exists() else "symlink"
if path.is_file():
return "file"
if path.is_dir():
return "dir"
return None
# ── State persistence ────────────────────────────────────────────────
def _load_state(self) -> LinkedState:
"""Load linked state from disk."""
data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={})
if data is None:
data = {}
state = LinkedState.from_dict(data)
reconciled = LinkedState(
links={
@@ -484,21 +536,9 @@ class DotfilesService:
"""Save linked state to disk."""
self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict())
def _save_backup(self, state: LinkedState) -> None:
self.ctx.runtime.fs.write_json(self._backup_path(), state.as_dict())
def _load_backup(self) -> Optional[LinkedState]:
data = self.ctx.runtime.fs.read_json(self._backup_path(), default=None)
if data is None:
return None
return LinkedState.from_dict(data)
def _backup_path(self) -> Path:
return paths.LINKED_STATE.with_name("linked.previous.json")
def _apply_plan(
self,
plan,
plan: LinkPlan,
targets: list[LinkTarget],
current: LinkedState,
) -> LinkedState:

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import os
from typing import Optional
from flow.core.config import FlowContext
from flow.core.errors import FlowError
from flow.domain.remote.resolution import (
build_destination,
build_ssh_command,
list_targets,
resolve_target,
@@ -78,10 +78,7 @@ class RemoteService:
self.ctx.config.targets,
default_user=os.environ.get("USER") or getpass.getuser(),
)
destination = f"{target.user}@{target.host}" if target.user else target.host
destination = build_destination(target.user, target.host)
cmd = terminfo_fix_command(os.environ.get("TERM"), destination)
if cmd is None:
self.ctx.console.info("No terminfo workaround needed for the current TERM.")
return
self.ctx.console.info("Run this command to fix terminfo:")
self.ctx.console.info(f" {cmd}")

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
def test_no_color_flag():
"""Test --no-color flag is accepted."""
result = subprocess.run(
[sys.executable, "-m", "flow", "--no-color", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_dotfiles_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "dotfiles", "--help"],
@@ -35,6 +44,12 @@ def test_dotfiles_help():
assert result.returncode == 0
assert "link" in result.stdout
assert "unlink" in result.stdout
# Removed commands should not appear
assert "relink" not in result.stdout
assert "undo" not in result.stdout
assert "clean" not in result.stdout
assert "sync" not in result.stdout
assert "modules" not in result.stdout
def test_packages_help():

View File

@@ -1,5 +1,7 @@
"""Tests for zsh completion."""
import subprocess
from flow.commands.completion import complete
@@ -23,6 +25,33 @@ def test_complete_dotfiles_subcommands():
assert "link" in result
assert "unlink" in result
assert "status" in result
assert "edit" in result
assert "repos" in result
# Removed commands should not appear
assert "sync" not in result
assert "relink" not in result
assert "undo" not in result
assert "clean" not in result
assert "modules" not in result
def test_complete_dotfiles_repos_subcommands():
result = complete(["flow", "dotfiles", "repos", ""], 3)
assert "list" in result
assert "status" in result
assert "pull" in result
assert "push" in result
def test_complete_dotfiles_repos_pull_flags():
result = complete(["flow", "dotfiles", "repos", "pull", "--"], 4)
assert "--repo" in result
assert "--dry-run" in result
def test_complete_dotfiles_edit_packages():
result = complete(["flow", "dotfiles", "edit", "--"], 3)
assert "--no-commit" in result
def test_complete_dotfiles_link_flags():
@@ -41,3 +70,17 @@ def test_complete_packages_subcommands():
assert "install" in result
assert "remove" in result
assert "list" in result
def test_complete_dev_attach_returns_empty_on_timeout(monkeypatch):
class FakeRuntime:
def __init__(self, runner, *, mode="auto"):
self.runner = runner
def ps(self, **kwargs):
assert kwargs["timeout"] == 1.0
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
monkeypatch.setattr("flow.commands.completion.ContainerRuntime", FakeRuntime)
result = complete(["flow", "dev", "attach", ""], 3)
assert result == []

View File

@@ -12,6 +12,7 @@ def test_load_config_missing_path(tmp_path):
assert isinstance(cfg, AppConfig)
assert cfg.dotfiles_url == ""
assert cfg.container_registry == "registry.tomastm.com"
assert cfg.container_runtime == "auto"
def test_load_config_from_yaml(tmp_path):
@@ -131,3 +132,12 @@ def test_load_manifest_merges_local_and_overlay(tmp_path):
data = load_manifest(local, overlay)
assert "profiles" in data
assert "packages" in data
def test_load_config_container_runtime(tmp_path):
(tmp_path / "config.yaml").write_text(
"defaults:\n"
" container-runtime: podman-rootful\n"
)
cfg = load_config(tmp_path)
assert cfg.container_runtime == "podman-rootful"

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

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)
assert len(profile.ssh_keys) == 1
def test_ssh_keygen_alias(self):
raw = {"ssh-keygen": [{"filename": "id_work", "type": "ed25519"}]}
def test_ssh_keys_with_filename(self):
raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
profile = parse_profile("test", raw)
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
def test_requires_alias(self):
profile = parse_profile("test", {"requires": ["USER_EMAIL"]})
def test_env_required(self):
profile = parse_profile("test", {"env-required": ["USER_EMAIL"]})
assert profile.env_required == ("USER_EMAIL",)
def test_post_link_and_dotfiles_profile(self):

View File

@@ -52,6 +52,18 @@ class TestResolveMounts:
mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
def test_socket_path_mount(self, tmp_path):
sock = tmp_path / "docker.sock"
sock.write_text("")
mounts = resolve_mounts(tmp_path, socket_path=sock)
socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"]
assert len(socket_mounts) == 1
assert socket_mounts[0].source == sock
def test_no_socket_path(self, tmp_path):
mounts = resolve_mounts(tmp_path)
assert not any(m.target == "/var/run/docker.sock" for m in mounts)
class TestBuildContainerSpec:
def test_basic(self):
@@ -68,10 +80,12 @@ class TestBuildContainerSpec:
class TestMount:
def test_to_flag(self):
def test_fields(self):
m = Mount(source=Path("/src"), target="/dst")
assert m.to_flag() == "-v /src:/dst"
assert m.source == Path("/src")
assert m.target == "/dst"
assert m.readonly is False
def test_to_flag_readonly(self):
def test_readonly(self):
m = Mount(source=Path("/src"), target="/dst", readonly=True)
assert ":ro" in m.to_flag()
assert m.readonly is True

View File

@@ -58,6 +58,9 @@ class TestPlanLink:
plan = plan_link([new], current, _fs_check_none)
types = [op.type for op in plan.operations]
assert types == ["remove_link", "create_link"]
assert plan.summary.updated == 1
assert plan.summary.added == 0
assert plan.summary.removed == 0
def test_unmanaged_file_at_target_is_conflict(self):
desired = [_lt("/home/x/.zshrc")]
@@ -71,6 +74,16 @@ class TestPlanLink:
assert plan.summary.from_modules == 1
def test_broken_symlink_is_repaired(self):
lt = _lt("/home/x/.zshrc")
current = LinkedState(links={Path("/home/x/.zshrc"): lt})
plan = plan_link([lt], current, lambda p: "broken_symlink")
types = [op.type for op in plan.operations]
assert types == ["remove_link", "create_link"]
assert plan.summary.updated == 1
assert plan.summary.unchanged == 0
class TestPlanUnlink:
def test_unlink_all(self):
lt = _lt("/home/x/.zshrc")

View File

@@ -140,7 +140,7 @@ class TestResolveBinaryAsset:
name="fd", type="binary", sources={},
source="github:sharkdp/fd",
version="v10.2.0",
asset_pattern="fd-v10.2.0-{arch}-unknown-{os}-gnu.tar.gz",
asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz",
platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,

View File

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

View File

@@ -4,35 +4,20 @@ import subprocess
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.containers import ContainerRuntime
from flow.core.platform import PlatformInfo
from flow.core.runtime import CommandRunner, SystemRuntime
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.containers import ContainerService
class FakeRunner(CommandRunner):
"""CommandRunner that captures calls instead of executing."""
def __init__(self):
self.calls: list[tuple] = []
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
self.calls.append(("run", list(argv)))
command = list(argv)
if command[:4] == ["docker", "container", "ls", "-a"]:
return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="")
if command[:3] == ["docker", "container", "ls"]:
return subprocess.CompletedProcess(argv, 0, stdout="dev-api\n", stderr="")
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
def run_shell(self, command, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
self.calls.append(("run_shell", command))
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
from tests.fakes import FakeRunner
def _make_ctx(tmp_path, runner=None):
rt = SystemRuntime()
if runner:
rt.runner = runner
rt.containers = ContainerRuntime(runner, binary="docker")
return FlowContext(
config=AppConfig(),
manifest={},
@@ -46,34 +31,37 @@ class TestContainerService:
def test_create_dry_run(self, tmp_path, capsys, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path)
monkeypatch.setattr(paths, "DOTFILES_DIR", tmp_path / "dotfiles")
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
ctx = _make_ctx(tmp_path)
runner = FakeRunner(responses={
("ps", "{{.Names}}"): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.create("api", "tm0/node", dry_run=True)
output = capsys.readouterr().out
assert "dev-api" in output
assert runner.calls == []
def test_list_no_containers(self, tmp_path, capsys, monkeypatch):
def test_list_no_containers(self, tmp_path, capsys):
runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
runner.run = lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.list()
output = capsys.readouterr().out
assert "No flow containers" in output
def test_stop_calls_docker(self, tmp_path, monkeypatch):
runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
def test_stop_calls_docker(self, tmp_path):
runner = FakeRunner(responses={
("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.stop("api")
assert any("docker" in str(c) and "stop" in str(c) for c in runner.calls)
def test_remove_calls_docker(self, tmp_path, monkeypatch):
runner = FakeRunner()
monkeypatch.setattr("flow.services.containers.runtime", lambda: "docker")
def test_remove_calls_docker(self, tmp_path):
runner = FakeRunner(responses={
("ps",): subprocess.CompletedProcess([], 0, stdout="dev-api\n"),
})
ctx = _make_ctx(tmp_path, runner=runner)
svc = ContainerService(ctx)
svc.remove("api")

View File

@@ -1,6 +1,5 @@
"""Tests for DotfilesService."""
import subprocess
from pathlib import Path
import yaml
@@ -8,19 +7,10 @@ import yaml
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import CommandRunner, SystemRuntime
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.dotfiles import DotfilesService
class FakeRunner(CommandRunner):
def __init__(self):
self.calls: list[list[str]] = []
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
command = [str(part) for part in argv]
self.calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
from tests.fakes import FakeRunner
def _make_ctx(tmp_path, console=None):
@@ -206,7 +196,69 @@ class TestDotfilesServiceLink:
assert target.read_text() == "user managed file"
assert not target.is_symlink()
def test_sync_modules_includes_profile_layers(self, tmp_path, monkeypatch):
def test_status_shows_module_info(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
# Set up package with _module.yaml
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
# Set up cloned module
module_dir = modules / "_shared--nvim"
module_dir.mkdir(parents=True)
(module_dir / "init.lua").write_text("-- init")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
svc.status()
output = capsys.readouterr().out
assert "nvim" in output
assert "branch:main" in output
def test_repos_list_shows_dotfiles_and_modules(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.repos_list()
output = capsys.readouterr().out
assert "dotfiles" in output
assert "nvim" in output
assert "module" in output
def test_repos_pull_includes_profile_module_repos(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
@@ -234,5 +286,150 @@ class TestDotfilesServiceLink:
runtime=runtime,
)
DotfilesService(ctx).sync_modules()
DotfilesService(ctx).repos_pull()
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)
def test_repos_status_shows_repo_names(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
# Make dotfiles dir look like a git repo for status
(dotfiles / ".git").mkdir()
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_status()
output = capsys.readouterr().out
assert "dotfiles" in output
def test_repos_push_calls_git_push(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_push()
assert any("push" in " ".join(call) for call in runner.calls)
def test_repos_pull_dry_run_no_calls(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_pull(dry_run=True)
output = capsys.readouterr().out
assert "Would" in output
# No git calls should be made in dry run
assert not runner.calls
def test_status_filter_by_package(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
"git": {".gitconfig": "[user]"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
capsys.readouterr() # discard link output
svc.status(package_filter=["zsh"])
output = capsys.readouterr().out
assert "zsh" in output
# Only zsh should appear, not git
assert "_shared/git" not in output
def test_link_repairs_broken_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh config"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link normally
svc.link()
assert (home / ".zshrc").is_symlink()
# Break the symlink by removing its target
real_target = (home / ".zshrc").resolve()
(home / ".zshrc").unlink()
(home / ".zshrc").symlink_to("/nonexistent/path")
# Re-link should repair the broken symlink
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".zshrc").resolve() == real_target.resolve()

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