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.
- reconcilekit-0.1.0/.gitignore +4 -0
- reconcilekit-0.1.0/LICENSE +21 -0
- reconcilekit-0.1.0/PKG-INFO +113 -0
- reconcilekit-0.1.0/README.md +91 -0
- reconcilekit-0.1.0/pyproject.toml +42 -0
- reconcilekit-0.1.0/src/reconcilekit/__init__.py +43 -0
- reconcilekit-0.1.0/src/reconcilekit/kernel.py +133 -0
- reconcilekit-0.1.0/src/reconcilekit/py.typed +0 -0
- reconcilekit-0.1.0/src/reconcilekit/render.py +54 -0
- reconcilekit-0.1.0/src/reconcilekit/status.py +55 -0
- reconcilekit-0.1.0/tests/test_kernel.py +186 -0
- reconcilekit-0.1.0/tests/test_render.py +62 -0
- reconcilekit-0.1.0/tests/test_status.py +73 -0
|
@@ -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)
|