feat: rewrite core layer (errors, template, paths, platform, console, runtime, config)
Complete rewrite of all core modules with proper abstractions:
- FlowError hierarchy with PlanConflict and ExecutionError
- Pure template substitution ($VAR, ${VAR}, {{expr}})
- XDG path constants
- Frozen PlatformInfo dataclass with context detection
- Console with color/quiet/TTY support
- Runtime primitives (CommandRunner, FileSystem, GitClient, SystemRuntime)
- Config loading with target parsing and manifest merging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
tests/test_core_config.py
Normal file
77
tests/test_core_config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for flow.core.config."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.config import AppConfig, load_config, load_manifest
|
||||
|
||||
|
||||
def test_load_config_missing_path(tmp_path):
|
||||
cfg = load_config(tmp_path / "nonexistent")
|
||||
assert isinstance(cfg, AppConfig)
|
||||
assert cfg.dotfiles_url == ""
|
||||
assert cfg.container_registry == "registry.tomastm.com"
|
||||
|
||||
|
||||
def test_load_config_from_yaml(tmp_path):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"repository:\n"
|
||||
" url: git@github.com:user/dots.git\n"
|
||||
" branch: dev\n"
|
||||
"paths:\n"
|
||||
" projects: ~/code\n"
|
||||
"defaults:\n"
|
||||
" container-registry: my.registry.com\n"
|
||||
" tmux-session: main\n"
|
||||
)
|
||||
cfg = load_config(tmp_path)
|
||||
assert cfg.dotfiles_url == "git@github.com:user/dots.git"
|
||||
assert cfg.dotfiles_branch == "dev"
|
||||
assert cfg.projects_dir == "~/code"
|
||||
assert cfg.container_registry == "my.registry.com"
|
||||
assert cfg.tmux_session == "main"
|
||||
|
||||
|
||||
def test_load_config_parses_targets_shorthand(tmp_path):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"targets:\n"
|
||||
" personal@orb: personal.orb\n"
|
||||
)
|
||||
cfg = load_config(tmp_path)
|
||||
assert len(cfg.targets) == 1
|
||||
assert cfg.targets[0].namespace == "personal"
|
||||
assert cfg.targets[0].platform == "orb"
|
||||
assert cfg.targets[0].host == "personal.orb"
|
||||
|
||||
|
||||
def test_load_config_parses_targets_dict(tmp_path):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"targets:\n"
|
||||
" work@ec2:\n"
|
||||
" host: work.ec2.internal\n"
|
||||
" identity: ~/.ssh/id_work\n"
|
||||
)
|
||||
cfg = load_config(tmp_path)
|
||||
assert len(cfg.targets) == 1
|
||||
assert cfg.targets[0].host == "work.ec2.internal"
|
||||
assert cfg.targets[0].identity == "~/.ssh/id_work"
|
||||
|
||||
|
||||
def test_load_manifest_returns_dict(tmp_path):
|
||||
(tmp_path / "manifest.yaml").write_text(
|
||||
"packages:\n"
|
||||
" - name: fd\n"
|
||||
" type: pkg\n"
|
||||
)
|
||||
data = load_manifest(tmp_path)
|
||||
assert isinstance(data, dict)
|
||||
assert "packages" in data
|
||||
|
||||
|
||||
def test_load_manifest_merges_files(tmp_path):
|
||||
(tmp_path / "01-packages.yaml").write_text("packages:\n - name: fd\n type: pkg\n")
|
||||
(tmp_path / "02-profiles.yaml").write_text("profiles:\n work:\n os: linux\n")
|
||||
data = load_manifest(tmp_path)
|
||||
assert "packages" in data
|
||||
assert "profiles" in data
|
||||
38
tests/test_core_console.py
Normal file
38
tests/test_core_console.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Tests for flow.core.console."""
|
||||
|
||||
from flow.core.console import Console
|
||||
|
||||
|
||||
def test_info_prints_message(capsys):
|
||||
c = Console(color=False)
|
||||
c.info("hello")
|
||||
assert "hello" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_quiet_suppresses_info(capsys):
|
||||
c = Console(quiet=True, color=False)
|
||||
c.info("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_quiet_does_not_suppress_error(capsys):
|
||||
c = Console(quiet=True, color=False)
|
||||
c.error("visible")
|
||||
captured = capsys.readouterr()
|
||||
assert "visible" in captured.err or "visible" in captured.out
|
||||
|
||||
|
||||
def test_table_prints_headers_and_rows(capsys):
|
||||
c = Console(color=False)
|
||||
c.table(["NAME", "STATUS"], [["foo", "ok"], ["bar", "fail"]])
|
||||
output = capsys.readouterr().out
|
||||
assert "NAME" in output
|
||||
assert "foo" in output
|
||||
assert "bar" in output
|
||||
|
||||
|
||||
def test_no_color_strips_ansi(capsys):
|
||||
c = Console(color=False)
|
||||
c.info("test")
|
||||
output = capsys.readouterr().out
|
||||
assert "\033[" not in output
|
||||
40
tests/test_core_paths.py
Normal file
40
tests/test_core_paths.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for flow.core.paths."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flow.core import paths
|
||||
|
||||
|
||||
def test_config_dir_ends_with_flow():
|
||||
assert paths.CONFIG_DIR.name == "flow"
|
||||
|
||||
|
||||
def test_data_dir_ends_with_flow():
|
||||
assert paths.DATA_DIR.name == "flow"
|
||||
|
||||
|
||||
def test_modules_dir_under_data():
|
||||
assert paths.MODULES_DIR.parent == paths.DATA_DIR
|
||||
|
||||
|
||||
def test_linked_state_under_state():
|
||||
assert paths.LINKED_STATE.parent == paths.STATE_DIR
|
||||
|
||||
|
||||
def test_dotfiles_flow_config_path():
|
||||
expected_suffix = Path("_shared") / "flow" / ".config" / "flow"
|
||||
assert str(paths.DOTFILES_FLOW_CONFIG).endswith(str(expected_suffix))
|
||||
|
||||
|
||||
def test_ensure_dirs_creates_directories(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(paths, "CONFIG_DIR", tmp_path / "config" / "flow")
|
||||
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data" / "flow")
|
||||
monkeypatch.setattr(paths, "STATE_DIR", tmp_path / "state" / "flow")
|
||||
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "data" / "flow" / "modules")
|
||||
monkeypatch.setattr(paths, "PACKAGES_DIR", tmp_path / "data" / "flow" / "packages")
|
||||
|
||||
paths.ensure_dirs()
|
||||
|
||||
assert (tmp_path / "config" / "flow").is_dir()
|
||||
assert (tmp_path / "data" / "flow" / "modules").is_dir()
|
||||
assert (tmp_path / "state" / "flow").is_dir()
|
||||
38
tests/test_core_platform.py
Normal file
38
tests/test_core_platform.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Tests for flow.core.platform."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.platform import PlatformInfo, detect_context, detect_platform
|
||||
|
||||
|
||||
def test_platform_info_computes_platform_string():
|
||||
p = PlatformInfo(os="linux", arch="x64")
|
||||
assert p.platform == "linux-x64"
|
||||
|
||||
|
||||
def test_detect_platform_returns_valid_info():
|
||||
info = detect_platform()
|
||||
assert info.os in ("linux", "macos")
|
||||
assert info.arch in ("x64", "arm64")
|
||||
assert info.platform == f"{info.os}-{info.arch}"
|
||||
|
||||
|
||||
def test_detect_platform_raises_flow_error_on_unsupported(monkeypatch):
|
||||
from flow.core.errors import FlowError
|
||||
monkeypatch.setattr("platform.system", lambda: "FreeBSD")
|
||||
with pytest.raises(FlowError, match="Unsupported operating system"):
|
||||
detect_platform()
|
||||
|
||||
|
||||
def test_detect_context_host(monkeypatch):
|
||||
monkeypatch.delenv("DF_NAMESPACE", raising=False)
|
||||
monkeypatch.delenv("DF_PLATFORM", raising=False)
|
||||
assert detect_context() == "host"
|
||||
|
||||
|
||||
def test_detect_context_vm(monkeypatch):
|
||||
monkeypatch.setenv("DF_NAMESPACE", "personal")
|
||||
monkeypatch.setenv("DF_PLATFORM", "orb")
|
||||
assert detect_context() == "vm"
|
||||
95
tests/test_core_runtime.py
Normal file
95
tests/test_core_runtime.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for flow.core.runtime."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
|
||||
|
||||
|
||||
class TestFileSystem:
|
||||
def test_ensure_dir_creates_nested(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
target = tmp_path / "a" / "b" / "c"
|
||||
fs.ensure_dir(target)
|
||||
assert target.is_dir()
|
||||
|
||||
def test_write_and_read_text(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
path = tmp_path / "test.txt"
|
||||
fs.write_text(path, "hello")
|
||||
assert fs.read_text(path) == "hello"
|
||||
|
||||
def test_read_text_default(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
path = tmp_path / "missing.txt"
|
||||
assert fs.read_text(path, default="fallback") == "fallback"
|
||||
|
||||
def test_write_and_read_json(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
path = tmp_path / "data.json"
|
||||
fs.write_json(path, {"key": "value"})
|
||||
assert fs.read_json(path) == {"key": "value"}
|
||||
|
||||
def test_create_symlink(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
source = tmp_path / "source"
|
||||
source.write_text("content")
|
||||
target = tmp_path / "link"
|
||||
fs.create_symlink(source, target)
|
||||
assert target.is_symlink()
|
||||
assert target.resolve() == source.resolve()
|
||||
|
||||
def test_same_symlink_true(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
source = tmp_path / "source"
|
||||
source.write_text("content")
|
||||
target = tmp_path / "link"
|
||||
target.symlink_to(source)
|
||||
assert fs.same_symlink(target, source) is True
|
||||
|
||||
def test_same_symlink_false(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
source = tmp_path / "source"
|
||||
source.write_text("content")
|
||||
other = tmp_path / "other"
|
||||
other.write_text("other")
|
||||
target = tmp_path / "link"
|
||||
target.symlink_to(other)
|
||||
assert fs.same_symlink(target, source) is False
|
||||
|
||||
def test_remove_file(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
path = tmp_path / "file"
|
||||
path.write_text("x")
|
||||
fs.remove_file(path)
|
||||
assert not path.exists()
|
||||
|
||||
def test_remove_file_missing_ok(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
fs.remove_file(tmp_path / "missing", missing_ok=True) # no error
|
||||
|
||||
def test_copy_file(self, tmp_path):
|
||||
fs = FileSystem()
|
||||
src = tmp_path / "src"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "sub" / "dst"
|
||||
fs.copy_file(src, dst)
|
||||
assert dst.read_text() == "data"
|
||||
|
||||
|
||||
class TestCommandRunner:
|
||||
def test_run_echo(self):
|
||||
runner = CommandRunner()
|
||||
result = runner.run(["echo", "hello"], capture_output=True)
|
||||
assert result.stdout.strip() == "hello"
|
||||
|
||||
def test_require_binary_finds_echo(self):
|
||||
runner = CommandRunner()
|
||||
path = runner.require_binary("echo")
|
||||
assert path is not None
|
||||
|
||||
|
||||
class TestSystemRuntime:
|
||||
def test_creates_git_client(self):
|
||||
rt = SystemRuntime()
|
||||
assert isinstance(rt.git, GitClient)
|
||||
assert rt.git.runner is rt.runner
|
||||
21
tests/test_errors.py
Normal file
21
tests/test_errors.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Tests for flow.core.errors."""
|
||||
|
||||
from flow.core.errors import ConfigError, ExecutionError, FlowError, PlanConflict
|
||||
|
||||
|
||||
def test_flow_error_is_exception():
|
||||
assert issubclass(FlowError, Exception)
|
||||
|
||||
|
||||
def test_config_error_is_flow_error():
|
||||
assert issubclass(ConfigError, FlowError)
|
||||
|
||||
|
||||
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)
|
||||
46
tests/test_template.py
Normal file
46
tests/test_template.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for flow.core.template."""
|
||||
|
||||
import os
|
||||
|
||||
from flow.core.template import substitute, substitute_template
|
||||
|
||||
|
||||
class TestSubstitute:
|
||||
def test_replaces_dollar_var(self):
|
||||
assert substitute("hello $NAME", {"NAME": "world"}) == "hello world"
|
||||
|
||||
def test_replaces_braced_var(self):
|
||||
assert substitute("hello ${NAME}", {"NAME": "world"}) == "hello world"
|
||||
|
||||
def test_falls_back_to_env(self, monkeypatch):
|
||||
monkeypatch.setenv("FOO", "bar")
|
||||
assert substitute("$FOO", {}) == "bar"
|
||||
|
||||
def test_preserves_unknown_vars(self):
|
||||
assert substitute("$UNKNOWN", {}) == "$UNKNOWN"
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert substitute(42, {}) == 42
|
||||
|
||||
|
||||
class TestSubstituteTemplate:
|
||||
def test_replaces_double_braces(self):
|
||||
assert substitute_template("nvim-{{os}}", {"os": "linux"}) == "nvim-linux"
|
||||
|
||||
def test_env_dot_notation(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "tomas")
|
||||
result = substitute_template("{{ env.USER }}", {"env": dict(os.environ)})
|
||||
assert result == "tomas"
|
||||
|
||||
def test_nested_dict_lookup(self):
|
||||
ctx = {"platform": {"arch": "arm64"}}
|
||||
assert substitute_template("{{ platform.arch }}", ctx) == "arm64"
|
||||
|
||||
def test_preserves_unknown_templates(self):
|
||||
assert substitute_template("{{ unknown }}", {}) == "{{ unknown }}"
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert substitute_template(42, {}) == 42
|
||||
|
||||
def test_whitespace_in_braces(self):
|
||||
assert substitute_template("{{ os }}", {"os": "linux"}) == "linux"
|
||||
Reference in New Issue
Block a user