update
This commit is contained in:
113
README.md
113
README.md
@@ -1,66 +1,84 @@
|
||||
# flow
|
||||
|
||||
`flow` is a CLI for managing development environments: dotfiles, packages, containers, remote targets, and system bootstrap.
|
||||
CLI for managing development environments: dotfiles, packages, dev containers, remote targets, and system bootstrap.
|
||||
|
||||
## Installation
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
make build
|
||||
make install-local
|
||||
make build && make install-local # installs to ~/.local/bin/flow
|
||||
flow setup run linux-work # bootstrap a machine
|
||||
flow dotfiles link # symlink dotfiles
|
||||
flow dev create api -i tm0/node # spin up a dev container
|
||||
flow dev attach api # attach via tmux
|
||||
```
|
||||
|
||||
This installs `flow` to `~/.local/bin/flow`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Four-layer design: **core** (runtime, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
|
||||
|
||||
- Domain layer is pure: no I/O, no side effects, fully testable
|
||||
- Services perform all I/O and delegate logic to domain functions
|
||||
- Commands are trivial dispatchers from argparse to services
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Dotfiles
|
||||
flow dotfiles link [--profile NAME] [--dry-run] [--skip PKG...]
|
||||
flow dotfiles unlink [PACKAGES...] [--dry-run]
|
||||
flow dotfiles status
|
||||
flow dotfiles sync
|
||||
flow dotfiles status [PACKAGES...]
|
||||
flow dotfiles edit PACKAGE [--no-commit] # pull -> $EDITOR -> commit+push
|
||||
|
||||
# Dotfiles repos (unified: dotfiles repo + module repos)
|
||||
flow dotfiles repos list
|
||||
flow dotfiles repos status [--repo NAME]
|
||||
flow dotfiles repos pull [--repo NAME] [--dry-run]
|
||||
flow dotfiles repos push [--repo NAME] [--dry-run]
|
||||
|
||||
# Packages
|
||||
flow packages install [NAMES...] [--profile NAME] [--dry-run]
|
||||
flow packages remove NAMES... [--dry-run]
|
||||
flow packages list
|
||||
flow packages remove NAMES...
|
||||
flow packages list [--all]
|
||||
|
||||
# Bootstrap
|
||||
flow setup run PROFILE [--dry-run]
|
||||
flow setup show PROFILE
|
||||
flow setup run PROFILE [--dry-run] # run a full bootstrap profile
|
||||
flow setup show PROFILE # preview profile steps
|
||||
flow setup list
|
||||
|
||||
# Remote targets
|
||||
flow remote enter NAMESPACE@PLATFORM [--dry-run]
|
||||
flow remote enter TARGET [--dry-run] # ssh + tmux into a remote target
|
||||
flow remote list
|
||||
|
||||
# Dev containers
|
||||
flow dev create IMAGE [--namespace NS] [--dry-run]
|
||||
flow dev enter NAME [--shell PATH]
|
||||
flow dev stop NAME
|
||||
flow dev remove NAME
|
||||
# Dev containers (docker or podman)
|
||||
flow dev create NAME -i IMAGE [-p PROJECT] [--dry-run]
|
||||
flow dev attach NAME # tmux session into container
|
||||
flow dev exec NAME [-- CMD...] # run a command in container
|
||||
flow dev enter NAME # interactive shell
|
||||
flow dev stop NAME [--kill]
|
||||
flow dev remove NAME [-f]
|
||||
flow dev respawn NAME # restart all tmux panes
|
||||
flow dev list
|
||||
|
||||
# Projects
|
||||
flow projects check [--fetch]
|
||||
flow projects check [--fetch] # scan ~/projects for dirty repos
|
||||
flow projects fetch # fetch all project remotes
|
||||
flow projects summary # quick overview
|
||||
|
||||
# Other
|
||||
# Shell completion
|
||||
flow completion zsh # print zsh completion script
|
||||
flow completion install-zsh # install to ~/.zsh/completions
|
||||
|
||||
# Global flags
|
||||
flow --version
|
||||
flow --quiet
|
||||
flow completion
|
||||
flow --quiet # suppress info output
|
||||
flow --no-color # disable colored output
|
||||
```
|
||||
|
||||
### Aliases
|
||||
|
||||
- `dotfiles` -> `dot`
|
||||
- `dotfiles repos` -> `dotfiles repo`
|
||||
- `packages` -> `package`, `pkg`
|
||||
- `projects` -> `project`, `sync` (with `--fetch` default)
|
||||
- `setup` -> `bootstrap`
|
||||
- `dev attach` -> `dev connect`
|
||||
- `dev remove` -> `dev rm`
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is loaded from `~/.config/flow/config.yaml` and merged with self-hosted config from the dotfiles repo at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`.
|
||||
Loaded from `~/.config/flow/config.yaml`, merged with dotfiles repo overlay at `~/.local/share/flow/dotfiles/_shared/flow/.config/flow/`.
|
||||
|
||||
```yaml
|
||||
repository:
|
||||
@@ -71,7 +89,9 @@ paths:
|
||||
projects: ~/projects
|
||||
|
||||
defaults:
|
||||
container-runtime: auto # auto | docker | podman | podman-rootful
|
||||
container-registry: registry.example.com
|
||||
container-tag: latest
|
||||
tmux-session: main
|
||||
|
||||
targets:
|
||||
@@ -81,6 +101,17 @@ targets:
|
||||
identity: ~/.ssh/id_work
|
||||
```
|
||||
|
||||
### Container runtime
|
||||
|
||||
`container-runtime` controls which engine `flow dev` uses:
|
||||
|
||||
- `auto` -- detect docker or podman from PATH (default)
|
||||
- `docker` -- force docker
|
||||
- `podman` -- force podman, prefer rootless socket
|
||||
- `podman-rootful` -- force podman, prefer rootful socket (`/run/podman/podman.sock`)
|
||||
|
||||
When using podman, `flow dev create` automatically adds `--security-opt label=disable` for engine socket access. The socket is always mounted to `/var/run/docker.sock` inside the container (Podman's Docker-compatible API).
|
||||
|
||||
## Dotfiles layout
|
||||
|
||||
```
|
||||
@@ -103,7 +134,7 @@ linux-work/ # Profile-specific layer
|
||||
|
||||
### External modules
|
||||
|
||||
A `_module.yaml` inside a package mounts an external git repo at that location:
|
||||
`_module.yaml` inside a package mounts an external git repo at that location:
|
||||
|
||||
```yaml
|
||||
source: github:org/nvim-config
|
||||
@@ -111,11 +142,11 @@ ref:
|
||||
branch: main
|
||||
```
|
||||
|
||||
Module files are linked from the clone cache, while local files outside the mount path are linked from the dotfiles repo.
|
||||
Modules are regular git clones managed by flow (not git submodules). They appear alongside the dotfiles repo in `flow dotfiles repos list` and are pulled/pushed with the same commands.
|
||||
|
||||
## Manifest
|
||||
|
||||
Packages and profiles are defined in YAML files under the flow config directory.
|
||||
Packages and profiles defined in YAML files under the flow config directory.
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
@@ -148,15 +179,21 @@ profiles:
|
||||
- echo "setup complete"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Four layers: **core** (runtime primitives, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
|
||||
|
||||
Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`.
|
||||
|
||||
## Security
|
||||
|
||||
- `flow` must run as a regular user (root invocation is rejected)
|
||||
- `_root/` files require sudo for linking
|
||||
- Rejects root invocation
|
||||
- `_root/` dotfile paths require sudo for linking
|
||||
- Package post-install hooks run without sudo by default
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make deps
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
make deps # create .venv + install deps
|
||||
.venv/bin/python -m pytest tests/ -v # run tests
|
||||
```
|
||||
|
||||
281
docs/refactor-plan.md
Normal file
281
docs/refactor-plan.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -4,17 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from flow.core.config import load_config, load_manifest
|
||||
from flow.core import paths
|
||||
from flow.core.containers import ContainerRuntime
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core.runtime import CommandRunner
|
||||
from flow.domain.remote.resolution import HOST_TEMPLATES
|
||||
|
||||
ZSH_RC_START = "# >>> flow completion >>>"
|
||||
ZSH_RC_END = "# <<< flow completion <<<"
|
||||
CONTAINER_COMPLETION_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
TOP_LEVEL_COMMANDS = [
|
||||
"enter",
|
||||
@@ -56,23 +59,20 @@ def complete(words: Sequence[str], cword: int) -> list[str]:
|
||||
return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "--version"], current)
|
||||
|
||||
command = _canonical_command(before[0])
|
||||
|
||||
if command in {"enter", "remote"}:
|
||||
return _complete_remote(before, current)
|
||||
if command == "dev":
|
||||
return _complete_dev(before, current)
|
||||
if command == "dotfiles":
|
||||
return _complete_dotfiles(before, current)
|
||||
if command == "bootstrap":
|
||||
return _complete_bootstrap(before, current)
|
||||
if command == "packages":
|
||||
return _complete_packages(before, current)
|
||||
if command == "projects":
|
||||
return _complete_projects(before, current)
|
||||
if command == "completion":
|
||||
return _complete_completion(before, current)
|
||||
|
||||
return []
|
||||
completers = {
|
||||
"enter": _complete_remote,
|
||||
"remote": _complete_remote,
|
||||
"dev": _complete_dev,
|
||||
"dotfiles": _complete_dotfiles,
|
||||
"setup": _complete_setup,
|
||||
"packages": _complete_packages,
|
||||
"projects": _complete_projects,
|
||||
"completion": _complete_completion,
|
||||
}
|
||||
handler = completers.get(command)
|
||||
if handler is None:
|
||||
return []
|
||||
return handler(before, current)
|
||||
|
||||
|
||||
def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
|
||||
@@ -88,9 +88,9 @@ def _split_words(words: Sequence[str], cword: int) -> tuple[list[str], str]:
|
||||
def _canonical_command(command: str) -> str:
|
||||
aliases = {
|
||||
"dot": "dotfiles",
|
||||
"bootstrap": "bootstrap",
|
||||
"setup": "bootstrap",
|
||||
"provision": "bootstrap",
|
||||
"bootstrap": "setup",
|
||||
"setup": "setup",
|
||||
"provision": "setup",
|
||||
"package": "packages",
|
||||
"pkg": "packages",
|
||||
"project": "projects",
|
||||
@@ -205,32 +205,18 @@ def _list_dotfiles_packages(profile: str | None = None) -> list[str]:
|
||||
|
||||
|
||||
def _list_container_names() -> list[str]:
|
||||
runtime = None
|
||||
for candidate in ("docker", "podman"):
|
||||
if shutil.which(candidate):
|
||||
runtime = candidate
|
||||
break
|
||||
if runtime is None:
|
||||
try:
|
||||
config = _config()
|
||||
rt = ContainerRuntime(CommandRunner(), mode=config.container_runtime)
|
||||
output = rt.ps(
|
||||
all=True,
|
||||
filter="label=dev=true",
|
||||
format='{{.Label "dev.name"}}',
|
||||
timeout=CONTAINER_COMPLETION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (FlowError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
runtime,
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=dev=true",
|
||||
"--format",
|
||||
'{{.Label "dev.name"}}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()})
|
||||
return sorted({line.strip() for line in output.splitlines() if line.strip()})
|
||||
|
||||
|
||||
def _profile_from_before(before: Sequence[str]) -> str | None:
|
||||
@@ -302,7 +288,7 @@ def _complete_dev(before: Sequence[str], current: str) -> list[str]:
|
||||
def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(
|
||||
["init", "link", "relink", "unlink", "undo", "status", "clean", "sync", "modules", "repo", "repos", "edit"],
|
||||
["init", "link", "unlink", "status", "edit", "repo", "repos"],
|
||||
current,
|
||||
)
|
||||
|
||||
@@ -310,7 +296,7 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
|
||||
if subcommand == "init":
|
||||
return _filter(["--repo"], current) if current.startswith("-") else []
|
||||
|
||||
if subcommand in {"link", "relink"}:
|
||||
if subcommand == "link":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
@@ -322,43 +308,27 @@ def _complete_dotfiles(before: Sequence[str], current: str) -> list[str]:
|
||||
return _filter(["--dry-run"], current)
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
|
||||
if subcommand == "sync":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--relink", "--profile"], current)
|
||||
return []
|
||||
|
||||
if subcommand == "clean":
|
||||
return _filter(["--dry-run"], current) if current.startswith("-") else []
|
||||
|
||||
if subcommand == "edit":
|
||||
return _filter(_list_dotfiles_packages(), current) if not current.startswith("-") else []
|
||||
if current.startswith("-"):
|
||||
return _filter(["--no-commit"], current)
|
||||
return _filter(_list_dotfiles_packages(), current)
|
||||
|
||||
if subcommand in {"repo", "repos"}:
|
||||
if len(before) <= 2:
|
||||
return _filter(["status", "pull", "push"], current)
|
||||
return _filter(["list", "status", "pull", "push"], current)
|
||||
repo_subcommand = before[2]
|
||||
if repo_subcommand == "pull":
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
if repo_subcommand in {"pull", "push"}:
|
||||
if current.startswith("-"):
|
||||
return _filter(["--rebase", "--no-rebase", "--relink", "--profile"], current)
|
||||
return _filter(["--repo", "--dry-run"], current)
|
||||
if repo_subcommand == "status":
|
||||
if current.startswith("-"):
|
||||
return _filter(["--repo"], current)
|
||||
return []
|
||||
|
||||
if subcommand == "modules":
|
||||
if len(before) <= 2:
|
||||
return _filter(["list", "sync"], current)
|
||||
if before and before[-1] == "--profile":
|
||||
return _filter(_list_dotfiles_profiles(), current)
|
||||
if current.startswith("-"):
|
||||
return _filter(["--profile"], current)
|
||||
return _filter(_list_dotfiles_packages(_profile_from_before(before)), current)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _complete_bootstrap(before: Sequence[str], current: str) -> list[str]:
|
||||
def _complete_setup(before: Sequence[str], current: str) -> list[str]:
|
||||
if len(before) <= 1:
|
||||
return _filter(["run", "show", "list"], current)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
111
src/flow/core/config_parse.py
Normal file
111
src/flow/core/config_parse.py
Normal 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
176
src/flow/core/containers.py
Normal 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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
86
src/flow/core/tmux.py
Normal 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
83
src/flow/core/yaml.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SetupModule:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,17 +16,12 @@ from flow.domain.containers.resolution import (
|
||||
)
|
||||
|
||||
|
||||
def runtime() -> str:
|
||||
for name in ("docker", "podman"):
|
||||
if shutil.which(name):
|
||||
return name
|
||||
raise FlowError("No container runtime found (docker or podman)")
|
||||
|
||||
|
||||
class ContainerService:
|
||||
def __init__(self, ctx: FlowContext):
|
||||
self.ctx = ctx
|
||||
self.runner = ctx.runtime.runner
|
||||
self.rt = ctx.runtime.containers
|
||||
self.tmux = ctx.runtime.tmux
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -37,7 +32,6 @@ class ContainerService:
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Create and start a development container."""
|
||||
rt = runtime()
|
||||
spec = build_container_spec(
|
||||
name,
|
||||
parse_image_ref(
|
||||
@@ -49,11 +43,12 @@ class ContainerService:
|
||||
paths.HOME,
|
||||
project_path=project_path,
|
||||
dotfiles_dir=paths.DOTFILES_DIR,
|
||||
socket_path=self.rt.socket_path,
|
||||
),
|
||||
project_path=project_path,
|
||||
)
|
||||
|
||||
if self._container_exists(rt, spec.name):
|
||||
if not dry_run and self.rt.container_exists(spec.name):
|
||||
raise FlowError(f"Container already exists: {spec.name}")
|
||||
|
||||
self.ctx.console.info(f"Creating container: {spec.name}")
|
||||
@@ -62,116 +57,87 @@ class ContainerService:
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
cmd = [
|
||||
rt,
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
spec.name,
|
||||
"--network",
|
||||
spec.network,
|
||||
"--init",
|
||||
mount_flags = [
|
||||
f"{m.source}:{m.target}{':ro' if m.readonly else ''}"
|
||||
for m in spec.mounts
|
||||
]
|
||||
for key, value in spec.labels.items():
|
||||
cmd.extend(["--label", f"{key}={value}"])
|
||||
for mount in spec.mounts:
|
||||
cmd.extend(["-v", f"{mount.source}:{mount.target}{':ro' if mount.readonly else ''}"])
|
||||
cmd.extend([spec.image.full, "sleep", "infinity"])
|
||||
|
||||
self.runner.run(cmd, capture_output=False, check=True)
|
||||
security_opts = self.rt.socket_security_opts if self.rt.socket_path else []
|
||||
self.rt.run_container(
|
||||
spec.name,
|
||||
spec.image.full,
|
||||
network=spec.network,
|
||||
labels=spec.labels,
|
||||
mounts=mount_flags,
|
||||
security_opts=security_opts,
|
||||
command=["sleep", "infinity"],
|
||||
detach=True,
|
||||
)
|
||||
self.ctx.console.success(f"Created and started container: {spec.name}")
|
||||
|
||||
def exec(self, name: str, command: list[str] | None = None) -> None:
|
||||
"""Run a command or interactive shell inside a container."""
|
||||
rt = runtime()
|
||||
cname = container_name(name)
|
||||
if not self._container_running(rt, cname):
|
||||
if not self.rt.container_running(cname):
|
||||
raise FlowError(f"Container {cname} not running")
|
||||
|
||||
if command:
|
||||
argv = [rt, "exec"]
|
||||
if os.isatty(0):
|
||||
argv.extend(["-it"])
|
||||
argv.append(cname)
|
||||
argv.extend(command)
|
||||
result = self.runner.run(argv, capture_output=False)
|
||||
raise SystemExit(result.returncode)
|
||||
rc = self.rt.exec_in(cname, command, interactive=os.isatty(0))
|
||||
raise SystemExit(rc)
|
||||
|
||||
for shell in (["zsh", "-l"], ["bash", "-l"], ["sh"]):
|
||||
argv = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname, *shell]
|
||||
result = self.runner.run(argv, capture_output=False)
|
||||
if result.returncode not in (126, 127):
|
||||
raise SystemExit(result.returncode)
|
||||
rc = self.rt.exec_in(
|
||||
cname, shell, interactive=True, detach_keys="ctrl-q,ctrl-p",
|
||||
)
|
||||
if rc not in (126, 127):
|
||||
raise SystemExit(rc)
|
||||
|
||||
raise FlowError(f"Unable to start an interactive shell in {cname}")
|
||||
|
||||
def connect(self, name: str) -> None:
|
||||
"""Attach to the container tmux session."""
|
||||
rt = runtime()
|
||||
cname = container_name(name)
|
||||
|
||||
if not self._container_exists(rt, cname):
|
||||
if not self.rt.container_exists(cname):
|
||||
raise FlowError(f"Container does not exist: {cname}")
|
||||
if not self._container_running(rt, cname):
|
||||
self.runner.run([rt, "start", cname], capture_output=True, check=True)
|
||||
if not self.rt.container_running(cname):
|
||||
self.rt.start(cname)
|
||||
|
||||
if not shutil.which("tmux"):
|
||||
self.ctx.console.warn("tmux not found; falling back to direct exec")
|
||||
self.exec(name)
|
||||
return
|
||||
|
||||
inspect = self.runner.run(
|
||||
[rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"],
|
||||
check=True,
|
||||
)
|
||||
image_ref = parse_image_ref(inspect.stdout.strip())
|
||||
image_str = self.rt.inspect(cname, "{{ .Config.Image }}")
|
||||
image_ref = parse_image_ref(image_str)
|
||||
|
||||
has_session = self.runner.run(["tmux", "has-session", "-t", cname], check=False)
|
||||
if has_session.returncode != 0:
|
||||
self.runner.run(
|
||||
[
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-ds",
|
||||
cname,
|
||||
"-e",
|
||||
f"DF_IMAGE={image_ref.label}",
|
||||
f"flow dev exec {name}",
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
self.runner.run(
|
||||
["tmux", "set-option", "-t", cname, "default-command", f"flow dev exec {name}"],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
if not self.tmux.has_session(cname):
|
||||
self.tmux.new_session(
|
||||
cname,
|
||||
detached=True,
|
||||
env={"DF_IMAGE": image_ref.label},
|
||||
command=f"flow dev exec {name}",
|
||||
)
|
||||
self.tmux.set_option(cname, "default-command", f"flow dev exec {name}")
|
||||
|
||||
if os.environ.get("TMUX"):
|
||||
os.execvp("tmux", ["tmux", "switch-client", "-t", cname])
|
||||
os.execvp("tmux", ["tmux", "attach", "-t", cname])
|
||||
self.tmux.attach_or_switch(cname)
|
||||
|
||||
def stop(self, name: str, *, kill: bool = False) -> None:
|
||||
"""Stop a running container."""
|
||||
rt = runtime()
|
||||
cname = container_name(name)
|
||||
if not self._container_exists(rt, cname):
|
||||
if not self.rt.container_exists(cname):
|
||||
raise FlowError(f"Container {cname} does not exist")
|
||||
argv = [rt, "kill" if kill else "stop", cname]
|
||||
self.runner.run(argv, capture_output=False, check=True)
|
||||
if kill:
|
||||
self.rt.kill(cname)
|
||||
else:
|
||||
self.rt.stop(cname)
|
||||
self.ctx.console.success(f"Container {cname} stopped.")
|
||||
|
||||
def remove(self, name: str, *, force: bool = False) -> None:
|
||||
"""Remove a container."""
|
||||
rt = runtime()
|
||||
cname = container_name(name)
|
||||
if not self._container_exists(rt, cname):
|
||||
if not self.rt.container_exists(cname):
|
||||
raise FlowError(f"Container {cname} does not exist")
|
||||
argv = [rt, "rm"]
|
||||
if force:
|
||||
argv.append("-f")
|
||||
argv.append(cname)
|
||||
self.runner.run(argv, capture_output=False, check=True)
|
||||
self.rt.rm(cname, force=force)
|
||||
self.ctx.console.success(f"Container {cname} removed.")
|
||||
|
||||
def respawn(self, name: str) -> None:
|
||||
@@ -180,63 +146,27 @@ class ContainerService:
|
||||
raise FlowError("tmux is required for respawn but was not found")
|
||||
|
||||
cname = container_name(name)
|
||||
panes = self.runner.run(
|
||||
[
|
||||
"tmux",
|
||||
"list-panes",
|
||||
"-t",
|
||||
cname,
|
||||
"-s",
|
||||
"-F",
|
||||
"#{session_name}:#{window_index}.#{pane_index}",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
for pane in panes.stdout.strip().splitlines():
|
||||
if not pane:
|
||||
continue
|
||||
for pane in self.tmux.list_panes(cname):
|
||||
self.ctx.console.info(f"Respawning {pane}...")
|
||||
self.runner.run(["tmux", "respawn-pane", "-t", pane], capture_output=False, check=True)
|
||||
self.tmux.respawn_pane(pane)
|
||||
|
||||
def list(self) -> None:
|
||||
"""List flow-managed containers."""
|
||||
rt = runtime()
|
||||
result = self.runner.run(
|
||||
[
|
||||
rt,
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=dev=true",
|
||||
"--format",
|
||||
'{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}',
|
||||
],
|
||||
check=True,
|
||||
result = self.rt.ps(
|
||||
all=True,
|
||||
filter="label=dev=true",
|
||||
format='{{.Label "dev.name"}}\t{{.Image}}\t{{.Label "dev.project_path"}}\t{{.Status}}',
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
if not result:
|
||||
self.ctx.console.info("No flow containers found.")
|
||||
return
|
||||
|
||||
rows = []
|
||||
home = str(paths.HOME)
|
||||
for line in result.stdout.strip().splitlines():
|
||||
for line in result.splitlines():
|
||||
name, image, project, status = (line.split("\t") + ["", "", "", ""])[:4]
|
||||
if project.startswith(home):
|
||||
project = "~" + project[len(home):]
|
||||
rows.append([name, image, project or "-", status])
|
||||
|
||||
self.ctx.console.table(["NAME", "IMAGE", "PROJECT", "STATUS"], rows)
|
||||
|
||||
def _container_exists(self, rt: str, name: str) -> bool:
|
||||
result = self.runner.run(
|
||||
[rt, "container", "ls", "-a", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
)
|
||||
return name in result.stdout.strip().splitlines()
|
||||
|
||||
def _container_running(self, rt: str, name: str) -> bool:
|
||||
result = self.runner.run(
|
||||
[rt, "container", "ls", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
)
|
||||
return name in result.stdout.strip().splitlines()
|
||||
|
||||
@@ -2,32 +2,50 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from typing import Optional
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.core.errors import FlowError, PlanConflict
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core.yaml import load_yaml_file
|
||||
from flow.core import paths
|
||||
from flow.domain.dotfiles.models import (
|
||||
LinkedState,
|
||||
LinkPlan,
|
||||
LinkTarget,
|
||||
ModuleRef,
|
||||
Package,
|
||||
RepoInfo,
|
||||
)
|
||||
from flow.domain.dotfiles.modules import (
|
||||
compute_mount_path,
|
||||
normalize_source,
|
||||
parse_module_ref,
|
||||
)
|
||||
from flow.domain.dotfiles.planning import plan_link, plan_unlink
|
||||
from flow.domain.dotfiles.resolution import resolve_all_targets
|
||||
|
||||
|
||||
# Maps ref_type to its git checkout prefix. Branch and commit use the
|
||||
# value directly; tags need the "tags/" prefix for detached checkout.
|
||||
_REF_TYPE_PREFIXES = {
|
||||
"branch": "",
|
||||
"tag": "tags/",
|
||||
"commit": "",
|
||||
}
|
||||
|
||||
|
||||
def _git_checkout_ref(module: ModuleRef) -> str:
|
||||
"""Resolve a ModuleRef to its git checkout ref string."""
|
||||
prefix = _REF_TYPE_PREFIXES.get(module.ref_type)
|
||||
if prefix is None:
|
||||
raise FlowError(f"Unknown ref_type {module.ref_type!r}")
|
||||
return f"{prefix}{module.ref_value}"
|
||||
|
||||
|
||||
MODULE_FILE = "_module.yaml"
|
||||
SKIP_DIRS = {".git", ".github", "__pycache__", "flow"}
|
||||
SKIP_FILES = {".DS_Store", ".gitkeep"}
|
||||
SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"}
|
||||
|
||||
|
||||
class DotfilesService:
|
||||
@@ -36,6 +54,8 @@ class DotfilesService:
|
||||
self.dotfiles_dir = paths.DOTFILES_DIR
|
||||
self.modules_dir = paths.MODULES_DIR
|
||||
|
||||
# ── Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
def link(
|
||||
self,
|
||||
*,
|
||||
@@ -43,7 +63,8 @@ class DotfilesService:
|
||||
dry_run: bool = False,
|
||||
skip: Optional[set[str]] = None,
|
||||
) -> None:
|
||||
"""Link dotfiles to home directory."""
|
||||
"""Reconcile dotfile symlinks. Creates, updates, removes stale, and
|
||||
repairs broken symlinks in a single pass."""
|
||||
skip_set = skip or set()
|
||||
packages = self._discover_packages(profile)
|
||||
|
||||
@@ -51,13 +72,8 @@ class DotfilesService:
|
||||
self.ctx.console.info("No packages found.")
|
||||
return
|
||||
|
||||
# Resolve all targets
|
||||
targets = resolve_all_targets(packages, paths.HOME, skip_set)
|
||||
|
||||
# Load current state
|
||||
current = self._load_state()
|
||||
|
||||
# Build plan
|
||||
plan = plan_link(targets, current, self._filesystem_check)
|
||||
|
||||
if plan.conflicts:
|
||||
@@ -76,15 +92,17 @@ class DotfilesService:
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
self._save_backup(current)
|
||||
new_state = self._apply_plan(plan, targets, current)
|
||||
|
||||
self._save_state(new_state)
|
||||
self.ctx.console.success(
|
||||
f"Linked: {plan.summary.added} added, "
|
||||
f"{plan.summary.removed} removed, "
|
||||
f"{plan.summary.unchanged} unchanged"
|
||||
)
|
||||
parts = []
|
||||
if plan.summary.added:
|
||||
parts.append(f"{plan.summary.added} added")
|
||||
if plan.summary.updated:
|
||||
parts.append(f"{plan.summary.updated} updated")
|
||||
if plan.summary.removed:
|
||||
parts.append(f"{plan.summary.removed} removed")
|
||||
parts.append(f"{plan.summary.unchanged} unchanged")
|
||||
self.ctx.console.success(f"Linked: {', '.join(parts)}")
|
||||
|
||||
def unlink(
|
||||
self,
|
||||
@@ -109,7 +127,6 @@ class DotfilesService:
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
self._save_backup(current)
|
||||
new_state = LinkedState(links=dict(current.links))
|
||||
for op in plan.operations:
|
||||
self.ctx.runtime.fs.remove_file(
|
||||
@@ -123,35 +140,94 @@ class DotfilesService:
|
||||
self._save_state(new_state)
|
||||
self.ctx.console.success(f"Unlinked {plan.summary.removed} file(s).")
|
||||
|
||||
def status(self) -> None:
|
||||
"""Show linked dotfiles status."""
|
||||
# ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
def status(self, package_filter: Optional[list[str]] = None) -> None:
|
||||
"""Show linked dotfiles status with module info and link health."""
|
||||
state = self._load_state()
|
||||
if not state.links:
|
||||
self.ctx.console.info("No managed links.")
|
||||
all_packages = self._discover_packages(profile=None, include_all_layers=True)
|
||||
|
||||
if not state.links and not all_packages:
|
||||
self.ctx.console.info("No managed links or packages.")
|
||||
return
|
||||
|
||||
# Group by package
|
||||
by_package: dict[str, list[LinkTarget]] = {}
|
||||
for lt in state.links.values():
|
||||
by_package.setdefault(lt.package, []).append(lt)
|
||||
|
||||
rows: list[list[str]] = []
|
||||
for pkg_id in sorted(by_package):
|
||||
links = by_package[pkg_id]
|
||||
rows.append([pkg_id, str(len(links)), "linked"])
|
||||
for pkg in all_packages:
|
||||
if package_filter:
|
||||
name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id
|
||||
if pkg.package_id not in package_filter and name not in package_filter:
|
||||
continue
|
||||
|
||||
self.ctx.console.table(["PACKAGE", "FILES", "STATUS"], rows)
|
||||
links = by_package.get(pkg.package_id, [])
|
||||
link_count = str(len(links))
|
||||
|
||||
def edit(self, package_name: str) -> None:
|
||||
"""Open package directory in editor."""
|
||||
pkg_dir = self.dotfiles_dir / "_shared" / package_name
|
||||
if not pkg_dir.is_dir():
|
||||
raise FlowError(f"Package not found: {package_name}")
|
||||
module_col = ""
|
||||
if pkg.module:
|
||||
module_col = f"{pkg.module.ref_type}:{pkg.module.ref_value}"
|
||||
|
||||
self.ctx.console.info(f"Package directory: {pkg_dir}")
|
||||
if not links:
|
||||
health = "not linked"
|
||||
else:
|
||||
broken = sum(
|
||||
1 for lt in links
|
||||
if lt.target.is_symlink() and not lt.target.exists()
|
||||
)
|
||||
health = f"{broken} broken" if broken else "ok"
|
||||
|
||||
rows.append([pkg.package_id, link_count, health, module_col])
|
||||
|
||||
if not rows:
|
||||
self.ctx.console.info("No packages match filter.")
|
||||
return
|
||||
|
||||
self.ctx.console.table(["PACKAGE", "FILES", "HEALTH", "MODULE"], rows)
|
||||
|
||||
# ── Edit ─────────────────────────────────────────────────────────────
|
||||
|
||||
def edit(self, package_name: str, *, no_commit: bool = False) -> None:
|
||||
"""Pull repo -> open $EDITOR -> commit+push if changed."""
|
||||
pkg, repo = self._find_package_repo(package_name)
|
||||
|
||||
# Pull before editing (if configured)
|
||||
if self.ctx.config.dotfiles_pull_before_edit and repo.path.is_dir():
|
||||
self._pull_or_clone_repo(repo)
|
||||
|
||||
# Open editor
|
||||
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi")
|
||||
edit_dir = repo.path if repo.is_module else pkg.source_dir
|
||||
result = self.ctx.runtime.runner.run(
|
||||
[editor, str(edit_dir)], capture_output=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise FlowError(f"Editor exited with code {result.returncode}")
|
||||
|
||||
if no_commit:
|
||||
return
|
||||
|
||||
# Check for changes and auto-commit+push
|
||||
status = self.ctx.runtime.git.run(
|
||||
repo.path, "status", "--porcelain", check=True,
|
||||
)
|
||||
if not status.stdout.strip():
|
||||
self.ctx.console.info("No changes.")
|
||||
return
|
||||
|
||||
self.ctx.runtime.git.run(repo.path, "add", str(edit_dir), check=True)
|
||||
self.ctx.runtime.git.run(
|
||||
repo.path, "commit", "-m", f"dotfiles: update {package_name}",
|
||||
check=True,
|
||||
)
|
||||
self.ctx.runtime.git.run(repo.path, "push", check=True)
|
||||
self.ctx.console.success(f"Changes to {package_name} committed and pushed.")
|
||||
|
||||
# ── Init ─────────────────────────────────────────────────────────────
|
||||
|
||||
def init(self, repo_url: Optional[str] = None) -> None:
|
||||
"""Clone the dotfiles repository."""
|
||||
"""Clone the dotfiles repository and all module repos."""
|
||||
remote = repo_url or self.ctx.config.dotfiles_url
|
||||
if not remote:
|
||||
raise FlowError("No dotfiles URL configured")
|
||||
@@ -172,185 +248,176 @@ class DotfilesService:
|
||||
str(self.dotfiles_dir),
|
||||
check=True,
|
||||
)
|
||||
self.sync_modules()
|
||||
self.repos_pull()
|
||||
self.ctx.console.success(f"Dotfiles cloned to {self.dotfiles_dir}")
|
||||
|
||||
def sync(self, *, profile: Optional[str] = None, relink: bool = False) -> None:
|
||||
"""Pull latest dotfiles and sync modules."""
|
||||
if not self.dotfiles_dir.is_dir():
|
||||
self.init()
|
||||
else:
|
||||
self.ctx.console.info("Pulling latest dotfiles...")
|
||||
self.ctx.runtime.git.run(
|
||||
self.dotfiles_dir, "pull", "--ff-only", check=True,
|
||||
)
|
||||
# ── Repos (unified: dotfiles + modules) ──────────────────────────────
|
||||
|
||||
self.sync_modules(profile=profile)
|
||||
if relink:
|
||||
self.relink(profile=profile)
|
||||
|
||||
def list_modules(self, *, profile: Optional[str] = None) -> None:
|
||||
"""List detected module packages."""
|
||||
packages = self._discover_packages(
|
||||
profile=profile,
|
||||
include_all_layers=profile is None,
|
||||
)
|
||||
module_packages = [pkg for pkg in packages if pkg.module is not None]
|
||||
if not module_packages:
|
||||
self.ctx.console.info("No module packages found.")
|
||||
def repos_list(self) -> None:
|
||||
"""List all managed repos (dotfiles + modules)."""
|
||||
repos = self._discover_repos()
|
||||
if not repos:
|
||||
self.ctx.console.info("No managed repos.")
|
||||
return
|
||||
|
||||
rows = []
|
||||
for pkg in module_packages:
|
||||
assert pkg.module is not None
|
||||
status = "ready" if pkg.module.cache_dir.exists() else "missing"
|
||||
rows.append([
|
||||
pkg.package_id,
|
||||
f"{pkg.module.ref_type}:{pkg.module.ref_value}",
|
||||
pkg.module.source,
|
||||
status,
|
||||
])
|
||||
self.ctx.console.table(["PACKAGE", "REF", "SOURCE", "STATUS"], rows)
|
||||
for repo in repos:
|
||||
status = "cloned" if repo.path.is_dir() else "missing"
|
||||
kind = "module" if repo.is_module else "dotfiles"
|
||||
rows.append([repo.name, kind, str(repo.path), status])
|
||||
|
||||
def sync_modules(self, *, profile: Optional[str] = None) -> None:
|
||||
"""Clone or update module repositories."""
|
||||
packages = self._discover_packages(
|
||||
profile=profile,
|
||||
include_all_layers=profile is None,
|
||||
)
|
||||
for pkg in packages:
|
||||
if pkg.module:
|
||||
self._sync_module(pkg)
|
||||
self.ctx.console.table(["NAME", "TYPE", "PATH", "STATUS"], rows)
|
||||
|
||||
def repo_status(self) -> None:
|
||||
"""Show git status for the dotfiles repository."""
|
||||
if not self.dotfiles_dir.is_dir():
|
||||
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
|
||||
result = self.ctx.runtime.git.run(
|
||||
self.dotfiles_dir,
|
||||
"status",
|
||||
"--short",
|
||||
"--branch",
|
||||
check=True,
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
if output:
|
||||
print(output)
|
||||
return
|
||||
self.ctx.console.info("Dotfiles repository is clean.")
|
||||
|
||||
def repo_pull(
|
||||
self,
|
||||
*,
|
||||
profile: Optional[str] = None,
|
||||
relink: bool = False,
|
||||
rebase: bool = True,
|
||||
) -> None:
|
||||
"""Pull the dotfiles repository and refresh modules."""
|
||||
if not self.dotfiles_dir.is_dir():
|
||||
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
|
||||
argv = ["pull"]
|
||||
argv.append("--rebase" if rebase else "--ff-only")
|
||||
self.ctx.runtime.git.run(self.dotfiles_dir, *argv, check=True)
|
||||
self.sync_modules(profile=profile)
|
||||
if relink:
|
||||
self.relink(profile=profile)
|
||||
|
||||
def repo_push(self) -> None:
|
||||
"""Push the dotfiles repository."""
|
||||
if not self.dotfiles_dir.is_dir():
|
||||
raise FlowError(f"Dotfiles directory not found: {self.dotfiles_dir}")
|
||||
self.ctx.runtime.git.run(self.dotfiles_dir, "push", check=True)
|
||||
self.ctx.console.success("Dotfiles pushed.")
|
||||
|
||||
def relink(self, *, profile: Optional[str] = None) -> None:
|
||||
"""Refresh symlinks for the selected profile."""
|
||||
self.link(profile=profile)
|
||||
|
||||
def clean(self, *, dry_run: bool = False) -> None:
|
||||
"""Remove broken symlinks from managed state."""
|
||||
current = self._load_state()
|
||||
broken = [
|
||||
target for target in sorted(current.links)
|
||||
if target.is_symlink() and not target.exists()
|
||||
]
|
||||
if not broken:
|
||||
self.ctx.console.info("No broken symlinks found.")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
for target in broken:
|
||||
self.ctx.console.info(f"Would remove broken symlink: {target}")
|
||||
return
|
||||
|
||||
self._save_backup(current)
|
||||
for target in broken:
|
||||
link = current.links[target]
|
||||
self.ctx.runtime.fs.remove_file(
|
||||
target,
|
||||
sudo=link.needs_sudo,
|
||||
runner=self.ctx.runtime.runner if link.needs_sudo else None,
|
||||
missing_ok=True,
|
||||
def repos_status(self, repo_filter: Optional[str] = None) -> None:
|
||||
"""Show git status for managed repos."""
|
||||
for repo in self._filter_repos(repo_filter):
|
||||
if not repo.path.is_dir():
|
||||
self.ctx.console.warn(f"{repo.name}: not cloned")
|
||||
continue
|
||||
self.ctx.console.info(f"[{repo.name}]")
|
||||
result = self.ctx.runtime.git.run(
|
||||
repo.path, "status", "--short", "--branch", check=True,
|
||||
)
|
||||
current.links.pop(target, None)
|
||||
self._save_state(current)
|
||||
self.ctx.console.success(f"Cleaned {len(broken)} broken symlink(s).")
|
||||
output = result.stdout.strip()
|
||||
if output:
|
||||
self.ctx.console.info(output)
|
||||
else:
|
||||
self.ctx.console.info(" clean")
|
||||
|
||||
def undo(self) -> None:
|
||||
"""Restore the previous linked state."""
|
||||
previous = self._load_backup()
|
||||
if previous is None:
|
||||
self.ctx.console.info("No dotfiles link transaction to undo.")
|
||||
return
|
||||
def repos_pull(
|
||||
self,
|
||||
repo_filter: Optional[str] = None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Pull (or clone) managed repos."""
|
||||
for repo in self._filter_repos(repo_filter):
|
||||
if dry_run:
|
||||
action = "pull" if repo.path.is_dir() else "clone"
|
||||
self.ctx.console.info(f"Would {action}: {repo.name} ({repo.source})")
|
||||
continue
|
||||
self._pull_or_clone_repo(repo)
|
||||
|
||||
current = self._load_state()
|
||||
desired = list(previous.links.values())
|
||||
plan = plan_link(desired, current, self._filesystem_check)
|
||||
if not plan.operations:
|
||||
self.ctx.console.info("Nothing to undo.")
|
||||
return
|
||||
def repos_push(
|
||||
self,
|
||||
repo_filter: Optional[str] = None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Push managed repos."""
|
||||
for repo in self._filter_repos(repo_filter):
|
||||
if not repo.path.is_dir():
|
||||
self.ctx.console.warn(f"{repo.name}: not cloned, skipping")
|
||||
continue
|
||||
if dry_run:
|
||||
self.ctx.console.info(f"Would push: {repo.name}")
|
||||
continue
|
||||
self.ctx.runtime.git.run(repo.path, "push", check=True)
|
||||
self.ctx.console.success(f"Pushed: {repo.name}")
|
||||
|
||||
self.ctx.console.print_plan(plan.operations, verb="undo")
|
||||
self._save_backup(current)
|
||||
restored = self._apply_plan(plan, desired, current)
|
||||
self._save_state(restored)
|
||||
self.ctx.console.success("Dotfiles state restored.")
|
||||
# ── Repo discovery ───────────────────────────────────────────────────
|
||||
|
||||
def _sync_module(self, pkg: Package) -> None:
|
||||
"""Clone or update a module."""
|
||||
module = pkg.module
|
||||
assert module is not None
|
||||
cache_dir = module.cache_dir
|
||||
def _discover_repos(self) -> list[RepoInfo]:
|
||||
"""Return all managed repos: dotfiles repo + module repos."""
|
||||
repos: list[RepoInfo] = []
|
||||
|
||||
if cache_dir.is_dir():
|
||||
self.ctx.console.info(f" Updating module: {pkg.package_id}")
|
||||
self.ctx.runtime.git.run(cache_dir, "fetch", "--all", check=True)
|
||||
if module.ref_type == "branch":
|
||||
remote = self.ctx.config.dotfiles_url
|
||||
if remote or self.dotfiles_dir.is_dir():
|
||||
repos.append(RepoInfo(
|
||||
name="dotfiles",
|
||||
path=self.dotfiles_dir,
|
||||
source=remote or "",
|
||||
is_module=False,
|
||||
))
|
||||
|
||||
packages = self._discover_packages(profile=None, include_all_layers=True)
|
||||
seen: set[str] = set()
|
||||
for pkg in packages:
|
||||
if pkg.module and pkg.module.source not in seen:
|
||||
seen.add(pkg.module.source)
|
||||
repos.append(RepoInfo(
|
||||
name=pkg.name,
|
||||
path=pkg.module.cache_dir,
|
||||
source=pkg.module.source,
|
||||
is_module=True,
|
||||
module_ref=pkg.module,
|
||||
))
|
||||
|
||||
return repos
|
||||
|
||||
def _filter_repos(self, repo_filter: Optional[str]) -> list[RepoInfo]:
|
||||
"""Filter repos by name. Returns all if filter is None."""
|
||||
repos = self._discover_repos()
|
||||
if repo_filter is None:
|
||||
return repos
|
||||
matched = [r for r in repos if r.name == repo_filter]
|
||||
if not matched:
|
||||
raise FlowError(f"No repo named {repo_filter!r}. "
|
||||
f"Available: {', '.join(r.name for r in repos)}")
|
||||
return matched
|
||||
|
||||
def _pull_or_clone_repo(self, repo: RepoInfo) -> None:
|
||||
"""Pull an existing repo or clone it if missing."""
|
||||
if repo.path.is_dir():
|
||||
self.ctx.console.info(f"Pulling: {repo.name}")
|
||||
if repo.is_module:
|
||||
self._pull_module_repo(repo)
|
||||
else:
|
||||
self.ctx.runtime.git.run(
|
||||
cache_dir, "checkout", module.ref_value, check=True,
|
||||
)
|
||||
self.ctx.runtime.git.run(
|
||||
cache_dir, "pull", "--ff-only", check=True,
|
||||
)
|
||||
elif module.ref_type == "tag":
|
||||
self.ctx.runtime.git.run(
|
||||
cache_dir, "checkout", f"tags/{module.ref_value}", check=True,
|
||||
)
|
||||
elif module.ref_type == "commit":
|
||||
self.ctx.runtime.git.run(
|
||||
cache_dir, "checkout", module.ref_value, check=True,
|
||||
repo.path, "pull", "--ff-only", check=True,
|
||||
)
|
||||
else:
|
||||
self.ctx.console.info(f" Cloning module: {pkg.package_id}")
|
||||
if not repo.source:
|
||||
raise FlowError(f"No source URL for repo {repo.name!r}")
|
||||
self.ctx.console.info(f"Cloning: {repo.name}")
|
||||
self.ctx.runtime.runner.run(
|
||||
["git", "clone", module.source, str(cache_dir)],
|
||||
["git", "clone", repo.source, str(repo.path)],
|
||||
check=True,
|
||||
)
|
||||
if module.ref_type != "branch" or module.ref_value != "main":
|
||||
ref = module.ref_value
|
||||
if module.ref_type == "tag":
|
||||
ref = f"tags/{ref}"
|
||||
self.ctx.runtime.git.run(cache_dir, "checkout", ref, check=True)
|
||||
if repo.is_module:
|
||||
self._checkout_module_ref(repo)
|
||||
|
||||
def _pull_module_repo(self, repo: RepoInfo) -> None:
|
||||
"""Pull a module repo, respecting its ref type."""
|
||||
module = repo.module_ref
|
||||
if module is None:
|
||||
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
|
||||
return
|
||||
|
||||
self.ctx.runtime.git.run(repo.path, "fetch", "--all", check=True)
|
||||
ref = _git_checkout_ref(module)
|
||||
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
|
||||
if module.ref_type == "branch":
|
||||
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
|
||||
|
||||
def _checkout_module_ref(self, repo: RepoInfo) -> None:
|
||||
"""Checkout the correct ref after cloning a module repo."""
|
||||
module = repo.module_ref
|
||||
if module is None:
|
||||
return
|
||||
if module.ref_type == "branch" and module.ref_value == "main":
|
||||
return
|
||||
ref = _git_checkout_ref(module)
|
||||
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
|
||||
|
||||
def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]:
|
||||
"""Find a package and its owning repo."""
|
||||
repos = self._discover_repos()
|
||||
repo_by_name = {r.name: r for r in repos}
|
||||
dotfiles_repo = repo_by_name.get("dotfiles")
|
||||
|
||||
packages = self._discover_packages(profile=None, include_all_layers=True)
|
||||
for pkg in packages:
|
||||
name = pkg.package_id.split("/", 1)[-1] if "/" in pkg.package_id else pkg.package_id
|
||||
if name == package_name or pkg.package_id == package_name:
|
||||
if pkg.module and pkg.name in repo_by_name:
|
||||
return pkg, repo_by_name[pkg.name]
|
||||
if dotfiles_repo is not None:
|
||||
return pkg, dotfiles_repo
|
||||
raise FlowError(f"No repo found for package: {package_name}")
|
||||
raise FlowError(f"Package not found: {package_name}")
|
||||
|
||||
# ── Package discovery ────────────────────────────────────────────────
|
||||
|
||||
def _discover_packages(
|
||||
self,
|
||||
@@ -388,11 +455,7 @@ class DotfilesService:
|
||||
continue
|
||||
|
||||
package_id = f"{layer}/{pkg_dir.name}"
|
||||
|
||||
# Find _module.yaml (if any)
|
||||
module_ref = self._find_module(pkg_dir, package_id)
|
||||
|
||||
# Collect local files
|
||||
local_files = self._collect_files(pkg_dir)
|
||||
|
||||
packages.append(Package(
|
||||
@@ -407,20 +470,13 @@ class DotfilesService:
|
||||
return packages
|
||||
|
||||
def _find_module(self, pkg_dir: Path, package_id: str) -> Optional[ModuleRef]:
|
||||
"""Find and parse _module.yaml in a package directory."""
|
||||
for module_yaml in pkg_dir.rglob(MODULE_FILE):
|
||||
try:
|
||||
with open(module_yaml, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except (OSError, yaml.YAMLError) as e:
|
||||
from flow.core.errors import ConfigError
|
||||
raise ConfigError(f"Failed to read {module_yaml}: {e}") from e
|
||||
"""Find and parse _module.yaml in a package directory (first match wins)."""
|
||||
for module_yaml in sorted(pkg_dir.rglob(MODULE_FILE)):
|
||||
raw = load_yaml_file(module_yaml)
|
||||
|
||||
mount_path = compute_mount_path(module_yaml, pkg_dir)
|
||||
|
||||
ref = parse_module_ref(raw, package_id, mount_path, self.modules_dir)
|
||||
|
||||
# If module is cloned, populate module_files
|
||||
if ref.cache_dir.is_dir():
|
||||
module_files = self._collect_files(ref.cache_dir)
|
||||
ref = ModuleRef(
|
||||
@@ -443,31 +499,27 @@ class DotfilesService:
|
||||
continue
|
||||
if path.name in SKIP_FILES:
|
||||
continue
|
||||
# Skip .git contents
|
||||
try:
|
||||
path.relative_to(root_dir / ".git")
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
rel = path.relative_to(root_dir)
|
||||
if any(part in SKIP_DIRS for part in rel.parts):
|
||||
continue
|
||||
files.append((path, rel))
|
||||
return files
|
||||
|
||||
def _filesystem_check(self, path: Path) -> Optional[str]:
|
||||
"""Check what exists at a path. Returns type or None."""
|
||||
if path.is_symlink():
|
||||
return "symlink"
|
||||
return "broken_symlink" if not path.exists() else "symlink"
|
||||
if path.is_file():
|
||||
return "file"
|
||||
if path.is_dir():
|
||||
return "dir"
|
||||
return None
|
||||
|
||||
# ── State persistence ────────────────────────────────────────────────
|
||||
|
||||
def _load_state(self) -> LinkedState:
|
||||
"""Load linked state from disk."""
|
||||
data = self.ctx.runtime.fs.read_json(paths.LINKED_STATE, default={})
|
||||
if data is None:
|
||||
data = {}
|
||||
state = LinkedState.from_dict(data)
|
||||
reconciled = LinkedState(
|
||||
links={
|
||||
@@ -484,21 +536,9 @@ class DotfilesService:
|
||||
"""Save linked state to disk."""
|
||||
self.ctx.runtime.fs.write_json(paths.LINKED_STATE, state.as_dict())
|
||||
|
||||
def _save_backup(self, state: LinkedState) -> None:
|
||||
self.ctx.runtime.fs.write_json(self._backup_path(), state.as_dict())
|
||||
|
||||
def _load_backup(self) -> Optional[LinkedState]:
|
||||
data = self.ctx.runtime.fs.read_json(self._backup_path(), default=None)
|
||||
if data is None:
|
||||
return None
|
||||
return LinkedState.from_dict(data)
|
||||
|
||||
def _backup_path(self) -> Path:
|
||||
return paths.LINKED_STATE.with_name("linked.previous.json")
|
||||
|
||||
def _apply_plan(
|
||||
self,
|
||||
plan,
|
||||
plan: LinkPlan,
|
||||
targets: list[LinkTarget],
|
||||
current: LinkedState,
|
||||
) -> LinkedState:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
36
tests/fakes.py
Normal 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="")
|
||||
@@ -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():
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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"
|
||||
|
||||
71
tests/test_core_config_parse.py
Normal file
71
tests/test_core_config_parse.py
Normal 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")
|
||||
271
tests/test_core_containers.py
Normal file
271
tests/test_core_containers.py
Normal 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
|
||||
@@ -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
97
tests/test_core_tmux.py
Normal 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
113
tests/test_core_yaml.py
Normal 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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
393
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user