dgconvert 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.
dgconvert/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """dgconvert — a helper library for building dgpkm-compatible seeds."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ __version__ = _version("dgconvert")
@@ -0,0 +1,39 @@
1
+ """Body-snatcher (find/replace) content transforms, sourced from a
2
+ seed's own docufig.seed.yaml.
3
+
4
+ `bodySnatchers` is a required field in dgpkm's own seed config JSON
5
+ Schema (validated by `dgpkm doctor schema seed`), not a decorative
6
+ one -- so it's read directly from that already-schema-validated
7
+ manifest rather than requiring a seed's own Python code to keep a
8
+ second, hand-maintained copy in sync (a real, seen-in-practice failure
9
+ mode: a seed's manifest drifting out of sync with its own Python-side
10
+ transform table after a code-only change, caught only by a direct
11
+ question, not by any tooling).
12
+
13
+ Generalized out of two seeds' identical apply_body_snatchers once a
14
+ second seed needed the exact same shape a first one already had --
15
+ the same "second consumer" trigger this library's other modules were
16
+ each generalized under.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+
26
+ def load(seed_config_path: Path) -> tuple[tuple[str, str], ...]:
27
+ """Reads bodySnatchers from a seed's docufig.seed.yaml. Each entry's
28
+ {find, replace} shape matches dgpkm's own seed config JSON Schema
29
+ exactly. A missing bodySnatchers key (defensive -- the schema
30
+ requires it, but this shouldn't hard-fail a caller that hasn't
31
+ validated yet) behaves like an empty list."""
32
+ data = yaml.safe_load(seed_config_path.read_text(encoding="utf-8")) or {}
33
+ return tuple((s["find"], s["replace"]) for s in data.get("bodySnatchers", []))
34
+
35
+
36
+ def apply(text: str, snatchers: tuple[tuple[str, str], ...]) -> str:
37
+ for find, replace in snatchers:
38
+ text = text.replace(find, replace)
39
+ return text
dgconvert/dgpkm.py ADDED
@@ -0,0 +1,62 @@
1
+ """Wraps the `dgpkm` binary for verification.
2
+
3
+ `dgpkm doctor backlinks` and `dgpkm doctor schema <type>` always exit 0,
4
+ even when they report findings -- findings are only visible in
5
+ stdout/stderr text ("Found N broken link(s)", "✗ ..."). This module
6
+ parses that text so the converter can fail loudly (non-zero exit) when a
7
+ conversion isn't doctor-clean, which dgpkm itself won't do for you.
8
+
9
+ Generalized beyond its original prototype in one way: `verify`'s schema
10
+ check is now optional (`check_schema=None` skips it) since not every
11
+ consumer has a `docufig.seed.json` to validate -- a standalone
12
+ converter tool can write straight into a greenhouse's root garden,
13
+ not a separate seed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+
23
+ def dgpkm_bin() -> str:
24
+ return os.environ.get("DGPKM_BIN", "dgpkm")
25
+
26
+
27
+ def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
28
+ return subprocess.run(
29
+ [dgpkm_bin(), *args],
30
+ cwd=cwd,
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+
36
+
37
+ def verify(root: Path, *, check_schema: str | None = "seed") -> list[str]:
38
+ """Run dgpkm doctor checks against the garden at `root`. Returns a list
39
+ of human-readable problem descriptions (empty = clean).
40
+
41
+ `check_schema` names the config type to pass to `dgpkm doctor schema
42
+ <type>` (e.g. "seed" for a `docufig.seed.json`); pass None to skip
43
+ that check entirely for a tool that has no such config file.
44
+ """
45
+ problems: list[str] = []
46
+
47
+ backlinks = _run(["doctor", "backlinks"], root)
48
+ if backlinks.returncode != 0:
49
+ problems.append(f"dgpkm doctor backlinks failed to run:\n{backlinks.stderr}")
50
+ elif "All backlinks are valid" not in backlinks.stdout:
51
+ problems.append(f"backlinks: broken links found\n{backlinks.stdout}")
52
+
53
+ if check_schema:
54
+ schema = _run(["doctor", "schema", check_schema], root)
55
+ if schema.returncode != 0:
56
+ problems.append(f"dgpkm doctor schema {check_schema} failed to run:\n{schema.stderr}")
57
+ elif "✓" not in schema.stdout or "✗" in schema.stderr:
58
+ problems.append(
59
+ f"{check_schema} config schema invalid:\n{schema.stdout}{schema.stderr}"
60
+ )
61
+
62
+ return problems
@@ -0,0 +1,121 @@
1
+ """Single-line frontmatter emission, and a naive line-based parser kept
2
+ only for reading back this module's own prior output.
3
+
4
+ Historically dgpkm's frontmatter reader was itself naive -- a raw
5
+ `strings.SplitN(content, "---", 3)` plus a
6
+ per-line `key.partition(":")` scanner -- so this emitter avoided a real
7
+ YAML library entirely and just sanitized every value to a single line.
8
+ dgpkm's reader is now real YAML both directions
9
+ (`go.yaml.in/yaml/v4`, a line-anchored frontmatter split), which
10
+ surfaced a real bug here: an *unquoted* value that happens to look like
11
+ another YAML type (`title: 2022-09-09` parses as a date, not a string;
12
+ `desc: A | B: C` breaks the scanner outright on the embedded `:`) was
13
+ silently wrong. `emit_note` now runs every known-field value through
14
+ `yaml.safe_dump` (preserving `int`s as real YAML integers so
15
+ `updated`/`created` still round-trip as numbers) so quoting is decided
16
+ correctly by the same library dgpkm itself now uses, while keeping
17
+ `sanitize()`'s single-line collapse and `---`-neutralization as a
18
+ defense-in-depth measure. `parse_note` below stays naive -- it only
19
+ reads back `id`/`created`/`sourceFileHash` for incremental hash-checking
20
+ (dgconvert.incremental), and none of those three fields are ever
21
+ ambiguous enough for `yaml.safe_dump` to quote in practice (hex digests,
22
+ real ints, and Dendron/TGDP's actual id formats) -- but a value in that
23
+ position that *did* need quoting would round-trip with the quote
24
+ characters still literally in it here.
25
+
26
+ Generalized out of a first seed's own emitter once a second, independent
27
+ consumer needed the same thing.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import yaml
33
+
34
+ KEY_ORDER = (
35
+ "id",
36
+ "title",
37
+ "desc",
38
+ "canonicalUrl",
39
+ "license",
40
+ "sourceFileHash",
41
+ "sourceFileUrl",
42
+ "sourceRelease",
43
+ "sourceContributors",
44
+ "generator",
45
+ "frontmatterVersion",
46
+ "updated",
47
+ "created",
48
+ # dgpkm's own native "status" field, for a source format whose items
49
+ # have a task-like status to map onto it (e.g. an imported vault's
50
+ # own task notes). dgpkm's other two optional native fields, "type"
51
+ # (a YAML list) and "alias", aren't added here: no current caller
52
+ # sets them, and "type"'s list value would need emit_note's
53
+ # single-line-scalar sanitize() to grow flow-sequence handling first
54
+ # -- add both once a real caller needs them, not speculatively.
55
+ "status",
56
+ )
57
+
58
+
59
+ def sanitize(value: object) -> str:
60
+ """Collapse to a single line; neutralize dgpkm's frontmatter delimiter."""
61
+ text = " ".join(str(value).split())
62
+ return text.replace("---", "—")
63
+
64
+
65
+ def _dump_line(key: str, value: object) -> str:
66
+ return yaml.safe_dump(
67
+ {key: value}, default_flow_style=False, sort_keys=False, allow_unicode=True
68
+ ).rstrip("\n")
69
+
70
+
71
+ def emit_note(
72
+ frontmatter: dict[str, object], body: str, extra: dict[str, object] | None = None
73
+ ) -> str:
74
+ """Emits a dgpkm-compatible note. `frontmatter`'s keys are limited to
75
+ KEY_ORDER's known fields. String values are single-line-collapsed and
76
+ `---`-neutralized via `sanitize()` first; non-string values (e.g. the
77
+ `updated`/`created` ints) pass through unchanged so they stay real
78
+ YAML numbers. Either way, the final line is produced by
79
+ `yaml.safe_dump`, so a value that happens to look like another YAML
80
+ type (a bare date, a string containing an unescaped `:`) is quoted
81
+ correctly instead of corrupting the frontmatter block.
82
+
83
+ `extra` carries arbitrary passthrough keys beyond KEY_ORDER -- e.g. a
84
+ source format's own per-note provenance/task fields dgpkm doesn't
85
+ model natively. Extra values may additionally be nested (dicts/lists)
86
+ -- dgpkm's frontmatter reader is real YAML both directions
87
+ (go.yaml.in/yaml/v4). Keys are sorted for deterministic output,
88
+ matching how dgpkm's own writer orders its equivalent `Extra` map.
89
+ """
90
+ lines = ["---"]
91
+ for key in KEY_ORDER:
92
+ if key in frontmatter and frontmatter[key] not in (None, ""):
93
+ value = frontmatter[key]
94
+ if isinstance(value, str):
95
+ value = sanitize(value)
96
+ lines.append(_dump_line(key, value))
97
+ if extra:
98
+ for key in sorted(extra):
99
+ value = extra[key]
100
+ if value is None or value == "":
101
+ continue
102
+ lines.append(_dump_line(key, value))
103
+ lines.append("---")
104
+ return "\n".join(lines) + "\n\n" + body.rstrip("\n") + "\n"
105
+
106
+
107
+ def parse_note(text: str) -> tuple[dict[str, str], str]:
108
+ """Mirror dgpkm's parseNote: SplitN(content, "---", 3) then `key: value`
109
+ lines split on the first colon."""
110
+ parts = text.split("---", 2)
111
+ if len(parts) < 3:
112
+ raise ValueError("missing frontmatter (no '---' delimited block)")
113
+ _, fm_text, body = parts
114
+ frontmatter: dict[str, str] = {}
115
+ for line in fm_text.splitlines():
116
+ if not line.strip():
117
+ continue
118
+ key, sep, value = line.partition(":")
119
+ if sep:
120
+ frontmatter[key.strip()] = value.strip()
121
+ return frontmatter, body.strip()
@@ -0,0 +1,117 @@
1
+ """Generic incremental-conversion driver.
2
+
3
+ Generalized out of a first seed's own conversion loop once a second,
4
+ independent consumer needed the same create/skip/update/delete shape.
5
+
6
+ State lives in each note's own frontmatter (a `sourceFileHash` key), not a
7
+ separate cache file: unchanged hash -> skip entirely (byte-identical file
8
+ untouched); changed hash -> rewrite in place (same id, same created,
9
+ bumped updated); source gone -> delete the note and report it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable, Iterable
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from . import frontmatter as fm
19
+
20
+ # Called only for a new-or-changed item (never for an unchanged skip).
21
+ # Receives the previous note's id/created if one existed (None for a
22
+ # brand-new note) so a rewritten note keeps its identity. Returns
23
+ # (frontmatter, body, extra); frontmatter may set "created"/"updated" as
24
+ # this item's source-derived defaults -- convert() only overrides
25
+ # "created" when an old value exists, and always overrides "id" (when an
26
+ # old value exists) and "sourceFileHash".
27
+ BuildFn = Callable[
28
+ ["str | None", "int | None"], "tuple[dict[str, object], str, dict[str, object] | None]"
29
+ ]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ConvertItem:
34
+ slug: str
35
+ source_hash: str
36
+ build: BuildFn
37
+
38
+
39
+ @dataclass
40
+ class ConvertResult:
41
+ counts: dict[str, int] = field(
42
+ default_factory=lambda: {"created": 0, "updated": 0, "unchanged": 0, "deleted": 0}
43
+ )
44
+ deleted: list[str] = field(default_factory=list)
45
+
46
+
47
+ def read_existing(notes_dir: Path, glob_patterns: Iterable[str]) -> dict[str, dict[str, str]]:
48
+ """Reads back every previously-emitted note matching glob_patterns,
49
+ keyed by slug (filename stem) -> its parsed frontmatter dict."""
50
+ existing: dict[str, dict[str, str]] = {}
51
+ if notes_dir.is_dir():
52
+ for pattern in glob_patterns:
53
+ for path in notes_dir.glob(pattern):
54
+ frontmatter, _ = fm.parse_note(path.read_text(encoding="utf-8"))
55
+ existing[path.stem] = frontmatter
56
+ return existing
57
+
58
+
59
+ def convert(
60
+ notes_dir: Path,
61
+ items: Iterable[ConvertItem],
62
+ existing: dict[str, dict[str, str]],
63
+ *,
64
+ now_ms: int,
65
+ stable_id: Callable[[str], str],
66
+ ) -> ConvertResult:
67
+ """Runs the create/skip/update/delete loop, writing/deleting files
68
+ under `notes_dir`. `existing` is `read_existing`'s output -- passed in
69
+ rather than read internally so callers can glob exactly the slugs
70
+ their own hierarchy owns."""
71
+ result = ConvertResult()
72
+ desired_slugs: set[str] = set()
73
+ notes_dir.mkdir(parents=True, exist_ok=True)
74
+
75
+ for item in items:
76
+ desired_slugs.add(item.slug)
77
+ old = existing.get(item.slug)
78
+
79
+ if old and old.get("sourceFileHash") == item.source_hash:
80
+ result.counts["unchanged"] += 1
81
+ continue
82
+
83
+ # Only trust `old` for identity (id/created) if it actually carries
84
+ # a sourceFileHash -- i.e. it was itself written by a prior run of
85
+ # *this* incremental pipeline, not some unrelated pre-existing note
86
+ # that happens to occupy the same slug (e.g. `dgpkm init`'s own
87
+ # bootstrap notes/root.md, before any converter has ever run).
88
+ # Otherwise a foreign note's id/created would get silently adopted
89
+ # as if it were this item's own prior state.
90
+ own_old = old if (old and old.get("sourceFileHash")) else None
91
+ old_id = (own_old or {}).get("id") or None
92
+ old_created_raw = (own_old or {}).get("created")
93
+ old_created = int(old_created_raw) if old_created_raw else None
94
+
95
+ built_fm, body, extra = item.build(old_id, old_created)
96
+
97
+ note_id = old_id or built_fm.get("id") or stable_id(item.slug)
98
+ created = old_created or built_fm.get("created") or now_ms
99
+ merged_fm: dict[str, object] = {
100
+ **built_fm,
101
+ "id": note_id,
102
+ "created": created,
103
+ "updated": built_fm.get("updated", now_ms),
104
+ "sourceFileHash": item.source_hash,
105
+ }
106
+ (notes_dir / f"{item.slug}.md").write_text(
107
+ fm.emit_note(merged_fm, body, extra=extra), encoding="utf-8"
108
+ )
109
+ result.counts["updated" if old else "created"] += 1
110
+
111
+ deleted = sorted(set(existing) - desired_slugs)
112
+ for slug in deleted:
113
+ (notes_dir / f"{slug}.md").unlink()
114
+ result.counts["deleted"] += 1
115
+ result.deleted = deleted
116
+
117
+ return result
dgconvert/links.py ADDED
@@ -0,0 +1,59 @@
1
+ """Markdown link scanning and rewriting.
2
+
3
+ dgpkm's markdown-link extractor is
4
+ `\\]\\(\\.?\\/?([^#\\)]+)\\.md(#[^\\)]*)?\\)` -- it matches ANY `](....md)`
5
+ or `](....md#anchor)`, including full http(s) URLs, and treats the captured
6
+ path as a note slug to validate. `doctor backlinks` then reports it broken
7
+ if no note has that exact "slug". A link to unconverted or external content
8
+ that happens to bare-end in ".md" must therefore carry a query string --
9
+ appending one defeats the regex, since it requires ".md" to be immediately
10
+ followed by "#" or the closing paren. This is confirmed necessary against
11
+ real converted content: some source formats reference other projects'
12
+ CHANGELOG.md/README.md by full https URL with no query string.
13
+
14
+ Generalized out of a first seed's own link-rewriting module, unmodified,
15
+ once a second, independent consumer needed the same thing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections.abc import Callable
22
+
23
+ LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
24
+ _BARE_MD_RE = re.compile(r"\.md(#[^?]*)?$")
25
+
26
+ Resolver = Callable[[str, str], "str | None"]
27
+
28
+
29
+ def defeat_md_regex(href: str) -> str:
30
+ """Append a harmless query string if href bare-ends in .md or .md#anchor."""
31
+ if "?" in href:
32
+ return href
33
+ match = _BARE_MD_RE.search(href)
34
+ if not match:
35
+ return href
36
+ anchor = match.group(1) or ""
37
+ base = href[: match.start()]
38
+ return f"{base}.md?ref_type=tags{anchor}"
39
+
40
+
41
+ def rewrite_links(body: str, resolve: Resolver) -> str:
42
+ """resolve(text, href) -> replacement markdown-link string, or None to
43
+ leave the original link untouched (beyond the generic http(s) fixup
44
+ below)."""
45
+
46
+ def _sub(match: re.Match[str]) -> str:
47
+ text, href = match.group(1), match.group(2)
48
+ if href.startswith(("mailto:", "#")):
49
+ return match.group(0)
50
+ replacement = resolve(text, href)
51
+ if replacement is not None:
52
+ return replacement
53
+ if href.startswith(("http://", "https://")):
54
+ fixed = defeat_md_regex(href)
55
+ if fixed != href:
56
+ return f"[{text}]({fixed})"
57
+ return match.group(0)
58
+
59
+ return LINK_RE.sub(_sub, body)
dgconvert/slugs.py ADDED
@@ -0,0 +1,20 @@
1
+ """Slug joining and dgpkm link builders (alias-after only -- dgpkm cannot
2
+ parse Dendron's alias-first on-disk form).
3
+
4
+ Generalized out of a first seed's own slug module, unmodified, once a
5
+ second, independent consumer needed the same thing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def slug(*parts: str) -> str:
12
+ return ".".join(p.strip(".") for p in parts if p)
13
+
14
+
15
+ def wikilink(target_slug: str, anchor: str | None = None, alias: str | None = None) -> str:
16
+ frag = f"#{anchor}" if anchor else ""
17
+ if alias and alias != target_slug:
18
+ safe_alias = alias.replace("|", "/").replace("]]", ")")
19
+ return f"[[{target_slug}{frag}|{safe_alias}]]"
20
+ return f"[[{target_slug}{frag}]]"
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: dgconvert
3
+ Version: 0.1.0
4
+ Summary: Helper library for building dgpkm-compatible seeds (frontmatter, slugs, link rewriting, dgpkm doctor verification)
5
+ Project-URL: Homepage, https://github.com/ScriptAutomate/dgconvert
6
+ Project-URL: Repository, https://github.com/ScriptAutomate/dgconvert
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: pyyaml>=6
16
+ Description-Content-Type: text/markdown
17
+
18
+ # dgconvert
19
+
20
+ A small Python **helper library** for building
21
+ [dgpkm](https://github.com/ScriptAutomate/dgpkm)-compatible **seeds**
22
+ (pre-created gardens, ready to plant into a greenhouse).
23
+
24
+ `dgconvert` is not a task runner. Each seed repo owns its own
25
+ `docufig.seed.json` and its own plain-Python conversion script;
26
+ `dgconvert` supplies the pieces every seed needs regardless of source
27
+ format — dotted-slug file naming, single-line frontmatter with stable
28
+ ids, markdown-link → dgpkm-wikilink rewriting, and a wrapper that
29
+ verifies output with `dgpkm doctor`.
30
+
31
+ See `DESIGN.md` for the full architecture and the verified
32
+ dgpkm-compatibility constraints it's built against. The library was
33
+ generalized out of a first seed's own inline conversion code once a
34
+ second, independent seed needed the same pieces.
35
+
36
+ ## Installing
37
+
38
+ ```bash
39
+ uv add dgconvert
40
+ # or
41
+ pip install dgconvert
42
+ ```
43
+
44
+ ## Development
45
+
46
+ Managed with [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/),
47
+ using [Spec Kit](https://github.com/github/spec-kit) for feature specs
48
+ (`specs/`, `.specify/`) — unlike seed repos, which don't use Spec Kit:
49
+
50
+ ```bash
51
+ just # list tasks
52
+ just sync # uv sync
53
+ just test # uv run pytest
54
+ just check # format + lint
55
+ ```
56
+
57
+ Container-based development mirrors dgpkm: set `USE_CONTAINER_DEV` to
58
+ `podman`, `docker`, or `container`, then `just build-devcontainer` once and
59
+ every build/test recipe runs inside the dev container automatically.
60
+
61
+ ## License
62
+
63
+ [MIT](LICENSE)
@@ -0,0 +1,11 @@
1
+ dgconvert/__init__.py,sha256=rNzJg-RSjn_T-XVkP_9eXzgWl7caxwe68a86cBUH62U,163
2
+ dgconvert/body_snatchers.py,sha256=U3MovEeprdSqK5bLm9bhkfhhetVhHsSJnOdz490z9Ps,1629
3
+ dgconvert/dgpkm.py,sha256=VhhgqcKwHVZU5xJROlXKRXqfL4wUCpB1vOU-muYj9v0,2291
4
+ dgconvert/frontmatter.py,sha256=DPoscMdLVVgUVEQuFHAkKLKN6gmqmkC-bsUql18HduI,5059
5
+ dgconvert/incremental.py,sha256=1eOA0WGAzf-iZzWaU6yL-jVC2jxMTlxCgmDarJY3BVQ,4474
6
+ dgconvert/links.py,sha256=-eRHr-CIvZAF7hSF-b2J-XpZTWFuJGV9t_WqqsR4wAg,2189
7
+ dgconvert/slugs.py,sha256=QcSFkrVR80vzPrl7gOMX7sAwSWBgZD1XIRln6BS5qVc,698
8
+ dgconvert-0.1.0.dist-info/METADATA,sha256=gYX4jOXCvX4Ax-BVs95AowkIiQ0OltTXlzV4hebEB_M,2190
9
+ dgconvert-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ dgconvert-0.1.0.dist-info/licenses/LICENSE,sha256=iVk1IgRYica_g42kpWgHz9xFsghhc2_DjNyonzLWpMI,1071
11
+ dgconvert-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 ScriptAutomate
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.