reconcilekit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ public/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugo Haas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: reconcilekit
3
+ Version: 0.1.0
4
+ Summary: A tiny stateless fetch-diff-apply reconciliation kernel for fleet-wide config tools
5
+ Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/reconcilekit
6
+ Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
7
+ Project-URL: Repository, https://github.com/hugoh/gh-workflows
8
+ Author: Hugo Haas
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: rich>=13
21
+ Description-Content-Type: text/markdown
22
+
23
+ # reconcilekit
24
+
25
+ A tiny, stateless fetch-diff-apply reconciliation kernel — the pattern a
26
+ fleet-wide config tool repeats for every resource it manages, with none of the
27
+ domain specifics.
28
+
29
+ There is no state file, no backend, no daemon. Each run reads live state,
30
+ compares it to what you want, and either reports the plan or applies it.
31
+
32
+ ## The pattern
33
+
34
+ 1. **Enumerate targets** — anything with a `name`.
35
+ 2. **Fetch** each target's current state.
36
+ 3. **Compare** to the desired state: already there? partly blocked?
37
+ 4. **Dry-run** → report the plan, or **apply** → mutate and report the outcome.
38
+ 5. **Classify** into `Status` (`OK`, `UNCHANGED`, `LIMITED`, `LIMITED_UNCHANGED`,
39
+ `FAILED`).
40
+ 6. **Run the fleet in bounded parallel**, isolating per-target failures and
41
+ raising them together at the end.
42
+
43
+ ## API
44
+
45
+ Full API reference, generated from the docstrings:
46
+ [hugoh.github.io/gh-workflows/reconcilekit](https://hugoh.github.io/gh-workflows/reconcilekit/)
47
+ (rebuilt on every push that touches this package -- see
48
+ `.github/workflows/docs.yml`).
49
+
50
+ ## Example
51
+
52
+ ```python
53
+ import asyncio
54
+ from dataclasses import dataclass
55
+
56
+ from reconcilekit import (
57
+ Result,
58
+ Status,
59
+ classify_status,
60
+ result_line,
61
+ run_parallel,
62
+ run_reconcile,
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class File:
68
+ name: str
69
+ path: str
70
+ want: str
71
+
72
+
73
+ def make_worker(dry_run: bool):
74
+ async def worker(f: File) -> Result[File]:
75
+ async def fetch() -> str:
76
+ return f.path_text() # read current contents
77
+
78
+ def plan(current: str) -> Result[File]:
79
+ status = classify_status(
80
+ at_target=current == f.want, changed=current != f.want
81
+ )
82
+ return Result(
83
+ f,
84
+ result_line(
85
+ f.name, "would rewrite" if current != f.want else "ok", status
86
+ ),
87
+ status,
88
+ )
89
+
90
+ async def apply(current: str) -> Result[File]:
91
+ if current != f.want:
92
+ f.write(f.want)
93
+ status = classify_status(at_target=True, changed=current != f.want)
94
+ return Result(
95
+ f,
96
+ result_line(f.name, "written" if current != f.want else "ok", status),
97
+ status,
98
+ )
99
+
100
+ return await run_reconcile(
101
+ dry_run=dry_run, fetch=fetch, plan_result=plan, apply_result=apply
102
+ )
103
+
104
+ return worker
105
+
106
+
107
+ asyncio.run(run_parallel(files, make_worker(dry_run=True)))
108
+ ```
109
+
110
+ ## Consumers
111
+
112
+ `repo-admin/` (in this repo) uses it for every account-wide GitHub `sync`
113
+ command, consumed as a uv workspace member there.
@@ -0,0 +1,91 @@
1
+ # reconcilekit
2
+
3
+ A tiny, stateless fetch-diff-apply reconciliation kernel — the pattern a
4
+ fleet-wide config tool repeats for every resource it manages, with none of the
5
+ domain specifics.
6
+
7
+ There is no state file, no backend, no daemon. Each run reads live state,
8
+ compares it to what you want, and either reports the plan or applies it.
9
+
10
+ ## The pattern
11
+
12
+ 1. **Enumerate targets** — anything with a `name`.
13
+ 2. **Fetch** each target's current state.
14
+ 3. **Compare** to the desired state: already there? partly blocked?
15
+ 4. **Dry-run** → report the plan, or **apply** → mutate and report the outcome.
16
+ 5. **Classify** into `Status` (`OK`, `UNCHANGED`, `LIMITED`, `LIMITED_UNCHANGED`,
17
+ `FAILED`).
18
+ 6. **Run the fleet in bounded parallel**, isolating per-target failures and
19
+ raising them together at the end.
20
+
21
+ ## API
22
+
23
+ Full API reference, generated from the docstrings:
24
+ [hugoh.github.io/gh-workflows/reconcilekit](https://hugoh.github.io/gh-workflows/reconcilekit/)
25
+ (rebuilt on every push that touches this package -- see
26
+ `.github/workflows/docs.yml`).
27
+
28
+ ## Example
29
+
30
+ ```python
31
+ import asyncio
32
+ from dataclasses import dataclass
33
+
34
+ from reconcilekit import (
35
+ Result,
36
+ Status,
37
+ classify_status,
38
+ result_line,
39
+ run_parallel,
40
+ run_reconcile,
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class File:
46
+ name: str
47
+ path: str
48
+ want: str
49
+
50
+
51
+ def make_worker(dry_run: bool):
52
+ async def worker(f: File) -> Result[File]:
53
+ async def fetch() -> str:
54
+ return f.path_text() # read current contents
55
+
56
+ def plan(current: str) -> Result[File]:
57
+ status = classify_status(
58
+ at_target=current == f.want, changed=current != f.want
59
+ )
60
+ return Result(
61
+ f,
62
+ result_line(
63
+ f.name, "would rewrite" if current != f.want else "ok", status
64
+ ),
65
+ status,
66
+ )
67
+
68
+ async def apply(current: str) -> Result[File]:
69
+ if current != f.want:
70
+ f.write(f.want)
71
+ status = classify_status(at_target=True, changed=current != f.want)
72
+ return Result(
73
+ f,
74
+ result_line(f.name, "written" if current != f.want else "ok", status),
75
+ status,
76
+ )
77
+
78
+ return await run_reconcile(
79
+ dry_run=dry_run, fetch=fetch, plan_result=plan, apply_result=apply
80
+ )
81
+
82
+ return worker
83
+
84
+
85
+ asyncio.run(run_parallel(files, make_worker(dry_run=True)))
86
+ ```
87
+
88
+ ## Consumers
89
+
90
+ `repo-admin/` (in this repo) uses it for every account-wide GitHub `sync`
91
+ command, consumed as a uv workspace member there.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "reconcilekit"
3
+ version = "0.1.0"
4
+ description = "A tiny stateless fetch-diff-apply reconciliation kernel for fleet-wide config tools"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Hugo Haas" },
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ dependencies = ["rich>=13"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/hugoh/gh-workflows/tree/main/reconcilekit"
27
+ Issues = "https://github.com/hugoh/gh-workflows/issues"
28
+ Repository = "https://github.com/hugoh/gh-workflows"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/reconcilekit"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
@@ -0,0 +1,43 @@
1
+ """reconcilekit -- a tiny stateless fetch-diff-apply reconciliation kernel.
2
+
3
+ The pattern every fleet-wide config tool repeats: enumerate targets, fetch each
4
+ one's current state, compare to the desired state, either report the plan
5
+ (dry run) or apply it, classify the outcome into a small fixed vocabulary, and
6
+ run the whole thing in bounded parallel with per-target failure isolation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .kernel import (
12
+ DEFAULT_JOBS,
13
+ HasName,
14
+ ReconcileError,
15
+ Result,
16
+ run_parallel,
17
+ run_reconcile,
18
+ )
19
+ from .render import print_status, progress_bar, result_line
20
+ from .status import (
21
+ Status,
22
+ classify_status,
23
+ partition_fields,
24
+ summary_status,
25
+ unavailable_suffix,
26
+ )
27
+
28
+ __all__ = [
29
+ "DEFAULT_JOBS",
30
+ "HasName",
31
+ "ReconcileError",
32
+ "Result",
33
+ "Status",
34
+ "classify_status",
35
+ "partition_fields",
36
+ "print_status",
37
+ "progress_bar",
38
+ "result_line",
39
+ "run_parallel",
40
+ "run_reconcile",
41
+ "summary_status",
42
+ "unavailable_suffix",
43
+ ]
@@ -0,0 +1,133 @@
1
+ """The reconciliation kernel: a fetch-then-branch skeleton for one target, and
2
+ a bounded-parallel runner over a fleet of them with per-target failure
3
+ isolation. Nothing here knows what a target is beyond its name.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import Awaitable, Callable, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Generic, Protocol, TypeVar
12
+
13
+ from .render import QUIET_STATUSES, console, print_status, progress_bar
14
+ from .status import Status
15
+
16
+ DEFAULT_JOBS = 6
17
+
18
+
19
+ class ReconcileError(RuntimeError):
20
+ """A target's worker failed, or a run finished with failures.
21
+
22
+ Callers that need a domain-specific exception type (so `pytest.raises` and
23
+ `except` clauses can stay narrow) pass it as `run_parallel(..., error_cls=)`.
24
+ """
25
+
26
+
27
+ class HasName(Protocol):
28
+ @property
29
+ def name(self) -> str: ...
30
+
31
+
32
+ class ResultLike(Protocol):
33
+ status: Status
34
+ line: str
35
+
36
+
37
+ Target = TypeVar("Target", bound=HasName)
38
+ State = TypeVar("State")
39
+ R = TypeVar("R", bound=ResultLike)
40
+
41
+
42
+ @dataclass
43
+ class Result(Generic[Target]):
44
+ target: Target
45
+ line: str
46
+ status: Status = Status.OK
47
+ tag: str | None = None
48
+
49
+
50
+ async def run_reconcile(
51
+ *,
52
+ dry_run: bool,
53
+ fetch: Callable[[], Awaitable[State]],
54
+ plan_result: Callable[[State], R],
55
+ apply_result: Callable[[State], Awaitable[R]],
56
+ ) -> R:
57
+ """Read the target's current state once, then either report the plan (dry
58
+ run) or apply the change and report the outcome. Each worker keeps its own
59
+ planning, side effects, and result formatting in the three callables.
60
+ """
61
+ state = await fetch()
62
+ if dry_run:
63
+ return plan_result(state)
64
+ return await apply_result(state)
65
+
66
+
67
+ async def run_parallel(
68
+ targets: Sequence[Target],
69
+ worker: Callable[[Target], Awaitable[R]],
70
+ jobs: int = DEFAULT_JOBS,
71
+ verbose: bool = False,
72
+ *,
73
+ error_cls: type[Exception] = ReconcileError,
74
+ ) -> list[R]:
75
+ """Runs worker(target) for each target concurrently (bounded by a semaphore
76
+ -- these are I/O-bound calls, not CPU-bound work), printing each result's
77
+ line as soon as it's ready (completion order, not submission order) above a
78
+ live progress bar, and returning every result.
79
+
80
+ A worker exception is caught inside call() itself -- not left to propagate
81
+ through the TaskGroup -- so one target failing doesn't cancel the others
82
+ (TaskGroup cancels every sibling task the moment any task raises). Instead
83
+ it's printed as a failure line and collected; once every target has been
84
+ attempted, any failures are raised together as a single `error_cls` so the
85
+ run still ends with a nonzero exit.
86
+
87
+ Unless verbose=True, a target already at its target state (UNCHANGED /
88
+ LIMITED_UNCHANGED) isn't printed live -- on a large fleet the handful of
89
+ lines that represent an actual change would otherwise be lost in a wall of
90
+ "unchanged: ..." lines. Suppressed lines are counted and reported as a
91
+ single dim summary line instead.
92
+ """
93
+ results: list[R] = []
94
+ failed_names: list[str] = []
95
+ unchanged_count = 0
96
+ sem = asyncio.Semaphore(jobs)
97
+
98
+ with progress_bar() as progress:
99
+ task = progress.add_task("Processing...", total=len(targets))
100
+
101
+ async def call(target: Target) -> None:
102
+ nonlocal unchanged_count
103
+ async with sem:
104
+ try:
105
+ result = await worker(target)
106
+ except Exception as exc: # noqa: BLE001 -- collected below, not swallowed
107
+ failed_names.append(target.name)
108
+ print_status(Status.FAILED, f"{target.name}: {exc}")
109
+ else:
110
+ if not verbose and result.status in QUIET_STATUSES:
111
+ unchanged_count += 1
112
+ else:
113
+ print_status(result.status, result.line)
114
+ results.append(result)
115
+ finally:
116
+ progress.advance(task)
117
+
118
+ async with asyncio.TaskGroup() as tg:
119
+ for target in targets:
120
+ tg.create_task(call(target))
121
+
122
+ if unchanged_count:
123
+ console.print(
124
+ f" {unchanged_count} unchanged (rerun with --verbose to see them)",
125
+ style="dim",
126
+ )
127
+
128
+ if failed_names:
129
+ raise error_cls(
130
+ f"{len(failed_names)} target(s) failed: {', '.join(sorted(failed_names))}"
131
+ )
132
+
133
+ return results
File without changes
@@ -0,0 +1,54 @@
1
+ """Default terminal renderer for reconcile runs: a status symbol per line above
2
+ a live progress bar, silenced when stdout isn't a terminal.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.progress import Progress
11
+
12
+ from .status import Status
13
+
14
+ console = Console()
15
+
16
+ _STATUS_DISPLAY: dict[Status, tuple[str, str]] = {
17
+ Status.OK: ("✓", "green"),
18
+ Status.UNCHANGED: ("•", "green"),
19
+ Status.LIMITED: ("○", "yellow"),
20
+ Status.LIMITED_UNCHANGED: ("•", "yellow"),
21
+ Status.FAILED: ("✗", "red"),
22
+ }
23
+
24
+ QUIET_STATUSES = (Status.UNCHANGED, Status.LIMITED_UNCHANGED)
25
+
26
+
27
+ def progress_bar(*, console: Console = console, **kwargs: Any) -> Progress:
28
+ """A Progress bound to `console` (module-level `console` by default),
29
+ silenced when its console isn't a terminal -- e.g. `cmd > file` or piping
30
+ into another command -- so its status text (which Progress still renders as
31
+ plain lines even without a tty) doesn't end up mixed into redirected output.
32
+ """
33
+ return Progress(console=console, disable=not console.is_terminal, **kwargs)
34
+
35
+
36
+ def result_line(name: str, detail: str, status: Status) -> str:
37
+ prefix = (
38
+ "unchanged: " if status in (Status.UNCHANGED, Status.LIMITED_UNCHANGED) else ""
39
+ )
40
+ return f"{name:<30} {prefix}{detail}"
41
+
42
+
43
+ def print_status(status: Status, line: str) -> None:
44
+ # Always goes through the single Console that Progress/Live owns
45
+ # (run_parallel passes it to Progress(console=...)): a second Console
46
+ # writing to a separate stream isn't coordinated by rich's Live redraw
47
+ # bookkeeping and can visually corrupt output on a real terminal.
48
+ symbol, color = _STATUS_DISPLAY[status]
49
+ # markup=False: `line` can contain target/error text with literal "[" (dict
50
+ # reprs, error messages) that would otherwise be parsed as rich markup.
51
+ # highlight=False: rich's default ReprHighlighter recolors numbers, paths,
52
+ # etc. within the line (e.g. a target named "foo-410"), fighting with the
53
+ # single status color we want for the whole line.
54
+ console.print(f"{symbol} {line}", style=color, markup=False, highlight=False)
@@ -0,0 +1,55 @@
1
+ """The status vocabulary every reconcile result is classified into, plus the
2
+ "enable a set of toggles, some of which are gated" summarising helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import enum
8
+
9
+
10
+ class Status(enum.Enum):
11
+ """How a target's result relates to the desired end state -- distinct from
12
+ a Result's tag, which is per-command bookkeeping for end-of-run summaries.
13
+ """
14
+
15
+ OK = "ok" # full target reached, changed this run
16
+ UNCHANGED = "unchanged" # full target reached, already was
17
+ LIMITED = "limited" # best-effort (capped by plan/data) reached, changed this run
18
+ LIMITED_UNCHANGED = "limited_unchanged" # best-effort reached, already was
19
+ FAILED = "failed" # worker raised
20
+
21
+
22
+ def classify_status(at_target: bool, changed: bool) -> Status:
23
+ if at_target:
24
+ return Status.OK if changed else Status.UNCHANGED
25
+ return Status.LIMITED if changed else Status.LIMITED_UNCHANGED
26
+
27
+
28
+ def partition_fields(fields: dict[str, tuple[bool, bool]]) -> dict[str, list[str]]:
29
+ """Splits a `name -> (currently_enabled, available_here)` mapping into the
30
+ two lists every "enable a set of toggles" command reports: `would_enable`
31
+ (available but off) and `unavailable` (gated).
32
+ """
33
+ return {
34
+ "would_enable": [
35
+ name
36
+ for name, (enabled, available) in fields.items()
37
+ if available and not enabled
38
+ ],
39
+ "unavailable": [
40
+ name for name, (_enabled, available) in fields.items() if not available
41
+ ],
42
+ }
43
+
44
+
45
+ def summary_status(summary: dict[str, list[str]]) -> Status:
46
+ """Status for a partition_fields() summary: at target once nothing is
47
+ gated, changed when there's anything left to enable.
48
+ """
49
+ return classify_status(
50
+ at_target=not summary["unavailable"], changed=bool(summary["would_enable"])
51
+ )
52
+
53
+
54
+ def unavailable_suffix(unavailable: list[str]) -> str:
55
+ return f" (unavailable: {', '.join(unavailable)})" if unavailable else ""
@@ -0,0 +1,186 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+
4
+ import pytest
5
+
6
+ from reconcilekit import (
7
+ ReconcileError,
8
+ Result,
9
+ Status,
10
+ run_parallel,
11
+ run_reconcile,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class Fake:
17
+ name: str
18
+
19
+
20
+ def _targets(n: int) -> list[Fake]:
21
+ return [Fake(name=f"t{i}") for i in range(n)]
22
+
23
+
24
+ def test_result_defaults_to_ok_status():
25
+ assert Result(Fake("t"), "line").status == Status.OK
26
+
27
+
28
+ async def test_run_reconcile_dry_run_reports_plan_without_applying():
29
+ applied = False
30
+
31
+ async def fetch():
32
+ return "state"
33
+
34
+ def plan_result(state):
35
+ assert state == "state"
36
+ return "planned"
37
+
38
+ async def apply_result(state):
39
+ nonlocal applied
40
+ applied = True
41
+ return "applied"
42
+
43
+ result = await run_reconcile(
44
+ dry_run=True, fetch=fetch, plan_result=plan_result, apply_result=apply_result
45
+ )
46
+ assert result == "planned"
47
+ assert not applied
48
+
49
+
50
+ async def test_run_reconcile_applies_with_fetched_state():
51
+ fetch_calls = 0
52
+
53
+ async def fetch():
54
+ nonlocal fetch_calls
55
+ fetch_calls += 1
56
+ return "before"
57
+
58
+ def plan_result(state):
59
+ raise AssertionError("plan_result should not run when not dry_run")
60
+
61
+ async def apply_result(state):
62
+ assert state == "before"
63
+ return "applied"
64
+
65
+ result = await run_reconcile(
66
+ dry_run=False, fetch=fetch, plan_result=plan_result, apply_result=apply_result
67
+ )
68
+ assert result == "applied"
69
+ assert fetch_calls == 1
70
+
71
+
72
+ async def test_run_parallel_returns_worker_results_for_every_target():
73
+ targets = _targets(5)
74
+
75
+ async def worker(target):
76
+ return Result(target, f"{target.name} done")
77
+
78
+ results = await run_parallel(targets, worker, jobs=3)
79
+ assert sorted(r.target.name for r in results) == sorted(t.name for t in targets)
80
+
81
+
82
+ async def test_run_parallel_prints_each_worker_result_line(capsys):
83
+ async def worker(target):
84
+ return Result(target, f"{target.name} line")
85
+
86
+ await run_parallel([Fake("t-a")], worker, jobs=1)
87
+ assert "t-a line" in capsys.readouterr().out
88
+
89
+
90
+ async def test_run_parallel_runs_workers_concurrently():
91
+ targets = _targets(4)
92
+ barrier = asyncio.Barrier(4)
93
+
94
+ async def worker(target):
95
+ await barrier.wait()
96
+ return Result(target, target.name)
97
+
98
+ # If run_parallel executed workers serially, the barrier would never
99
+ # release with only 1 worker present at a time and this would time out.
100
+ await asyncio.wait_for(run_parallel(targets, worker, jobs=4), timeout=2)
101
+
102
+
103
+ async def test_run_parallel_one_failure_does_not_block_others():
104
+ targets = _targets(3)
105
+ completed = []
106
+
107
+ async def worker(target):
108
+ if target.name == "t1":
109
+ raise RuntimeError("boom")
110
+ await asyncio.sleep(0.05)
111
+ completed.append(target.name)
112
+ return Result(target, target.name)
113
+
114
+ with pytest.raises(ReconcileError):
115
+ await run_parallel(targets, worker, jobs=3)
116
+
117
+ assert sorted(completed) == ["t0", "t2"]
118
+
119
+
120
+ async def test_run_parallel_reports_failed_target_names_in_error(capsys):
121
+ async def worker(target):
122
+ raise RuntimeError("network error")
123
+
124
+ with pytest.raises(ReconcileError, match="bad-target"):
125
+ await run_parallel([Fake("bad-target")], worker, jobs=1)
126
+ assert "bad-target" in capsys.readouterr().out
127
+
128
+
129
+ async def test_run_parallel_raises_the_given_error_class():
130
+ class MyError(RuntimeError):
131
+ pass
132
+
133
+ async def worker(target):
134
+ raise RuntimeError("boom")
135
+
136
+ with pytest.raises(MyError):
137
+ await run_parallel([Fake("t")], worker, jobs=1, error_cls=MyError)
138
+
139
+
140
+ async def test_run_parallel_hides_unchanged_lines_by_default(capsys):
141
+ async def worker(target):
142
+ return Result(target, f"{target.name} line", status=Status.UNCHANGED)
143
+
144
+ await run_parallel([Fake("t-a")], worker, jobs=1)
145
+ out = capsys.readouterr().out
146
+ assert "t-a line" not in out
147
+ assert "1 unchanged" in out
148
+
149
+
150
+ async def test_run_parallel_verbose_shows_unchanged_lines_without_summary(capsys):
151
+ async def worker(target):
152
+ return Result(target, f"{target.name} line", status=Status.UNCHANGED)
153
+
154
+ await run_parallel([Fake("t-a")], worker, jobs=1, verbose=True)
155
+ out = capsys.readouterr().out
156
+ assert "t-a line" in out
157
+ assert "unchanged" not in out.replace("t-a line", "")
158
+
159
+
160
+ @pytest.mark.parametrize("status", [Status.OK, Status.FAILED, Status.LIMITED])
161
+ async def test_run_parallel_always_shows_non_unchanged_lines(capsys, status):
162
+ async def worker(target):
163
+ if status is Status.FAILED:
164
+ raise RuntimeError("boom")
165
+ return Result(target, f"{target.name} line", status=status)
166
+
167
+ if status is Status.FAILED:
168
+ with pytest.raises(ReconcileError):
169
+ await run_parallel([Fake("t-a")], worker, jobs=1)
170
+ out = capsys.readouterr().out
171
+ assert "t-a" in out
172
+ else:
173
+ await run_parallel([Fake("t-a")], worker, jobs=1)
174
+ out = capsys.readouterr().out
175
+ assert "t-a line" in out
176
+ assert "unchanged" not in out
177
+
178
+
179
+ async def test_run_parallel_hides_limited_unchanged_lines_by_default(capsys):
180
+ async def worker(target):
181
+ return Result(target, f"{target.name} line", status=Status.LIMITED_UNCHANGED)
182
+
183
+ await run_parallel([Fake("t-a")], worker, jobs=1)
184
+ out = capsys.readouterr().out
185
+ assert "t-a line" not in out
186
+ assert "1 unchanged" in out
@@ -0,0 +1,62 @@
1
+ import io
2
+
3
+ import pytest
4
+ from reconcilekit.render import _STATUS_DISPLAY
5
+ from rich.console import Console
6
+
7
+ from reconcilekit import Status, print_status, progress_bar, result_line
8
+
9
+
10
+ def test_result_line_prefixes_unchanged_status():
11
+ assert result_line("t", "up to date detail", Status.UNCHANGED) == (
12
+ f"{'t':<30} unchanged: up to date detail"
13
+ )
14
+
15
+
16
+ def test_result_line_prefixes_limited_unchanged_status():
17
+ assert result_line("t", "capped detail", Status.LIMITED_UNCHANGED) == (
18
+ f"{'t':<30} unchanged: capped detail"
19
+ )
20
+
21
+
22
+ @pytest.mark.parametrize("status", [Status.OK, Status.LIMITED, Status.FAILED])
23
+ def test_result_line_does_not_prefix_changed_statuses(status):
24
+ assert result_line("t", "detail", status) == f"{'t':<30} detail"
25
+
26
+
27
+ def test_print_status_prints_line_to_stdout(capsys):
28
+ print_status(Status.OK, "target-a done")
29
+ assert "target-a done" in capsys.readouterr().out
30
+
31
+
32
+ def test_print_status_does_not_interpret_brackets_in_line_as_markup(capsys):
33
+ print_status(Status.OK, "t {'allow_auto_merge': True} -> [oops]")
34
+ assert "{'allow_auto_merge': True} -> [oops]" in capsys.readouterr().out
35
+
36
+
37
+ def test_status_display_pairs_are_unique_per_status():
38
+ # (symbol, color) pairs -- not symbols alone, since e.g. UNCHANGED and
39
+ # LIMITED_UNCHANGED intentionally share the "•" symbol and differ only by
40
+ # color.
41
+ pairs = list(_STATUS_DISPLAY.values())
42
+ assert len(pairs) == len(set(pairs)) == len(list(Status))
43
+
44
+
45
+ def test_progress_bar_disabled_when_console_is_not_a_terminal():
46
+ non_tty_console = Console(file=io.StringIO())
47
+ assert progress_bar(console=non_tty_console).disable is True
48
+
49
+
50
+ def test_progress_bar_enabled_when_console_is_a_terminal():
51
+ tty_console = Console(file=io.StringIO(), force_terminal=True)
52
+ assert progress_bar(console=tty_console).disable is False
53
+
54
+
55
+ def test_progress_bar_prints_nothing_when_disabled():
56
+ output = io.StringIO()
57
+ non_tty_console = Console(file=output)
58
+ with progress_bar(console=non_tty_console) as bar:
59
+ task = bar.add_task("working", total=2)
60
+ bar.advance(task)
61
+ bar.advance(task)
62
+ assert output.getvalue() == ""
@@ -0,0 +1,73 @@
1
+ import pytest
2
+
3
+ from reconcilekit import (
4
+ Status,
5
+ classify_status,
6
+ partition_fields,
7
+ summary_status,
8
+ unavailable_suffix,
9
+ )
10
+
11
+
12
+ def test_classify_status_at_target_and_changed_is_ok():
13
+ assert classify_status(at_target=True, changed=True) == Status.OK
14
+
15
+
16
+ def test_classify_status_at_target_and_unchanged_is_unchanged():
17
+ assert classify_status(at_target=True, changed=False) == Status.UNCHANGED
18
+
19
+
20
+ def test_classify_status_not_at_target_and_changed_is_limited():
21
+ assert classify_status(at_target=False, changed=True) == Status.LIMITED
22
+
23
+
24
+ def test_classify_status_not_at_target_and_unchanged_is_limited_unchanged():
25
+ assert classify_status(at_target=False, changed=False) == Status.LIMITED_UNCHANGED
26
+
27
+
28
+ def test_partition_fields_splits_would_enable_and_unavailable():
29
+ summary = partition_fields(
30
+ {"a": (False, True), "b": (True, True), "c": (False, False)}
31
+ )
32
+ assert summary == {"would_enable": ["a"], "unavailable": ["c"]}
33
+
34
+
35
+ def test_partition_fields_preserves_field_order():
36
+ summary = partition_fields(
37
+ {"z": (False, True), "a": (False, True), "m": (False, True)}
38
+ )
39
+ assert summary["would_enable"] == ["z", "a", "m"]
40
+
41
+
42
+ def test_summary_status_ok_when_would_enable_and_nothing_gated():
43
+ assert summary_status({"would_enable": ["a"], "unavailable": []}) == Status.OK
44
+
45
+
46
+ def test_summary_status_limited_when_gated_and_would_enable():
47
+ assert (
48
+ summary_status({"would_enable": ["a"], "unavailable": ["b"]}) == Status.LIMITED
49
+ )
50
+
51
+
52
+ def test_summary_status_limited_unchanged_when_only_gated():
53
+ assert (
54
+ summary_status({"would_enable": [], "unavailable": ["b"]})
55
+ == Status.LIMITED_UNCHANGED
56
+ )
57
+
58
+
59
+ def test_summary_status_unchanged_when_nothing_to_do():
60
+ assert summary_status({"would_enable": [], "unavailable": []}) == Status.UNCHANGED
61
+
62
+
63
+ def test_unavailable_suffix_empty_when_nothing_gated():
64
+ assert unavailable_suffix([]) == ""
65
+
66
+
67
+ def test_unavailable_suffix_lists_names():
68
+ assert unavailable_suffix(["a", "b"]) == " (unavailable: a, b)"
69
+
70
+
71
+ @pytest.mark.parametrize("status", [Status.OK, Status.LIMITED, Status.FAILED])
72
+ def test_status_is_hashable_and_distinct(status):
73
+ assert status in set(Status)