csvledger 0.0.2__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.
csvledger/__init__.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Long-form CSV tables that merge idempotently: a git-friendly history store.
|
|
2
|
+
|
|
3
|
+
A table is described by a :class:`TableSpec` (columns, key, merge policy,
|
|
4
|
+
partitioning). Rows are merged into CSV files, one per table (or one per year
|
|
5
|
+
of its time column), sorted so new rows land at the end of a file, through any
|
|
6
|
+
``MutableMapping[str, str]`` (relative path to text): a local folder
|
|
7
|
+
(:class:`Files`, the default), a git checkout, or any ``dol`` store.
|
|
8
|
+
|
|
9
|
+
Merge policies are chosen so that re-running a collection never changes the
|
|
10
|
+
result (idempotent), and the order of runs does not matter for daily series:
|
|
11
|
+
|
|
12
|
+
- ``max``: upsert by key; integer fields keep the larger value. A day's count
|
|
13
|
+
only grows as the day completes, so the fullest count wins whichever run saw it.
|
|
14
|
+
- ``replace_group``: rows of a snapshot (``group`` columns, e.g. one repo and
|
|
15
|
+
one fetch date) replace any earlier rows of the same group.
|
|
16
|
+
- ``change_only``: a row is stored only when its values differ from the
|
|
17
|
+
previous row of the same ``entity``; the key is the entity columns plus one
|
|
18
|
+
date column. For slowly changing values, where daily copies would be noise.
|
|
19
|
+
- ``upsert``: upsert by key, the new row wins (current metadata).
|
|
20
|
+
|
|
21
|
+
>>> spec = TableSpec("views", ("date", "repo", "count"), key=("date", "repo"),
|
|
22
|
+
... merge="max", int_columns=("count",), time_column="date")
|
|
23
|
+
>>> store = {}
|
|
24
|
+
>>> write_rows(store, spec, [{"date": "2026-01-01", "repo": "a/b", "count": 3}])
|
|
25
|
+
1
|
|
26
|
+
>>> write_rows(store, spec, [{"date": "2026-01-01", "repo": "a/b", "count": 2}])
|
|
27
|
+
0
|
|
28
|
+
>>> print(store["views/2026.csv"], end="")
|
|
29
|
+
date,repo,count
|
|
30
|
+
2026-01-01,a/b,3
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import csv
|
|
36
|
+
import io
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
from collections.abc import Iterable, Iterator, Mapping, MutableMapping
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Literal
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Files",
|
|
46
|
+
"MANIFEST_KEY",
|
|
47
|
+
"MergePolicy",
|
|
48
|
+
"TableSpec",
|
|
49
|
+
"dump_csv",
|
|
50
|
+
"manifest",
|
|
51
|
+
"merge_rows",
|
|
52
|
+
"parse_csv",
|
|
53
|
+
"read_table",
|
|
54
|
+
"table_files",
|
|
55
|
+
"write_manifest",
|
|
56
|
+
"write_rows",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
MergePolicy = Literal["max", "replace_group", "change_only", "upsert"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TableSpec:
|
|
64
|
+
"""How one table is laid out on disk and merged."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
columns: tuple[str, ...]
|
|
68
|
+
key: tuple[str, ...]
|
|
69
|
+
merge: MergePolicy
|
|
70
|
+
int_columns: tuple[str, ...] = ()
|
|
71
|
+
time_column: str | None = None # partition by its year when set
|
|
72
|
+
group: tuple[str, ...] = () # for 'replace_group'
|
|
73
|
+
entity: tuple[str, ...] = () # for 'change_only': what a row tracks
|
|
74
|
+
description: str = ""
|
|
75
|
+
sort: tuple[str, ...] = field(default=())
|
|
76
|
+
|
|
77
|
+
def __post_init__(self):
|
|
78
|
+
if self.merge == "change_only":
|
|
79
|
+
if self.time_column is not None:
|
|
80
|
+
raise ValueError("A 'change_only' table cannot be partitioned by time.")
|
|
81
|
+
if len([c for c in self.key if c not in self.entity]) != 1:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"A 'change_only' table's key must be its entity columns "
|
|
84
|
+
f"plus one date column; got key={self.key}, entity={self.entity}."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def change_column(self) -> str:
|
|
89
|
+
"""For 'change_only': the key column that orders an entity's rows (a date)."""
|
|
90
|
+
return next(c for c in self.key if c not in self.entity)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def sort_columns(self) -> tuple[str, ...]:
|
|
94
|
+
"""Sort order on disk: time first, so new rows land at the end of a file."""
|
|
95
|
+
return self.sort or self.key
|
|
96
|
+
|
|
97
|
+
def path_for(self, row: dict) -> str:
|
|
98
|
+
"""The relative path of the file holding ``row``."""
|
|
99
|
+
if self.time_column is None:
|
|
100
|
+
return f"{self.name}.csv"
|
|
101
|
+
return f"{self.name}/{str(row[self.time_column])[:4]}.csv"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------- store
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Files(MutableMapping):
|
|
108
|
+
"""Text files under ``rootdir``, keyed by relative POSIX path."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, rootdir: str | os.PathLike):
|
|
111
|
+
self.rootdir = Path(rootdir).expanduser()
|
|
112
|
+
|
|
113
|
+
def _path(self, k: str) -> Path:
|
|
114
|
+
return self.rootdir / k
|
|
115
|
+
|
|
116
|
+
def __getitem__(self, k: str) -> str:
|
|
117
|
+
try:
|
|
118
|
+
return self._path(k).read_text(encoding="utf-8")
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
raise KeyError(k) from None
|
|
121
|
+
|
|
122
|
+
def __setitem__(self, k: str, v: str) -> None:
|
|
123
|
+
p = self._path(k)
|
|
124
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
126
|
+
tmp.write_text(v, encoding="utf-8", newline="\n")
|
|
127
|
+
os.replace(tmp, p) # atomic: a crash never leaves half a file
|
|
128
|
+
|
|
129
|
+
def __delitem__(self, k: str) -> None:
|
|
130
|
+
try:
|
|
131
|
+
self._path(k).unlink()
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
raise KeyError(k) from None
|
|
134
|
+
|
|
135
|
+
def __iter__(self) -> Iterator[str]:
|
|
136
|
+
if not self.rootdir.exists():
|
|
137
|
+
return
|
|
138
|
+
for p in sorted(self.rootdir.rglob("*")):
|
|
139
|
+
if p.is_file() and ".git" not in p.parts:
|
|
140
|
+
yield p.relative_to(self.rootdir).as_posix()
|
|
141
|
+
|
|
142
|
+
def __len__(self) -> int:
|
|
143
|
+
return sum(1 for _ in self)
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
return f"{type(self).__name__}({str(self.rootdir)!r})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ csv in and out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _coerce(spec: TableSpec, row: dict) -> dict:
|
|
153
|
+
out = {c: row.get(c, "") for c in spec.columns}
|
|
154
|
+
for c in spec.int_columns:
|
|
155
|
+
v = out[c]
|
|
156
|
+
out[c] = int(v) if v not in ("", None) else ""
|
|
157
|
+
for c, v in out.items():
|
|
158
|
+
if isinstance(v, bool):
|
|
159
|
+
out[c] = "true" if v else "false"
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_csv(spec: TableSpec, text: str) -> list[dict]:
|
|
164
|
+
"""Rows of a table file, with integer columns as ``int``."""
|
|
165
|
+
return [_coerce(spec, r) for r in csv.DictReader(io.StringIO(text))]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sort_key(spec: TableSpec):
|
|
169
|
+
return lambda r: tuple(str(r[c]) for c in spec.sort_columns)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def dump_csv(spec: TableSpec, rows: Iterable[dict]) -> str:
|
|
173
|
+
"""CSV text of ``rows``, sorted by the spec's sort columns, ``\\n`` line ends."""
|
|
174
|
+
buf = io.StringIO()
|
|
175
|
+
w = csv.DictWriter(buf, fieldnames=spec.columns, lineterminator="\n")
|
|
176
|
+
w.writeheader()
|
|
177
|
+
w.writerows(sorted((_coerce(spec, r) for r in rows), key=_sort_key(spec)))
|
|
178
|
+
return buf.getvalue()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# -------------------------------------------------------------------------- merging
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _values(spec: TableSpec, row: dict) -> tuple:
|
|
185
|
+
return tuple(row[c] for c in spec.columns if c not in spec.key)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def merge_rows(spec: TableSpec, old: list[dict], new: list[dict]) -> list[dict]:
|
|
189
|
+
"""Merge ``new`` rows into ``old`` according to ``spec.merge``.
|
|
190
|
+
|
|
191
|
+
>>> v = TableSpec("views", ("date", "repo", "count", "uniques"),
|
|
192
|
+
... key=("date", "repo"), merge="max",
|
|
193
|
+
... int_columns=("count", "uniques"), time_column="date")
|
|
194
|
+
>>> old = [{"date": "2026-01-01", "repo": "a/b", "count": 3, "uniques": 1}]
|
|
195
|
+
>>> new = [{"date": "2026-01-01", "repo": "a/b", "count": 5, "uniques": 1},
|
|
196
|
+
... {"date": "2026-01-02", "repo": "a/b", "count": 1, "uniques": 1}]
|
|
197
|
+
>>> [r["count"] for r in merge_rows(v, old, new)]
|
|
198
|
+
[5, 1]
|
|
199
|
+
>>> merge_rows(v, merge_rows(v, old, new), new) == merge_rows(v, old, new)
|
|
200
|
+
True
|
|
201
|
+
"""
|
|
202
|
+
old = [_coerce(spec, r) for r in old]
|
|
203
|
+
new = [_coerce(spec, r) for r in new]
|
|
204
|
+
|
|
205
|
+
def key(r):
|
|
206
|
+
return tuple(str(r[c]) for c in spec.key)
|
|
207
|
+
|
|
208
|
+
if spec.merge in ("max", "upsert"):
|
|
209
|
+
merged = {key(r): r for r in old}
|
|
210
|
+
for r in new:
|
|
211
|
+
k = key(r)
|
|
212
|
+
if spec.merge == "max" and k in merged:
|
|
213
|
+
prev = merged[k]
|
|
214
|
+
r = {
|
|
215
|
+
c: (
|
|
216
|
+
max(prev[c], r[c])
|
|
217
|
+
if c in spec.int_columns and prev[c] != "" and r[c] != ""
|
|
218
|
+
else r[c]
|
|
219
|
+
if r[c] != ""
|
|
220
|
+
else prev[c]
|
|
221
|
+
)
|
|
222
|
+
for c in spec.columns
|
|
223
|
+
}
|
|
224
|
+
merged[k] = r
|
|
225
|
+
return list(merged.values())
|
|
226
|
+
|
|
227
|
+
if spec.merge == "replace_group":
|
|
228
|
+
groups = {tuple(str(r[c]) for c in spec.group) for r in new}
|
|
229
|
+
kept = [r for r in old if tuple(str(r[c]) for c in spec.group) not in groups]
|
|
230
|
+
return kept + new
|
|
231
|
+
|
|
232
|
+
if spec.merge == "change_only":
|
|
233
|
+
merged: dict[tuple, dict] = {}
|
|
234
|
+
for r in old + new: # same key: field-wise, a non-empty newer value wins
|
|
235
|
+
prev = merged.get(key(r), {})
|
|
236
|
+
merged[key(r)] = {
|
|
237
|
+
c: r[c] if r[c] != "" else prev.get(c, "") for c in spec.columns
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def entity(r):
|
|
241
|
+
return tuple(str(r[c]) for c in spec.entity)
|
|
242
|
+
|
|
243
|
+
kept, last_values = [], {}
|
|
244
|
+
when = spec.change_column
|
|
245
|
+
for r in sorted(merged.values(), key=lambda r: (entity(r), str(r[when]))):
|
|
246
|
+
if last_values.get(entity(r)) != _values(spec, r):
|
|
247
|
+
kept.append(r)
|
|
248
|
+
last_values[entity(r)] = _values(spec, r)
|
|
249
|
+
return kept
|
|
250
|
+
|
|
251
|
+
raise ValueError(f"Unknown merge policy {spec.merge!r} for table {spec.name!r}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def read_table(
|
|
255
|
+
store: MutableMapping, spec: TableSpec, *, years: Iterable[str] | None = None
|
|
256
|
+
) -> list[dict]:
|
|
257
|
+
"""All rows of a table (optionally only some year partitions)."""
|
|
258
|
+
keys = table_files(store, spec)
|
|
259
|
+
if years is not None:
|
|
260
|
+
wanted = {f"{spec.name}/{y}.csv" for y in years}
|
|
261
|
+
keys = [k for k in keys if k in wanted]
|
|
262
|
+
return [r for k in keys for r in parse_csv(spec, store[k])]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def table_files(store: MutableMapping, spec: TableSpec) -> list[str]:
|
|
266
|
+
"""The relative paths of a table's files present in ``store``, sorted."""
|
|
267
|
+
if spec.time_column is None:
|
|
268
|
+
return [f"{spec.name}.csv"] if f"{spec.name}.csv" in store else []
|
|
269
|
+
prefix = f"{spec.name}/"
|
|
270
|
+
return sorted(k for k in store if k.startswith(prefix) and k.endswith(".csv"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def write_rows(store: MutableMapping, spec: TableSpec, rows: Iterable[dict]) -> int:
|
|
274
|
+
"""Merge ``rows`` into the table's files; return how many files changed."""
|
|
275
|
+
by_path: dict[str, list[dict]] = {}
|
|
276
|
+
for r in rows:
|
|
277
|
+
by_path.setdefault(spec.path_for(r), []).append(r)
|
|
278
|
+
changed = 0
|
|
279
|
+
for path, new in by_path.items():
|
|
280
|
+
old_text = store.get(path)
|
|
281
|
+
old = parse_csv(spec, old_text) if old_text else []
|
|
282
|
+
text = dump_csv(spec, merge_rows(spec, old, new))
|
|
283
|
+
if text != old_text:
|
|
284
|
+
store[path] = text
|
|
285
|
+
changed += 1
|
|
286
|
+
return changed
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------------ manifest
|
|
290
|
+
|
|
291
|
+
MANIFEST_KEY = "manifest.json"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def manifest(store: MutableMapping, tables: Mapping[str, TableSpec], **extra) -> dict:
|
|
295
|
+
"""A description of the tables in ``store``: files, columns, keys.
|
|
296
|
+
|
|
297
|
+
``extra`` keys (a schema version, an update date) come first. A reader (a
|
|
298
|
+
dashboard) can discover every file of every table from it in one request.
|
|
299
|
+
"""
|
|
300
|
+
return {
|
|
301
|
+
**extra,
|
|
302
|
+
"tables": {
|
|
303
|
+
name: {
|
|
304
|
+
"files": table_files(store, spec),
|
|
305
|
+
"columns": list(spec.columns),
|
|
306
|
+
"key": list(spec.key),
|
|
307
|
+
"description": spec.description,
|
|
308
|
+
}
|
|
309
|
+
for name, spec in tables.items()
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def write_manifest(
|
|
315
|
+
store: MutableMapping,
|
|
316
|
+
tables: Mapping[str, TableSpec],
|
|
317
|
+
*,
|
|
318
|
+
key: str = MANIFEST_KEY,
|
|
319
|
+
**extra,
|
|
320
|
+
) -> dict:
|
|
321
|
+
"""Write :func:`manifest` as JSON under ``key`` (only if it changed)."""
|
|
322
|
+
m = manifest(store, tables, **extra)
|
|
323
|
+
text = json.dumps(m, indent=2) + "\n"
|
|
324
|
+
if store.get(key) != text:
|
|
325
|
+
store[key] = text
|
|
326
|
+
return m
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: csvledger
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Long-form CSV tables that merge idempotently (max, replace-group, change-only, upsert), partitioned by year, in any MutableMapping store: a git-friendly history store with no dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/csvledger
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/csvledger
|
|
7
|
+
Author: Thor Whalen
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: csv,dol,git,history,idempotent,merge,time-series
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: docs
|
|
17
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
18
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# csvledger
|
|
22
|
+
|
|
23
|
+
Long-form CSV tables that merge idempotently: a git-friendly history store with no dependencies.
|
|
24
|
+
|
|
25
|
+
You collect numbers on a schedule (traffic, downloads, prices), each run sees an overlapping window, and you want one growing history in plain files: re-runs harmless, the order of runs irrelevant, small diffs, past years never rewritten. `csvledger` is that store.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from csvledger import TableSpec, Files, write_rows, read_table
|
|
29
|
+
|
|
30
|
+
views = TableSpec(
|
|
31
|
+
"views", ("date", "repo", "count"),
|
|
32
|
+
key=("date", "repo"), merge="max", int_columns=("count",), time_column="date",
|
|
33
|
+
)
|
|
34
|
+
store = Files("history") # or any MutableMapping[str, str]
|
|
35
|
+
write_rows(store, views, [{"date": "2026-01-01", "repo": "a/b", "count": 3}])
|
|
36
|
+
write_rows(store, views, [{"date": "2026-01-01", "repo": "a/b", "count": 2}]) # no change
|
|
37
|
+
read_table(store, views) # [{'date': '2026-01-01', 'repo': 'a/b', 'count': 3}]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This writes `history/views/2026.csv`, sorted by date, so each run appends at the end of the current year's file.
|
|
41
|
+
|
|
42
|
+
## Merge policies
|
|
43
|
+
|
|
44
|
+
| `merge=` | per key | for |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `max` | integer fields keep the larger value | daily counts seen by overlapping windows (a partial day only grows) |
|
|
47
|
+
| `replace_group` | rows of a `group` (e.g. one repo, one fetch date) replace that group's earlier rows | top-N snapshots |
|
|
48
|
+
| `change_only` | a row is kept only when its values differ from the previous row of its `entity` (key = entity + one date column) | slowly changing values (stars, versions) |
|
|
49
|
+
| `upsert` | the new row wins | current metadata |
|
|
50
|
+
|
|
51
|
+
All four are idempotent: merging the same rows again changes nothing.
|
|
52
|
+
|
|
53
|
+
## The store
|
|
54
|
+
|
|
55
|
+
Any `MutableMapping[str, str]` (relative path to text). `Files(root)` is a folder with atomic writes; a [dol](https://github.com/i2mint/dol) store puts the same tables in S3 or a database. `write_manifest(store, tables, **extra)` writes a `manifest.json` listing every table's files and columns, so a static dashboard can discover the data in one request.
|
|
56
|
+
|
|
57
|
+
Used by [ghtraffic](https://github.com/thorwhalen/ghtraffic) (GitHub traffic) and [pkgreach](https://github.com/thorwhalen/pkgreach) (package downloads), where it was extracted from.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
csvledger/__init__.py,sha256=USpq4Aj2KMxMr_HPUGMAaAvDv8NQ79Anq-JcqjZ8ijw,11640
|
|
2
|
+
csvledger-0.0.2.dist-info/METADATA,sha256=j3QVjcnQK8guQ6IZji3QRJVXF7jCfYs9Rvj9cbD8sro,3044
|
|
3
|
+
csvledger-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
4
|
+
csvledger-0.0.2.dist-info/licenses/LICENSE,sha256=ekE13yCE9fWWEJfP1NPnUJabZ_Wwrc6coKJV2ZZx27w,1068
|
|
5
|
+
csvledger-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
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.
|