knott 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.
- knott/__init__.py +26 -0
- knott/_values.py +50 -0
- knott/_yaml.py +151 -0
- knott/api.py +197 -0
- knott/cli.py +140 -0
- knott/errors.py +26 -0
- knott/models.py +50 -0
- knott/py.typed +0 -0
- knott/schema/__init__.py +0 -0
- knott/schema/loader.py +282 -0
- knott/schema/models.py +42 -0
- knott/schema/validator.py +263 -0
- knott/vault/__init__.py +0 -0
- knott/vault/discovery.py +37 -0
- knott/vault/entity.py +106 -0
- knott/vault/links.py +122 -0
- knott-0.1.0.dist-info/METADATA +146 -0
- knott-0.1.0.dist-info/RECORD +21 -0
- knott-0.1.0.dist-info/WHEEL +4 -0
- knott-0.1.0.dist-info/entry_points.txt +2 -0
- knott-0.1.0.dist-info/licenses/LICENSE +21 -0
knott/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Knott: a local, file-first knowledge layer for AI agents."""
|
|
2
|
+
|
|
3
|
+
from knott.api import Knott, version
|
|
4
|
+
from knott.errors import (
|
|
5
|
+
ConfigError,
|
|
6
|
+
KnottError,
|
|
7
|
+
PathNotFoundError,
|
|
8
|
+
PathOutsideVaultError,
|
|
9
|
+
VaultNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from knott.models import InitResult, Issue, SchemaInfo, Stats, ValidationResult
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ConfigError",
|
|
15
|
+
"InitResult",
|
|
16
|
+
"Issue",
|
|
17
|
+
"Knott",
|
|
18
|
+
"KnottError",
|
|
19
|
+
"PathNotFoundError",
|
|
20
|
+
"PathOutsideVaultError",
|
|
21
|
+
"SchemaInfo",
|
|
22
|
+
"Stats",
|
|
23
|
+
"ValidationResult",
|
|
24
|
+
"VaultNotFoundError",
|
|
25
|
+
"version",
|
|
26
|
+
]
|
knott/_values.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Describing constructed YAML values in messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Boolean spellings accepted as written; YAML 1.1 also reads yes/no/on/off as booleans.
|
|
9
|
+
BOOLEAN_SPELLINGS = frozenset({"true", "True", "TRUE", "false", "False", "FALSE"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def kind(value: Any) -> str:
|
|
13
|
+
"""Human name for the YAML kind of a constructed value (exact types, no subclasses)."""
|
|
14
|
+
if value is None:
|
|
15
|
+
return "null"
|
|
16
|
+
names: dict[type, str] = {
|
|
17
|
+
bool: "boolean",
|
|
18
|
+
int: "integer",
|
|
19
|
+
float: "number",
|
|
20
|
+
str: "string",
|
|
21
|
+
dt.datetime: "datetime",
|
|
22
|
+
dt.date: "date",
|
|
23
|
+
list: "list",
|
|
24
|
+
dict: "mapping",
|
|
25
|
+
}
|
|
26
|
+
return names.get(type(value), type(value).__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_MAX_SHOWN = 80
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show(value: Any, raw: str | None = None) -> str:
|
|
33
|
+
"""Render a value in backticks, preferring its source text when known."""
|
|
34
|
+
if raw is not None:
|
|
35
|
+
return f"`{_truncate(raw)}`"
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return "`[]`" if not value else f"`[…]` ({len(value)} items)"
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return "`{}`" if not value else f"`{{…}}` ({len(value)} keys)"
|
|
40
|
+
if value is None:
|
|
41
|
+
return "`null`"
|
|
42
|
+
if type(value) is bool:
|
|
43
|
+
return "`true`" if value else "`false`"
|
|
44
|
+
if isinstance(value, dt.date):
|
|
45
|
+
return f"`{value.isoformat()}`"
|
|
46
|
+
return f"`{_truncate(str(value))}`"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _truncate(text: str) -> str:
|
|
50
|
+
return text if len(text) <= _MAX_SHOWN else text[: _MAX_SHOWN - 1] + "…"
|
knott/_yaml.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Safe YAML loading that also records source line numbers.
|
|
2
|
+
|
|
3
|
+
Internal module: PyYAML nodes never leave it. Callers get plain Python data
|
|
4
|
+
plus a map from key paths to line numbers and raw scalar text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
KeyPath = tuple[str | int, ...]
|
|
16
|
+
|
|
17
|
+
# Upper bound on nodes after alias expansion. Real frontmatter and schemas are
|
|
18
|
+
# tiny; this only stops alias bombs (including via `<<` merge keys).
|
|
19
|
+
MAX_EXPANDED_NODES = 100_000
|
|
20
|
+
_STR_TAG = "tag:yaml.org,2002:str"
|
|
21
|
+
|
|
22
|
+
_MARKDOWN_LINK_LINE = re.compile(r"\[[^\]]*\]\([^)]*\)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Loc:
|
|
27
|
+
"""Where a value sits in its source text.
|
|
28
|
+
|
|
29
|
+
``line`` is 0-based within the parsed YAML text: the line of the mapping key
|
|
30
|
+
for mapping entries, or of the item itself for sequence items.
|
|
31
|
+
``raw`` is the scalar's source text, when the value is a scalar.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
line: int
|
|
35
|
+
raw: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class YamlDocument:
|
|
40
|
+
data: Any
|
|
41
|
+
locs: dict[KeyPath, Loc]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class YamlParseError(Exception):
|
|
45
|
+
def __init__(self, message: str, line: int | None) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.message = message
|
|
48
|
+
self.line = line # 0-based within the parsed text, when known
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load(text: str) -> YamlDocument:
|
|
52
|
+
"""Parse one YAML document with ``SafeLoader``. Raises ``YamlParseError``."""
|
|
53
|
+
try:
|
|
54
|
+
loader = yaml.SafeLoader(text)
|
|
55
|
+
except yaml.reader.ReaderError as exc:
|
|
56
|
+
reader_line = text.count("\n", 0, exc.position)
|
|
57
|
+
raise YamlParseError(f"invalid YAML: {exc.reason}", reader_line) from exc
|
|
58
|
+
try:
|
|
59
|
+
node = loader.get_single_node()
|
|
60
|
+
if node is not None:
|
|
61
|
+
_check_expansion(node)
|
|
62
|
+
data = loader.construct_document(node) if node is not None else None
|
|
63
|
+
except yaml.MarkedYAMLError as exc:
|
|
64
|
+
mark = exc.problem_mark or exc.context_mark
|
|
65
|
+
line = mark.line if mark is not None else None
|
|
66
|
+
raise YamlParseError(_describe(exc, text, line), line) from exc
|
|
67
|
+
except yaml.YAMLError as exc:
|
|
68
|
+
raise YamlParseError(str(exc), None) from exc
|
|
69
|
+
except ValueError as exc: # e.g. an impossible date such as 2026-13-45
|
|
70
|
+
raise YamlParseError(f"invalid value: {exc}", None) from exc
|
|
71
|
+
except RecursionError:
|
|
72
|
+
raise YamlParseError("invalid YAML: nesting is too deep", None) from None
|
|
73
|
+
except YamlParseError:
|
|
74
|
+
raise
|
|
75
|
+
except Exception as exc: # PyYAML constructors can fail oddly, e.g. `!!bool nope`
|
|
76
|
+
raise YamlParseError(f"invalid YAML value: {exc!r}", None) from exc
|
|
77
|
+
finally:
|
|
78
|
+
loader.dispose()
|
|
79
|
+
locs: dict[KeyPath, Loc] = {}
|
|
80
|
+
if node is not None:
|
|
81
|
+
try:
|
|
82
|
+
_collect(node, (), locs)
|
|
83
|
+
except RecursionError:
|
|
84
|
+
raise YamlParseError("invalid YAML: nesting is too deep", None) from None
|
|
85
|
+
return YamlDocument(data=data, locs=locs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe(exc: yaml.MarkedYAMLError, text: str, line: int | None) -> str:
|
|
89
|
+
parts = [p for p in (exc.context, exc.problem) if p]
|
|
90
|
+
message = "invalid YAML: " + ", ".join(parts) if parts else "invalid YAML"
|
|
91
|
+
lines = text.split("\n")
|
|
92
|
+
candidates = [line, line - 1] if line is not None else []
|
|
93
|
+
for candidate in candidates:
|
|
94
|
+
if 0 <= candidate < len(lines) and _MARKDOWN_LINK_LINE.search(lines[candidate]):
|
|
95
|
+
message += '; a Markdown link must be quoted in YAML, e.g. key: "[label](path.md)"'
|
|
96
|
+
break
|
|
97
|
+
return message
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_expansion(root: yaml.Node) -> None:
|
|
101
|
+
"""Reject documents whose alias expansion would be huge or recursive."""
|
|
102
|
+
sizes: dict[int, int] = {}
|
|
103
|
+
in_progress: set[int] = set()
|
|
104
|
+
|
|
105
|
+
def size(node: yaml.Node) -> int:
|
|
106
|
+
key = id(node)
|
|
107
|
+
if key in sizes:
|
|
108
|
+
return sizes[key]
|
|
109
|
+
if key in in_progress:
|
|
110
|
+
raise YamlParseError("invalid YAML: recursive alias", node.start_mark.line)
|
|
111
|
+
in_progress.add(key)
|
|
112
|
+
total = 1
|
|
113
|
+
if isinstance(node, yaml.MappingNode):
|
|
114
|
+
for key_node, value_node in node.value:
|
|
115
|
+
total += size(key_node) + size(value_node)
|
|
116
|
+
if total > MAX_EXPANDED_NODES:
|
|
117
|
+
break
|
|
118
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
119
|
+
for item in node.value:
|
|
120
|
+
total += size(item)
|
|
121
|
+
if total > MAX_EXPANDED_NODES:
|
|
122
|
+
break
|
|
123
|
+
in_progress.discard(key)
|
|
124
|
+
if total > MAX_EXPANDED_NODES:
|
|
125
|
+
raise YamlParseError(
|
|
126
|
+
"invalid YAML: aliases expand to too many values", node.start_mark.line
|
|
127
|
+
)
|
|
128
|
+
sizes[key] = total
|
|
129
|
+
return total
|
|
130
|
+
|
|
131
|
+
size(root)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _collect(node: yaml.Node, path: KeyPath, locs: dict[KeyPath, Loc]) -> None:
|
|
135
|
+
# Walks aliased nodes once per path, so aliased fields get full metadata.
|
|
136
|
+
# Safe because _check_expansion already bounded the expanded size.
|
|
137
|
+
if isinstance(node, yaml.MappingNode):
|
|
138
|
+
for key_node, value_node in node.value:
|
|
139
|
+
# Only string keys: a boolean `true:` must not share locs with `"true":`.
|
|
140
|
+
if not isinstance(key_node, yaml.ScalarNode) or key_node.tag != _STR_TAG:
|
|
141
|
+
continue
|
|
142
|
+
child = (*path, key_node.value)
|
|
143
|
+
raw = value_node.value if isinstance(value_node, yaml.ScalarNode) else None
|
|
144
|
+
locs[child] = Loc(key_node.start_mark.line, raw)
|
|
145
|
+
_collect(value_node, child, locs)
|
|
146
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
147
|
+
for index, item in enumerate(node.value):
|
|
148
|
+
child = (*path, index)
|
|
149
|
+
raw = item.value if isinstance(item, yaml.ScalarNode) else None
|
|
150
|
+
locs[child] = Loc(item.start_mark.line, raw)
|
|
151
|
+
_collect(item, child, locs)
|
knott/api.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The Knott API. The CLI is a thin adapter over this module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from importlib.metadata import PackageNotFoundError
|
|
8
|
+
from importlib.metadata import version as _dist_version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from knott import _yaml
|
|
12
|
+
from knott.errors import ConfigError, PathNotFoundError, PathOutsideVaultError
|
|
13
|
+
from knott.models import InitResult, Issue, SchemaInfo, Stats, ValidationResult
|
|
14
|
+
from knott.schema.loader import SCHEMAS_DIR, load_schemas
|
|
15
|
+
from knott.schema.validator import validate_entity
|
|
16
|
+
from knott.vault.discovery import KNOTT_DIR, find_vault_root, iter_markdown
|
|
17
|
+
from knott.vault.entity import MarkdownFile, read_markdown
|
|
18
|
+
from knott.vault.links import ResolvedReference, resolve_reference
|
|
19
|
+
|
|
20
|
+
CONFIG_FILE = Path(KNOTT_DIR) / "config.yaml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def version() -> str:
|
|
24
|
+
"""The installed Knott version."""
|
|
25
|
+
try:
|
|
26
|
+
return _dist_version("knott")
|
|
27
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
28
|
+
return "0.0.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Knott:
|
|
32
|
+
"""A Knott vault on disk."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, root: Path) -> None:
|
|
35
|
+
self.root = root
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def open(cls, path: str | Path = ".") -> Knott:
|
|
39
|
+
"""Open the vault containing ``path``, walking up to the nearest ``.knott/``.
|
|
40
|
+
|
|
41
|
+
Raises ``VaultNotFoundError``, ``PathNotFoundError`` or ``ConfigError``.
|
|
42
|
+
"""
|
|
43
|
+
start = Path(path)
|
|
44
|
+
if not start.exists():
|
|
45
|
+
raise PathNotFoundError(f"path does not exist: {path}")
|
|
46
|
+
vault = cls(find_vault_root(start))
|
|
47
|
+
vault._check_config()
|
|
48
|
+
return vault
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def init(path: str | Path = ".") -> InitResult:
|
|
52
|
+
"""Create ``.knott/schemas/`` and ``.knott/config.yaml`` in ``path``.
|
|
53
|
+
|
|
54
|
+
Changes nothing if ``.knott/`` already exists.
|
|
55
|
+
"""
|
|
56
|
+
root = Path(path)
|
|
57
|
+
if not root.is_dir():
|
|
58
|
+
raise PathNotFoundError(f"directory does not exist: {path}")
|
|
59
|
+
knott_dir = root / KNOTT_DIR
|
|
60
|
+
if knott_dir.exists():
|
|
61
|
+
return InitResult(root=str(root.resolve()), created=False)
|
|
62
|
+
(root / SCHEMAS_DIR).mkdir(parents=True)
|
|
63
|
+
(root / CONFIG_FILE).write_text(f"knott_version: {version()}\n", encoding="utf-8")
|
|
64
|
+
return InitResult(root=str(root.resolve()), created=True)
|
|
65
|
+
|
|
66
|
+
def types(self) -> list[str]:
|
|
67
|
+
"""Discovered type names, sorted. Schemas with errors may be missing."""
|
|
68
|
+
return [info.type for info in self.schemas()]
|
|
69
|
+
|
|
70
|
+
def schemas(self) -> list[SchemaInfo]:
|
|
71
|
+
"""Discovered types with their description and schema file, sorted by type."""
|
|
72
|
+
registry, _ = load_schemas(self.root)
|
|
73
|
+
return [
|
|
74
|
+
SchemaInfo(type=s.type, description=s.description, path=s.path)
|
|
75
|
+
for _, s in sorted(registry.items())
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def schema_issues(self) -> list[Issue]:
|
|
79
|
+
"""Problems found while loading schemas, sorted."""
|
|
80
|
+
_, issues = load_schemas(self.root)
|
|
81
|
+
return _sorted(issues)
|
|
82
|
+
|
|
83
|
+
def validate(self, paths: Sequence[str | Path] | None = None) -> ValidationResult:
|
|
84
|
+
"""Validate all schemas, and entities (all, or those within ``paths``).
|
|
85
|
+
|
|
86
|
+
Relative ``paths`` are resolved against the vault root. Validation problems
|
|
87
|
+
are returned as data; only usage errors raise.
|
|
88
|
+
"""
|
|
89
|
+
schemas, issues = load_schemas(self.root)
|
|
90
|
+
files: dict[str, MarkdownFile] = {}
|
|
91
|
+
|
|
92
|
+
def lookup(rel_path: str) -> MarkdownFile:
|
|
93
|
+
parsed = files.get(rel_path)
|
|
94
|
+
if parsed is None:
|
|
95
|
+
parsed = read_markdown(self.root / rel_path, rel_path)
|
|
96
|
+
files[rel_path] = parsed
|
|
97
|
+
return parsed
|
|
98
|
+
|
|
99
|
+
def resolve(source: str, reference: str) -> ResolvedReference:
|
|
100
|
+
return resolve_reference(self.root, source, reference)
|
|
101
|
+
|
|
102
|
+
all_markdown = iter_markdown(self.root)
|
|
103
|
+
if paths is None:
|
|
104
|
+
in_scope = all_markdown
|
|
105
|
+
else:
|
|
106
|
+
in_scope, scope_issues = self._scope(paths, all_markdown)
|
|
107
|
+
issues.extend(scope_issues)
|
|
108
|
+
|
|
109
|
+
entities = 0
|
|
110
|
+
relations = 0
|
|
111
|
+
for rel_path in in_scope:
|
|
112
|
+
parsed = lookup(rel_path)
|
|
113
|
+
if parsed.error is not None:
|
|
114
|
+
issues.append(
|
|
115
|
+
Issue(
|
|
116
|
+
code="entity-invalid-frontmatter",
|
|
117
|
+
path=rel_path,
|
|
118
|
+
line=parsed.error_line,
|
|
119
|
+
message=parsed.error,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
if not parsed.is_entity:
|
|
124
|
+
continue
|
|
125
|
+
entities += 1
|
|
126
|
+
report = validate_entity(parsed, schemas, resolve, lookup)
|
|
127
|
+
issues.extend(report.issues)
|
|
128
|
+
relations += report.relations
|
|
129
|
+
|
|
130
|
+
stats = Stats(schemas=len(schemas), entities=entities, relations=relations)
|
|
131
|
+
return ValidationResult(issues=_sorted(issues), stats=stats)
|
|
132
|
+
|
|
133
|
+
def _scope(
|
|
134
|
+
self,
|
|
135
|
+
paths: Sequence[str | Path],
|
|
136
|
+
all_markdown: list[str],
|
|
137
|
+
) -> tuple[list[str], list[Issue]]:
|
|
138
|
+
selected: set[str] = set()
|
|
139
|
+
issues: list[Issue] = []
|
|
140
|
+
for given in paths:
|
|
141
|
+
candidate = Path(given)
|
|
142
|
+
if not candidate.is_absolute():
|
|
143
|
+
candidate = self.root / candidate
|
|
144
|
+
if not candidate.exists():
|
|
145
|
+
raise PathNotFoundError(f"path does not exist: {given}")
|
|
146
|
+
# Resolve symlinks in the parent only, so a symlinked file keeps its own path.
|
|
147
|
+
absolute = Path(os.path.abspath(candidate))
|
|
148
|
+
absolute = absolute.parent.resolve() / absolute.name
|
|
149
|
+
try:
|
|
150
|
+
rel = absolute.relative_to(self.root)
|
|
151
|
+
except ValueError:
|
|
152
|
+
raise PathOutsideVaultError(
|
|
153
|
+
f"path is outside the vault {self.root}: {given}"
|
|
154
|
+
) from None
|
|
155
|
+
rel_posix = rel.as_posix()
|
|
156
|
+
if absolute.is_dir():
|
|
157
|
+
prefix = "" if rel_posix == "." else rel_posix + "/"
|
|
158
|
+
selected.update(p for p in all_markdown if p.startswith(prefix))
|
|
159
|
+
continue
|
|
160
|
+
parsed = read_markdown(absolute, rel_posix) if rel_posix.endswith(".md") else None
|
|
161
|
+
if parsed is not None and (parsed.is_entity or parsed.error is not None):
|
|
162
|
+
selected.add(rel_posix)
|
|
163
|
+
else:
|
|
164
|
+
issues.append(
|
|
165
|
+
Issue(
|
|
166
|
+
code="not-an-entity",
|
|
167
|
+
path=rel_posix,
|
|
168
|
+
message=(
|
|
169
|
+
"not a Knott entity: expected a Markdown file whose YAML "
|
|
170
|
+
"frontmatter has a `type` key"
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return sorted(selected), issues
|
|
175
|
+
|
|
176
|
+
def _check_config(self) -> None:
|
|
177
|
+
config = self.root / CONFIG_FILE
|
|
178
|
+
if not config.exists():
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
text = config.read_text(encoding="utf-8")
|
|
182
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
183
|
+
raise ConfigError(f"cannot read {CONFIG_FILE.as_posix()}: {exc}") from exc
|
|
184
|
+
try:
|
|
185
|
+
data = _yaml.load(text).data
|
|
186
|
+
except _yaml.YamlParseError as exc:
|
|
187
|
+
where = f":{exc.line + 1}" if exc.line is not None else ""
|
|
188
|
+
raise ConfigError(f"malformed {CONFIG_FILE.as_posix()}{where}: {exc.message}") from exc
|
|
189
|
+
if data is not None and not isinstance(data, dict):
|
|
190
|
+
raise ConfigError(f"malformed {CONFIG_FILE.as_posix()}: expected a YAML mapping")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _sorted(issues: list[Issue]) -> list[Issue]:
|
|
194
|
+
return sorted(
|
|
195
|
+
issues,
|
|
196
|
+
key=lambda i: (i.path, i.line or 0, i.field or "", i.code, i.message),
|
|
197
|
+
)
|
knott/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command-line interface: a thin adapter over ``knott.api``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from knott.api import Knott, version
|
|
13
|
+
from knott.errors import KnottError
|
|
14
|
+
from knott.models import Issue, ValidationResult
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="knott",
|
|
18
|
+
help="Knott: typed Markdown entities, schemas, and semantic relations.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
pretty_exceptions_enable=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
EXIT_INVALID = 1
|
|
25
|
+
EXIT_USAGE = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OutputFormat(StrEnum):
|
|
29
|
+
TEXT = "text"
|
|
30
|
+
JSON = "json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fail(error: KnottError) -> typer.Exit:
|
|
34
|
+
typer.echo(f"error: {error}", err=True)
|
|
35
|
+
return typer.Exit(EXIT_USAGE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _plural(count: int, singular: str, plural: str) -> str:
|
|
39
|
+
return f"{count} {singular if count == 1 else plural}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_issue(issue: Issue) -> str:
|
|
43
|
+
location = issue.path if issue.line is None else f"{issue.path}:{issue.line}"
|
|
44
|
+
header = f"✗ {location}" + (f" {issue.field}" if issue.field else "")
|
|
45
|
+
body = "\n".join(f" {line}" for line in issue.message.splitlines())
|
|
46
|
+
return f"{header}\n{body}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_result(result: ValidationResult) -> str:
|
|
50
|
+
if result.ok:
|
|
51
|
+
stats = result.stats
|
|
52
|
+
return "\n".join(
|
|
53
|
+
[
|
|
54
|
+
f"✓ {_plural(stats.schemas, 'schema', 'schemas')}",
|
|
55
|
+
f"✓ {_plural(stats.entities, 'entity', 'entities')}",
|
|
56
|
+
f"✓ {_plural(stats.relations, 'relation', 'relations')}",
|
|
57
|
+
"✓ vault is valid",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
blocks = [format_issue(issue) for issue in result.issues]
|
|
61
|
+
summary = _plural(len(result.issues), "validation error", "validation errors")
|
|
62
|
+
return "\n\n".join([*blocks, summary])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def init(
|
|
67
|
+
path: Annotated[
|
|
68
|
+
Path, typer.Argument(help="Directory to initialize (default: current directory).")
|
|
69
|
+
] = Path("."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Create .knott/schemas/ and .knott/config.yaml."""
|
|
72
|
+
try:
|
|
73
|
+
result = Knott.init(path)
|
|
74
|
+
except KnottError as error:
|
|
75
|
+
raise _fail(error) from None
|
|
76
|
+
if result.created:
|
|
77
|
+
typer.echo(f"Initialized Knott vault in {result.root}")
|
|
78
|
+
else:
|
|
79
|
+
typer.echo(f"Knott vault already initialized in {result.root}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def validate(
|
|
84
|
+
paths: Annotated[
|
|
85
|
+
list[Path] | None,
|
|
86
|
+
typer.Argument(help="Entity files or directories to check (default: the whole vault)."),
|
|
87
|
+
] = None,
|
|
88
|
+
output: Annotated[
|
|
89
|
+
OutputFormat, typer.Option("--format", help="Output format.")
|
|
90
|
+
] = OutputFormat.TEXT,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Validate schemas and entities."""
|
|
93
|
+
try:
|
|
94
|
+
start = paths[0] if paths else Path(".")
|
|
95
|
+
vault = Knott.open(start if start.exists() else Path("."))
|
|
96
|
+
result = vault.validate([p.absolute() for p in paths] if paths else None)
|
|
97
|
+
except KnottError as error:
|
|
98
|
+
raise _fail(error) from None
|
|
99
|
+
if output is OutputFormat.JSON:
|
|
100
|
+
payload = {
|
|
101
|
+
"ok": result.ok,
|
|
102
|
+
"stats": result.stats.model_dump(),
|
|
103
|
+
"issues": [issue.model_dump() for issue in result.issues],
|
|
104
|
+
}
|
|
105
|
+
typer.echo(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
106
|
+
else:
|
|
107
|
+
typer.echo(format_result(result))
|
|
108
|
+
if not result.ok:
|
|
109
|
+
raise typer.Exit(EXIT_INVALID)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def types(
|
|
114
|
+
verbose: Annotated[
|
|
115
|
+
bool, typer.Option("--verbose", "-v", help="Show each type's schema file and description.")
|
|
116
|
+
] = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""List discovered entity types."""
|
|
119
|
+
try:
|
|
120
|
+
vault = Knott.open(".")
|
|
121
|
+
except KnottError as error:
|
|
122
|
+
raise _fail(error) from None
|
|
123
|
+
for info in vault.schemas():
|
|
124
|
+
if verbose:
|
|
125
|
+
typer.echo(f"{info.type} ({info.path})")
|
|
126
|
+
if info.description:
|
|
127
|
+
for line in info.description.strip().splitlines():
|
|
128
|
+
typer.echo(f" {line}")
|
|
129
|
+
else:
|
|
130
|
+
typer.echo(info.type)
|
|
131
|
+
issues = vault.schema_issues()
|
|
132
|
+
if issues:
|
|
133
|
+
typer.echo("\n\n".join(format_issue(issue) for issue in issues), err=True)
|
|
134
|
+
raise typer.Exit(EXIT_INVALID)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("version")
|
|
138
|
+
def version_command() -> None:
|
|
139
|
+
"""Print the installed Knott version."""
|
|
140
|
+
typer.echo(version())
|
knott/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions for conditions that stop a run (CLI exit code 2).
|
|
2
|
+
|
|
3
|
+
Validation problems are never raised; they are returned as ``Issue`` data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KnottError(Exception):
|
|
10
|
+
"""Base class for usage and configuration errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VaultNotFoundError(KnottError):
|
|
14
|
+
"""No directory containing ``.knott/`` was found."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(KnottError):
|
|
18
|
+
"""``.knott/config.yaml`` is unreadable or malformed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PathNotFoundError(KnottError):
|
|
22
|
+
"""A path argument does not exist."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PathOutsideVaultError(KnottError):
|
|
26
|
+
"""A path argument lies outside the vault root."""
|
knott/models.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Public result types. All plain data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Frozen(BaseModel):
|
|
9
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Issue(_Frozen):
|
|
13
|
+
"""One validation problem."""
|
|
14
|
+
|
|
15
|
+
code: str
|
|
16
|
+
path: str
|
|
17
|
+
"""Vault-relative POSIX path of the file with the problem."""
|
|
18
|
+
line: int | None = None
|
|
19
|
+
"""1-based line number, when known."""
|
|
20
|
+
field: str | None = None
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Stats(_Frozen):
|
|
25
|
+
schemas: int = 0
|
|
26
|
+
entities: int = 0
|
|
27
|
+
relations: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationResult(_Frozen):
|
|
31
|
+
issues: list[Issue]
|
|
32
|
+
stats: Stats
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return not self.issues
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SchemaInfo(_Frozen):
|
|
40
|
+
"""A discovered entity type."""
|
|
41
|
+
|
|
42
|
+
type: str
|
|
43
|
+
description: str | None
|
|
44
|
+
path: str
|
|
45
|
+
"""Vault-relative path of the schema file."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InitResult(_Frozen):
|
|
49
|
+
root: str
|
|
50
|
+
created: bool
|
knott/py.typed
ADDED
|
File without changes
|
knott/schema/__init__.py
ADDED
|
File without changes
|