reconcilekit 0.1.0__py3-none-any.whl

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,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
+ ]
reconcilekit/kernel.py ADDED
@@ -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
reconcilekit/py.typed ADDED
File without changes
reconcilekit/render.py ADDED
@@ -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)
reconcilekit/status.py ADDED
@@ -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,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,9 @@
1
+ reconcilekit/__init__.py,sha256=cjUcDWryeNmxbfMwfnpfZ8Ojz-JRghvWzJY4PYUj1BE,1028
2
+ reconcilekit/kernel.py,sha256=LNru0fpigCA-cBWkEwmovxClj4i6VCSkUwq3JuSMvXw,4446
3
+ reconcilekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ reconcilekit/render.py,sha256=SXANXXA0yrVErfLSZ842dbNKNiHw8M2HpdBAi-7aJ3M,2167
5
+ reconcilekit/status.py,sha256=Bfn738tU_Ly3RYranGLLUu-yQK3ekwoLr7XFy2ePAik,1981
6
+ reconcilekit-0.1.0.dist-info/METADATA,sha256=l6_41FMv6li2QWiEan-KPisq3h4nNFCi5esGRjbgynU,3506
7
+ reconcilekit-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ reconcilekit-0.1.0.dist-info/licenses/LICENSE,sha256=qCv-aayDkFcB09IGjnNETtrZ31NOA1tdCq4IJ7nZObc,1066
9
+ reconcilekit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.