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.
- reconcilekit/__init__.py +43 -0
- reconcilekit/kernel.py +133 -0
- reconcilekit/py.typed +0 -0
- reconcilekit/render.py +54 -0
- reconcilekit/status.py +55 -0
- reconcilekit-0.1.0.dist-info/METADATA +113 -0
- reconcilekit-0.1.0.dist-info/RECORD +9 -0
- reconcilekit-0.1.0.dist-info/WHEEL +4 -0
- reconcilekit-0.1.0.dist-info/licenses/LICENSE +21 -0
reconcilekit/__init__.py
ADDED
|
@@ -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,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.
|