scry-parse 1.0.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.
- scry_parse/__init__.py +42 -0
- scry_parse/consts.py +19 -0
- scry_parse/markers.py +574 -0
- scry_parse/mint.py +34 -0
- scry_parse/validate.py +116 -0
- scry_parse-1.0.0.dist-info/METADATA +86 -0
- scry_parse-1.0.0.dist-info/RECORD +9 -0
- scry_parse-1.0.0.dist-info/WHEEL +4 -0
- scry_parse-1.0.0.dist-info/licenses/LICENSE +21 -0
scry_parse/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""scry-parse: Python parser for scry-spec v1.0 markers.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
parse_markers(content, language=None, file="") -> ParseResult
|
|
5
|
+
validate_marker(marker) -> ValidationResult
|
|
6
|
+
mint_id(kind, name, content=None) -> str
|
|
7
|
+
BASELINE_KINDS
|
|
8
|
+
BASELINE_STATUSES
|
|
9
|
+
|
|
10
|
+
Dataclasses:
|
|
11
|
+
EntryMarker
|
|
12
|
+
AnchorMarker
|
|
13
|
+
BindingMarker
|
|
14
|
+
ParseResult
|
|
15
|
+
ValidationResult
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from scry_parse.markers import (
|
|
19
|
+
parse_markers,
|
|
20
|
+
EntryMarker,
|
|
21
|
+
AnchorMarker,
|
|
22
|
+
BindingMarker,
|
|
23
|
+
ParseResult,
|
|
24
|
+
)
|
|
25
|
+
from scry_parse.validate import validate_marker, ValidationResult
|
|
26
|
+
from scry_parse.mint import mint_id
|
|
27
|
+
from scry_parse.consts import BASELINE_KINDS, BASELINE_STATUSES
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"parse_markers",
|
|
31
|
+
"EntryMarker",
|
|
32
|
+
"AnchorMarker",
|
|
33
|
+
"BindingMarker",
|
|
34
|
+
"ParseResult",
|
|
35
|
+
"validate_marker",
|
|
36
|
+
"ValidationResult",
|
|
37
|
+
"mint_id",
|
|
38
|
+
"BASELINE_KINDS",
|
|
39
|
+
"BASELINE_STATUSES",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "1.0.0"
|
scry_parse/consts.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants for scry-spec v1.0: baseline kinds, statuses, and ID regexes."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
BASELINE_KINDS = (
|
|
5
|
+
"design", "pattern", "spec", "lesson", "internal",
|
|
6
|
+
"task", "milestone",
|
|
7
|
+
"report", "audit", "research",
|
|
8
|
+
"code",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
BASELINE_STATUSES = ("draft", "active", "deprecated")
|
|
12
|
+
|
|
13
|
+
# ID validation regexes per scry-spec v1.0
|
|
14
|
+
# Entry/doc IDs: kind.name~8hexchars
|
|
15
|
+
ID_REGEX = re.compile(r'^[a-z]+\.[a-z0-9-]+~[a-f0-9]{8}$')
|
|
16
|
+
# Anchor/impl/test IDs: name~8hexchars (no dot)
|
|
17
|
+
ANCHOR_ID_REGEX = re.compile(r'^[a-z0-9-]+~[a-f0-9]{8}$')
|
|
18
|
+
# Loose anchor IDs used in refs: uppercase letters followed by digits (e.g. FR3, UT1)
|
|
19
|
+
LOOSE_ANCHOR_REGEX = re.compile(r'^[A-Z]+[0-9]+$')
|
scry_parse/markers.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""Marker parsing for scry-spec v1.0.
|
|
2
|
+
|
|
3
|
+
Implements FR1-FR11. Recognizes:
|
|
4
|
+
- @scry.entry (block marker — declarative knowledge entry)
|
|
5
|
+
- @scry.anchor (block marker — named code location)
|
|
6
|
+
- @scry.bind (line or block marker — binding / cross-reference)
|
|
7
|
+
|
|
8
|
+
Comment prefix detection is inferred from the opening sentinel line.
|
|
9
|
+
Legacy @scry.doc and @scry.file are not recognized per v1.0.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Dataclasses
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EntryMarker:
|
|
26
|
+
id: str
|
|
27
|
+
kind: str
|
|
28
|
+
summary: str
|
|
29
|
+
status: str
|
|
30
|
+
weight: float | None # None → default 0.5
|
|
31
|
+
tags: list[str]
|
|
32
|
+
rationale: str | None
|
|
33
|
+
applies: str | None
|
|
34
|
+
seeded_questions: list[str]
|
|
35
|
+
depends_on: list[str]
|
|
36
|
+
implements: str | None
|
|
37
|
+
supersedes: str | None
|
|
38
|
+
file: str
|
|
39
|
+
span: tuple[int, int] # (start_line, end_line) 1-indexed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AnchorMarker:
|
|
44
|
+
name: str # the {local-id} from the sentinel
|
|
45
|
+
description: str
|
|
46
|
+
seeded_questions: list[str]
|
|
47
|
+
file: str
|
|
48
|
+
span: tuple[int, int]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BindingMarker:
|
|
53
|
+
local_id: str
|
|
54
|
+
ref: str
|
|
55
|
+
comment: str | None
|
|
56
|
+
file: str
|
|
57
|
+
offset: int # line number (1-indexed) of the @scry.bind line
|
|
58
|
+
span: tuple[int, int] | None # (start, end) for block form; None for single-line
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ParseResult:
|
|
63
|
+
entries: list[EntryMarker] = field(default_factory=list)
|
|
64
|
+
anchors: list[AnchorMarker] = field(default_factory=list)
|
|
65
|
+
bindings: list[BindingMarker] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Comment-prefix detection
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# Maps sentinel prefix → (open_prefix, body_strip_prefix, close_suffix)
|
|
73
|
+
# For markdown: body lines are raw (no prefix strip); open/close include <!-- -->
|
|
74
|
+
_COMMENT_STYLES: list[tuple[str, str | None, str | None]] = [
|
|
75
|
+
# (detect_prefix, body_line_prefix_to_strip, close_suffix_hint)
|
|
76
|
+
("<!-- ", None, " -->"), # HTML/markdown — body is raw, strip nothing
|
|
77
|
+
("# ", "# ", None), # Python / shell
|
|
78
|
+
("// ", "// ", None), # TypeScript / JS
|
|
79
|
+
("-- ", "-- ", None), # SQL
|
|
80
|
+
(";; ", ";; ", None), # Lisp double-semi
|
|
81
|
+
("; ", "; ", None), # Lisp single-semi
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detect_comment_style(sentinel_line: str) -> tuple[str | None, str | None]:
|
|
86
|
+
"""Return (body_strip_prefix, close_suffix) from the opening sentinel line.
|
|
87
|
+
|
|
88
|
+
body_strip_prefix: prefix to strip from each body line (None = strip nothing)
|
|
89
|
+
close_suffix: suffix that appears on the closing sentinel line (e.g. ' -->')
|
|
90
|
+
"""
|
|
91
|
+
for detect, body_prefix, close_suffix in _COMMENT_STYLES:
|
|
92
|
+
if detect in sentinel_line:
|
|
93
|
+
return body_prefix, close_suffix
|
|
94
|
+
return None, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Regexes
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Matches the open sentinel for block markers (entry/anchor)
|
|
102
|
+
# Captures: (kind, rest_of_line)
|
|
103
|
+
_BLOCK_OPEN_RE = re.compile(r'@scry\.(entry|anchor)(?!\.end)\b(.*)')
|
|
104
|
+
# Matches the close sentinel
|
|
105
|
+
_BLOCK_CLOSE_RE = re.compile(r'@scry\.(entry|anchor)\.end\b')
|
|
106
|
+
|
|
107
|
+
# Bind markers
|
|
108
|
+
_BIND_OPEN_RE = re.compile(r'@scry\.bind(?!\.end)\b(.*)')
|
|
109
|
+
_BIND_CLOSE_RE = re.compile(r'@scry\.bind\.end\b')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Internal helpers
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _strip_body_prefix(line: str, prefix: str | None) -> str:
|
|
117
|
+
"""Strip the comment prefix from a single body line."""
|
|
118
|
+
if prefix is None:
|
|
119
|
+
return line
|
|
120
|
+
if line.startswith(prefix):
|
|
121
|
+
return line[len(prefix):]
|
|
122
|
+
# Handle trailing-whitespace variant (e.g. "#\n" instead of "# \n")
|
|
123
|
+
stripped = prefix.rstrip()
|
|
124
|
+
if stripped and line.startswith(stripped):
|
|
125
|
+
return line[len(stripped):].lstrip(" ")
|
|
126
|
+
return line
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _clean_body(lines: list[str], body_strip_prefix: str | None) -> str:
|
|
130
|
+
"""Strip comment prefixes from all body lines and join."""
|
|
131
|
+
cleaned = [_strip_body_prefix(ln, body_strip_prefix) for ln in lines]
|
|
132
|
+
return "".join(cleaned)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Matches any @scry.* marker token line that might appear inside a block body
|
|
136
|
+
_SCRY_LINE_RE = re.compile(r'^\s*@scry\.\w+.*$', re.MULTILINE)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_yaml(text: str) -> dict[str, Any] | None:
|
|
140
|
+
"""Parse YAML safely; return None on failure.
|
|
141
|
+
|
|
142
|
+
Strips any embedded @scry.* marker lines before parsing (they can appear
|
|
143
|
+
inside block bodies due to FR3 positional exclusion — the parser doesn't
|
|
144
|
+
index them, but they can still break YAML).
|
|
145
|
+
"""
|
|
146
|
+
# Remove embedded @scry.* lines to avoid YAML parse errors
|
|
147
|
+
cleaned = _SCRY_LINE_RE.sub("", text)
|
|
148
|
+
try:
|
|
149
|
+
data = yaml.safe_load(cleaned)
|
|
150
|
+
except yaml.YAMLError:
|
|
151
|
+
return None
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
return None
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _coerce_list(value: Any) -> list[str]:
|
|
158
|
+
if value is None:
|
|
159
|
+
return []
|
|
160
|
+
if isinstance(value, list):
|
|
161
|
+
return [str(v) for v in value]
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
return [value] if value.strip() else []
|
|
164
|
+
return [str(value)]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _coerce_str(value: Any) -> str | None:
|
|
168
|
+
if value is None:
|
|
169
|
+
return None
|
|
170
|
+
s = str(value).strip()
|
|
171
|
+
return s if s else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _coerce_float(value: Any) -> float | None:
|
|
175
|
+
if value is None:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return float(value)
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Block-span finder
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _find_block_spans(lines: list[str]) -> list[tuple[int, int, str, str]]:
|
|
188
|
+
"""Find all declarative block spans (entry, anchor).
|
|
189
|
+
|
|
190
|
+
Returns list of (start_line_1idx, end_line_1idx, kind, sentinel_line).
|
|
191
|
+
Lines are 1-indexed (matching span convention).
|
|
192
|
+
"""
|
|
193
|
+
spans: list[tuple[int, int, str, str]] = []
|
|
194
|
+
i = 0
|
|
195
|
+
while i < len(lines):
|
|
196
|
+
line = lines[i]
|
|
197
|
+
m = _BLOCK_OPEN_RE.search(line)
|
|
198
|
+
if not m:
|
|
199
|
+
i += 1
|
|
200
|
+
continue
|
|
201
|
+
kind = m.group(1)
|
|
202
|
+
open_line_idx = i # 0-indexed
|
|
203
|
+
# Scan forward for matching close
|
|
204
|
+
j = i + 1
|
|
205
|
+
close_idx = None
|
|
206
|
+
while j < len(lines):
|
|
207
|
+
cm = _BLOCK_CLOSE_RE.search(lines[j])
|
|
208
|
+
if cm and cm.group(1) == kind:
|
|
209
|
+
close_idx = j
|
|
210
|
+
break
|
|
211
|
+
j += 1
|
|
212
|
+
if close_idx is None:
|
|
213
|
+
# Unterminated block — skip (FR: unterminated → 0 entries)
|
|
214
|
+
i += 1
|
|
215
|
+
continue
|
|
216
|
+
# 1-indexed span
|
|
217
|
+
spans.append((open_line_idx + 1, close_idx + 1, kind, line))
|
|
218
|
+
i = close_idx + 1
|
|
219
|
+
return spans
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _inside_any_span(
|
|
223
|
+
line_1idx: int,
|
|
224
|
+
spans: list[tuple[int, int, str, str]],
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Return True if line_1idx falls inside any declarative block span."""
|
|
227
|
+
for start, end, _kind, _sentinel in spans:
|
|
228
|
+
if start <= line_1idx <= end:
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Binding marker parser
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _parse_bindings(
|
|
238
|
+
lines: list[str],
|
|
239
|
+
block_spans: list[tuple[int, int, str, str]],
|
|
240
|
+
body_strip_prefix: str | None,
|
|
241
|
+
file: str,
|
|
242
|
+
) -> list[BindingMarker]:
|
|
243
|
+
"""Scan lines for @scry.bind markers.
|
|
244
|
+
|
|
245
|
+
FR3: skip any bind line that falls inside a declarative block span.
|
|
246
|
+
FR2: forward-scan to determine block vs single-line form.
|
|
247
|
+
"""
|
|
248
|
+
result: list[BindingMarker] = []
|
|
249
|
+
i = 0
|
|
250
|
+
while i < len(lines):
|
|
251
|
+
line_1idx = i + 1
|
|
252
|
+
line = lines[i]
|
|
253
|
+
|
|
254
|
+
m = _BIND_OPEN_RE.search(line)
|
|
255
|
+
if not m:
|
|
256
|
+
i += 1
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# FR3 exclusion
|
|
260
|
+
if _inside_any_span(line_1idx, block_spans):
|
|
261
|
+
i += 1
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
rest = m.group(1).strip()
|
|
265
|
+
|
|
266
|
+
# Forward-scan disambiguation (FR2):
|
|
267
|
+
# Look for @scry.bind.end before the next @scry.bind
|
|
268
|
+
is_block = False
|
|
269
|
+
close_line_idx = None
|
|
270
|
+
j = i + 1
|
|
271
|
+
while j < len(lines):
|
|
272
|
+
if _BIND_CLOSE_RE.search(lines[j]):
|
|
273
|
+
is_block = True
|
|
274
|
+
close_line_idx = j
|
|
275
|
+
break
|
|
276
|
+
if _BIND_OPEN_RE.search(lines[j]):
|
|
277
|
+
# Next bind found before close → single-line form
|
|
278
|
+
break
|
|
279
|
+
j += 1
|
|
280
|
+
|
|
281
|
+
if is_block and close_line_idx is not None:
|
|
282
|
+
# Block form: extract body lines between open and close
|
|
283
|
+
body_lines = lines[i + 1: close_line_idx]
|
|
284
|
+
body_text = _clean_body(body_lines, body_strip_prefix).strip()
|
|
285
|
+
span: tuple[int, int] | None = (line_1idx, close_line_idx + 1)
|
|
286
|
+
# Parse local_id, ref, comment from rest (open line) + body
|
|
287
|
+
local_id, ref, comment = _parse_bind_content(rest, body_text)
|
|
288
|
+
end_line = close_line_idx + 1
|
|
289
|
+
i = close_line_idx + 1
|
|
290
|
+
else:
|
|
291
|
+
# Single-line form
|
|
292
|
+
body_text = ""
|
|
293
|
+
span = None
|
|
294
|
+
local_id, ref, comment = _parse_bind_content(rest, "")
|
|
295
|
+
end_line = line_1idx
|
|
296
|
+
i += 1
|
|
297
|
+
|
|
298
|
+
if not local_id or not ref:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
# Multi-anchor expansion (FR2): ref may contain {id}#{A},{B},{C}
|
|
302
|
+
bindings = _expand_binding(local_id, ref, comment, file, line_1idx, span)
|
|
303
|
+
result.extend(bindings)
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_bind_content(
|
|
309
|
+
rest: str, body_text: str
|
|
310
|
+
) -> tuple[str, str, str | None]:
|
|
311
|
+
"""Extract local_id, ref, and optional comment from bind marker content.
|
|
312
|
+
|
|
313
|
+
Single-line form: `@scry.bind <local_id> <ref> [# comment]`
|
|
314
|
+
Block form: open line has local_id + ref; body has comment.
|
|
315
|
+
"""
|
|
316
|
+
# Try to parse from the rest (tokens after @scry.bind)
|
|
317
|
+
tokens = rest.split()
|
|
318
|
+
local_id = tokens[0] if len(tokens) >= 1 else ""
|
|
319
|
+
ref = tokens[1] if len(tokens) >= 2 else ""
|
|
320
|
+
|
|
321
|
+
# Comment: anything after the second token on the open line,
|
|
322
|
+
# or the body text for block form
|
|
323
|
+
if len(tokens) >= 3:
|
|
324
|
+
comment: str | None = " ".join(tokens[2:]).lstrip("# ").strip()
|
|
325
|
+
elif body_text:
|
|
326
|
+
comment = body_text
|
|
327
|
+
else:
|
|
328
|
+
comment = None
|
|
329
|
+
|
|
330
|
+
if comment == "":
|
|
331
|
+
comment = None
|
|
332
|
+
|
|
333
|
+
return local_id, ref, comment
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _expand_binding(
|
|
337
|
+
local_id: str,
|
|
338
|
+
ref: str,
|
|
339
|
+
comment: str | None,
|
|
340
|
+
file: str,
|
|
341
|
+
offset: int,
|
|
342
|
+
span: tuple[int, int] | None,
|
|
343
|
+
) -> list[BindingMarker]:
|
|
344
|
+
"""Expand multi-anchor refs (FR2).
|
|
345
|
+
|
|
346
|
+
If ref is `{artifact}#{A},{B},{C}`, expand to N BindingMarker records.
|
|
347
|
+
Range syntax `{A}-{B}` is treated as a single anchor name (no expansion).
|
|
348
|
+
"""
|
|
349
|
+
# Check for comma-separated loose anchors in the fragment
|
|
350
|
+
# Pattern: something~hash#ANCHOR1,ANCHOR2,...
|
|
351
|
+
# or just: something#ANCHOR1,ANCHOR2,...
|
|
352
|
+
comma_re = re.compile(r'^(.+)#([^,#]+(?:,[^,#]+)+)$')
|
|
353
|
+
m = comma_re.match(ref)
|
|
354
|
+
if m:
|
|
355
|
+
base = m.group(1)
|
|
356
|
+
anchors_str = m.group(2)
|
|
357
|
+
anchors = [a.strip() for a in anchors_str.split(",")]
|
|
358
|
+
# Only expand if these look like loose anchors (LOOSE_ANCHOR_REGEX)
|
|
359
|
+
from scry_parse.consts import LOOSE_ANCHOR_REGEX
|
|
360
|
+
if all(LOOSE_ANCHOR_REGEX.match(a) for a in anchors):
|
|
361
|
+
return [
|
|
362
|
+
BindingMarker(
|
|
363
|
+
local_id=local_id,
|
|
364
|
+
ref=f"{base}#{anchor}",
|
|
365
|
+
comment=comment,
|
|
366
|
+
file=file,
|
|
367
|
+
offset=offset,
|
|
368
|
+
span=span,
|
|
369
|
+
)
|
|
370
|
+
for anchor in anchors
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
# Single binding
|
|
374
|
+
return [BindingMarker(
|
|
375
|
+
local_id=local_id,
|
|
376
|
+
ref=ref,
|
|
377
|
+
comment=comment,
|
|
378
|
+
file=file,
|
|
379
|
+
offset=offset,
|
|
380
|
+
span=span,
|
|
381
|
+
)]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Main parser
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def parse_markers(
|
|
389
|
+
content: str,
|
|
390
|
+
language: str | None = None,
|
|
391
|
+
file: str = "",
|
|
392
|
+
) -> ParseResult:
|
|
393
|
+
"""Parse all scry markers from file content.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
content: The raw file content to parse.
|
|
397
|
+
language: Optional language hint (currently unused; style is
|
|
398
|
+
inferred from the sentinel line).
|
|
399
|
+
file: Optional file path to record in markers.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
ParseResult with entries, anchors, and bindings.
|
|
403
|
+
"""
|
|
404
|
+
result = ParseResult()
|
|
405
|
+
if "@scry." not in content:
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
lines = content.splitlines(keepends=True)
|
|
409
|
+
|
|
410
|
+
# First pass: find all declarative block spans
|
|
411
|
+
block_spans = _find_block_spans(lines)
|
|
412
|
+
|
|
413
|
+
# Second pass: parse each block
|
|
414
|
+
for start_1idx, end_1idx, kind, sentinel_line in block_spans:
|
|
415
|
+
body_strip_prefix, _close_suffix = _detect_comment_style(sentinel_line)
|
|
416
|
+
|
|
417
|
+
# Body lines (between open and close, exclusive)
|
|
418
|
+
body_lines = lines[start_1idx: end_1idx - 1] # 0-indexed slice
|
|
419
|
+
body_text = _clean_body(body_lines, body_strip_prefix)
|
|
420
|
+
|
|
421
|
+
if kind == "anchor":
|
|
422
|
+
_parse_anchor_block(
|
|
423
|
+
sentinel_line, body_text, file,
|
|
424
|
+
(start_1idx, end_1idx), result
|
|
425
|
+
)
|
|
426
|
+
elif kind == "entry":
|
|
427
|
+
_parse_entry_block(
|
|
428
|
+
body_text, file, (start_1idx, end_1idx), result
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Third pass: parse binding markers
|
|
432
|
+
bindings = _parse_bindings(lines, block_spans, None, file)
|
|
433
|
+
|
|
434
|
+
# For bindings, we need to infer the body_strip_prefix per bind line.
|
|
435
|
+
# Re-do with proper per-line prefix detection.
|
|
436
|
+
result.bindings = _parse_bindings_with_prefix(lines, block_spans, file)
|
|
437
|
+
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parse_bindings_with_prefix(
|
|
442
|
+
lines: list[str],
|
|
443
|
+
block_spans: list[tuple[int, int, str, str]],
|
|
444
|
+
file: str,
|
|
445
|
+
) -> list[BindingMarker]:
|
|
446
|
+
"""Parse bindings, detecting comment prefix per bind sentinel line."""
|
|
447
|
+
result: list[BindingMarker] = []
|
|
448
|
+
i = 0
|
|
449
|
+
while i < len(lines):
|
|
450
|
+
line_1idx = i + 1
|
|
451
|
+
line = lines[i]
|
|
452
|
+
|
|
453
|
+
m = _BIND_OPEN_RE.search(line)
|
|
454
|
+
if not m:
|
|
455
|
+
i += 1
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
# FR3 exclusion
|
|
459
|
+
if _inside_any_span(line_1idx, block_spans):
|
|
460
|
+
i += 1
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Detect comment style from this line
|
|
464
|
+
body_strip_prefix, _close_suffix = _detect_comment_style(line)
|
|
465
|
+
rest = m.group(1).strip()
|
|
466
|
+
|
|
467
|
+
# Forward-scan disambiguation (FR2)
|
|
468
|
+
is_block = False
|
|
469
|
+
close_line_idx = None
|
|
470
|
+
j = i + 1
|
|
471
|
+
while j < len(lines):
|
|
472
|
+
if _BIND_CLOSE_RE.search(lines[j]):
|
|
473
|
+
is_block = True
|
|
474
|
+
close_line_idx = j
|
|
475
|
+
break
|
|
476
|
+
if _BIND_OPEN_RE.search(lines[j]):
|
|
477
|
+
break
|
|
478
|
+
j += 1
|
|
479
|
+
|
|
480
|
+
if is_block and close_line_idx is not None:
|
|
481
|
+
body_lines = lines[i + 1: close_line_idx]
|
|
482
|
+
body_text = _clean_body(body_lines, body_strip_prefix).strip()
|
|
483
|
+
span: tuple[int, int] | None = (line_1idx, close_line_idx + 1)
|
|
484
|
+
local_id, ref, comment = _parse_bind_content(rest, body_text)
|
|
485
|
+
i = close_line_idx + 1
|
|
486
|
+
else:
|
|
487
|
+
span = None
|
|
488
|
+
local_id, ref, comment = _parse_bind_content(rest, "")
|
|
489
|
+
i += 1
|
|
490
|
+
|
|
491
|
+
if not local_id or not ref:
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
bindings = _expand_binding(local_id, ref, comment, file, line_1idx, span)
|
|
495
|
+
result.extend(bindings)
|
|
496
|
+
|
|
497
|
+
return result
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _parse_anchor_block(
|
|
501
|
+
sentinel_line: str,
|
|
502
|
+
body_text: str,
|
|
503
|
+
file: str,
|
|
504
|
+
span: tuple[int, int],
|
|
505
|
+
result: ParseResult,
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Parse an @scry.anchor block and append to result."""
|
|
508
|
+
# Extract name from sentinel line: `@scry.anchor {name}`
|
|
509
|
+
m = _BLOCK_OPEN_RE.search(sentinel_line)
|
|
510
|
+
if not m:
|
|
511
|
+
return
|
|
512
|
+
rest = m.group(2).strip()
|
|
513
|
+
# Name is the first token after the sentinel
|
|
514
|
+
name_part = rest.split()[0] if rest.split() else ""
|
|
515
|
+
if not name_part:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
data = _parse_yaml(body_text) or {}
|
|
519
|
+
description = _coerce_str(data.get("description")) or ""
|
|
520
|
+
seeded_questions = _coerce_list(data.get("seeded_questions"))
|
|
521
|
+
|
|
522
|
+
result.anchors.append(AnchorMarker(
|
|
523
|
+
name=name_part,
|
|
524
|
+
description=description,
|
|
525
|
+
seeded_questions=seeded_questions,
|
|
526
|
+
file=file,
|
|
527
|
+
span=span,
|
|
528
|
+
))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _parse_entry_block(
|
|
532
|
+
body_text: str,
|
|
533
|
+
file: str,
|
|
534
|
+
span: tuple[int, int],
|
|
535
|
+
result: ParseResult,
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Parse a @scry.entry block and append to result."""
|
|
538
|
+
data = _parse_yaml(body_text)
|
|
539
|
+
if data is None:
|
|
540
|
+
# Invalid YAML — fail gracefully, produce 0 entries
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
marker_id = _coerce_str(data.get("id"))
|
|
544
|
+
if not marker_id:
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
kind = _coerce_str(data.get("kind")) or "internal"
|
|
548
|
+
summary = _coerce_str(data.get("summary")) or ""
|
|
549
|
+
status = _coerce_str(data.get("status")) or "draft"
|
|
550
|
+
weight = _coerce_float(data.get("weight"))
|
|
551
|
+
tags = _coerce_list(data.get("tags"))
|
|
552
|
+
rationale = _coerce_str(data.get("rationale"))
|
|
553
|
+
applies = _coerce_str(data.get("applies"))
|
|
554
|
+
seeded_questions = _coerce_list(data.get("seeded_questions"))
|
|
555
|
+
depends_on = _coerce_list(data.get("depends_on"))
|
|
556
|
+
implements = _coerce_str(data.get("implements"))
|
|
557
|
+
supersedes = _coerce_str(data.get("supersedes"))
|
|
558
|
+
|
|
559
|
+
result.entries.append(EntryMarker(
|
|
560
|
+
id=marker_id,
|
|
561
|
+
kind=kind,
|
|
562
|
+
summary=summary,
|
|
563
|
+
status=status,
|
|
564
|
+
weight=weight,
|
|
565
|
+
tags=tags,
|
|
566
|
+
rationale=rationale,
|
|
567
|
+
applies=applies,
|
|
568
|
+
seeded_questions=seeded_questions,
|
|
569
|
+
depends_on=depends_on,
|
|
570
|
+
implements=implements,
|
|
571
|
+
supersedes=supersedes,
|
|
572
|
+
file=file,
|
|
573
|
+
span=span,
|
|
574
|
+
))
|
scry_parse/mint.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ID generation for scry-spec v1.0 markers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mint_id(kind: str, name: str, content: str | None = None) -> str:
|
|
9
|
+
"""Generate a spec-conformant marker ID: '{kind}.{name}~{hash}'.
|
|
10
|
+
|
|
11
|
+
For entry markers, the full ID is '{kind}.{name}~{8hexchars}'.
|
|
12
|
+
For anchor/impl/test markers, the convention is '{name}~{8hexchars}'
|
|
13
|
+
(no dot prefix) — callers should pass name only without kind in that case,
|
|
14
|
+
or use kind="" and name="name~...".
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
kind: The marker kind (e.g. "design", "spec"). Use "" for anchors/impls/tests
|
|
18
|
+
where the ID format is just '{name}~{hash}'.
|
|
19
|
+
name: The human-readable name portion (e.g. "auth-flow").
|
|
20
|
+
content: Optional content for deterministic hash. If None, a random
|
|
21
|
+
4-byte hex string is generated.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A spec-conformant ID string.
|
|
25
|
+
"""
|
|
26
|
+
if content is not None:
|
|
27
|
+
hash_hex = hashlib.sha256(content.encode()).hexdigest()[:8]
|
|
28
|
+
else:
|
|
29
|
+
hash_hex = secrets.token_hex(4) # 4 bytes = 8 hex chars
|
|
30
|
+
|
|
31
|
+
if kind:
|
|
32
|
+
return f"{kind}.{name}~{hash_hex}"
|
|
33
|
+
else:
|
|
34
|
+
return f"{name}~{hash_hex}"
|
scry_parse/validate.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Validation for parsed scry markers per scry-spec v1.0."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from scry_parse.consts import (
|
|
7
|
+
ID_REGEX,
|
|
8
|
+
ANCHOR_ID_REGEX,
|
|
9
|
+
BASELINE_KINDS,
|
|
10
|
+
BASELINE_STATUSES,
|
|
11
|
+
)
|
|
12
|
+
from scry_parse.markers import EntryMarker, AnchorMarker, BindingMarker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ValidationResult:
|
|
17
|
+
valid: bool
|
|
18
|
+
errors: list[str]
|
|
19
|
+
warnings: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_marker(marker: EntryMarker | AnchorMarker | BindingMarker) -> ValidationResult:
|
|
23
|
+
"""Validate a parsed marker against spec rules.
|
|
24
|
+
|
|
25
|
+
Returns a ValidationResult with errors (spec violations) and warnings
|
|
26
|
+
(non-standard but tolerated values).
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(marker, EntryMarker):
|
|
29
|
+
return _validate_entry(marker)
|
|
30
|
+
if isinstance(marker, AnchorMarker):
|
|
31
|
+
return _validate_anchor(marker)
|
|
32
|
+
if isinstance(marker, BindingMarker):
|
|
33
|
+
return _validate_binding(marker)
|
|
34
|
+
return ValidationResult(
|
|
35
|
+
valid=False,
|
|
36
|
+
errors=[f"Unknown marker type: {type(marker).__name__}"],
|
|
37
|
+
warnings=[],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_entry(marker: EntryMarker) -> ValidationResult:
|
|
42
|
+
errors: list[str] = []
|
|
43
|
+
warnings: list[str] = []
|
|
44
|
+
|
|
45
|
+
# id must match ID_REGEX
|
|
46
|
+
if not marker.id or not ID_REGEX.match(marker.id):
|
|
47
|
+
errors.append(
|
|
48
|
+
f"id {marker.id!r} does not match expected pattern "
|
|
49
|
+
f"'<kind>.<name>~<8hexchars>' (got {marker.id!r})"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# kind must be non-empty; warn if not in BASELINE_KINDS
|
|
53
|
+
if not marker.kind or not marker.kind.strip():
|
|
54
|
+
errors.append("kind must be a non-empty string")
|
|
55
|
+
elif marker.kind not in BASELINE_KINDS:
|
|
56
|
+
warnings.append(
|
|
57
|
+
f"kind {marker.kind!r} is not in the baseline kinds list; "
|
|
58
|
+
f"expected one of: {', '.join(BASELINE_KINDS)}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# summary must be non-empty
|
|
62
|
+
if not marker.summary or not marker.summary.strip():
|
|
63
|
+
errors.append("summary must be non-empty")
|
|
64
|
+
|
|
65
|
+
# status must be non-empty; warn if not in BASELINE_STATUSES
|
|
66
|
+
if not marker.status or not marker.status.strip():
|
|
67
|
+
errors.append("status must be a non-empty string")
|
|
68
|
+
elif marker.status not in BASELINE_STATUSES:
|
|
69
|
+
warnings.append(
|
|
70
|
+
f"status {marker.status!r} is not in the baseline statuses list; "
|
|
71
|
+
f"expected one of: {', '.join(BASELINE_STATUSES)}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
valid = len(errors) == 0
|
|
75
|
+
return ValidationResult(valid=valid, errors=errors, warnings=warnings)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate_anchor(marker: AnchorMarker) -> ValidationResult:
|
|
79
|
+
errors: list[str] = []
|
|
80
|
+
warnings: list[str] = []
|
|
81
|
+
|
|
82
|
+
# name must match ANCHOR_ID_REGEX
|
|
83
|
+
if not marker.name or not ANCHOR_ID_REGEX.match(marker.name):
|
|
84
|
+
errors.append(
|
|
85
|
+
f"name {marker.name!r} does not match expected pattern "
|
|
86
|
+
f"'<name>~<8hexchars>' (got {marker.name!r})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# description must be non-empty
|
|
90
|
+
if not marker.description or not marker.description.strip():
|
|
91
|
+
errors.append("description must be non-empty")
|
|
92
|
+
|
|
93
|
+
# seeded_questions must be declared (can be empty list)
|
|
94
|
+
# It's a list type so always present; no additional check needed.
|
|
95
|
+
|
|
96
|
+
valid = len(errors) == 0
|
|
97
|
+
return ValidationResult(valid=valid, errors=errors, warnings=warnings)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_binding(marker: BindingMarker) -> ValidationResult:
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
warnings: list[str] = []
|
|
103
|
+
|
|
104
|
+
# local_id must match ANCHOR_ID_REGEX
|
|
105
|
+
if not marker.local_id or not ANCHOR_ID_REGEX.match(marker.local_id):
|
|
106
|
+
errors.append(
|
|
107
|
+
f"local_id {marker.local_id!r} does not match expected pattern "
|
|
108
|
+
f"'<name>~<8hexchars>' (got {marker.local_id!r})"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ref must be non-empty
|
|
112
|
+
if not marker.ref or not marker.ref.strip():
|
|
113
|
+
errors.append("ref must be non-empty")
|
|
114
|
+
|
|
115
|
+
valid = len(errors) == 0
|
|
116
|
+
return ValidationResult(valid=valid, errors=errors, warnings=warnings)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scry-parse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python parser for scry-spec v1.0 markers
|
|
5
|
+
Project-URL: Repository, https://github.com/prmichaelsen/scry-spec
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 reflection
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: pyyaml>=6.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# scry-parse
|
|
35
|
+
|
|
36
|
+
Python parser for the [scry-spec v1.0](https://github.com/prmichaelsen/scry-spec) marker format.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv pip install scry-parse
|
|
42
|
+
# or:
|
|
43
|
+
pip install scry-parse
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from scry_parse import parse_markers, validate_marker, mint_id, BASELINE_KINDS, BASELINE_STATUSES
|
|
50
|
+
|
|
51
|
+
# Parse markers from file content
|
|
52
|
+
with open("my_file.md") as f:
|
|
53
|
+
content = f.read()
|
|
54
|
+
|
|
55
|
+
result = parse_markers(content)
|
|
56
|
+
|
|
57
|
+
for entry in result.entries:
|
|
58
|
+
print(entry.id, entry.kind, entry.summary)
|
|
59
|
+
|
|
60
|
+
for anchor in result.anchors:
|
|
61
|
+
print(anchor.name, anchor.description)
|
|
62
|
+
|
|
63
|
+
for binding in result.bindings:
|
|
64
|
+
print(binding.local_id, binding.ref)
|
|
65
|
+
|
|
66
|
+
# Validate a parsed marker
|
|
67
|
+
vr = validate_marker(result.entries[0])
|
|
68
|
+
if not vr.valid:
|
|
69
|
+
print(vr.errors)
|
|
70
|
+
|
|
71
|
+
# Generate a new marker ID
|
|
72
|
+
entry_id = mint_id("design", "auth-flow") # random hash
|
|
73
|
+
entry_id = mint_id("design", "auth-flow", content) # deterministic hash
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Supported comment styles
|
|
77
|
+
|
|
78
|
+
- HTML/Markdown: `<!-- @scry.entry ... @scry.entry.end -->`
|
|
79
|
+
- Python/Shell: `# @scry.entry ... # @scry.entry.end`
|
|
80
|
+
- TypeScript/JS: `// @scry.entry ... // @scry.entry.end`
|
|
81
|
+
- SQL: `-- @scry.entry ... -- @scry.entry.end`
|
|
82
|
+
- Lisp: `;; @scry.entry ... ;; @scry.entry.end`
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
scry_parse/__init__.py,sha256=GiNrb0jdZ2aXFwP-mNgRKM6X2_ek5chaGxBhCxq2Mnk,915
|
|
2
|
+
scry_parse/consts.py,sha256=ILXHa9pzv8WFXXtCNTZYLAmDN58zmw8nhW5PCm-qj38,675
|
|
3
|
+
scry_parse/markers.py,sha256=iSkS-jHSxD1If0btrs91-9jIU3iaKYxKWgCLD_AVd3k,18003
|
|
4
|
+
scry_parse/mint.py,sha256=jQ8oI-qUb_HyEljSGGU4CAL6kXMyWgmo7NjglDbf0RE,1197
|
|
5
|
+
scry_parse/validate.py,sha256=BHe9I9t5xf9FEcecOYb3Q41FHGrA6SYGOkPVxpbjluM,3893
|
|
6
|
+
scry_parse-1.0.0.dist-info/METADATA,sha256=VkJZ4nW2MgOx4vhvUdSaOTFdL19Z7177yGBezAnsA94,2857
|
|
7
|
+
scry_parse-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
scry_parse-1.0.0.dist-info/licenses/LICENSE,sha256=9j-1QE48XqQoy_Sh9nueXMhr7z2niD1G9tS9QM9Zb_Q,1067
|
|
9
|
+
scry_parse-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 reflection
|
|
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.
|