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 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
File without changes