wireforge 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.
Files changed (96) hide show
  1. wireforge/__init__.py +3 -0
  2. wireforge/_data/cmake/WireForgeConfig.cmake +29 -0
  3. wireforge/_data/cmake/WireForgeConfigVersion.cmake +15 -0
  4. wireforge/_data/cmake/WireForgeGenerate.cmake +64 -0
  5. wireforge/_data/runtime/LICENSE +21 -0
  6. wireforge/_data/runtime/include/wireforge/array_access.hpp +35 -0
  7. wireforge/_data/runtime/include/wireforge/bit_utils.hpp +226 -0
  8. wireforge/_data/runtime/include/wireforge/byte_io.hpp +387 -0
  9. wireforge/_data/runtime/include/wireforge/checksum.hpp +201 -0
  10. wireforge/_data/runtime/include/wireforge/codec_support.hpp +412 -0
  11. wireforge/_data/runtime/include/wireforge/config.hpp +41 -0
  12. wireforge/_data/runtime/include/wireforge/delegate.hpp +101 -0
  13. wireforge/_data/runtime/include/wireforge/dispatcher.hpp +339 -0
  14. wireforge/_data/runtime/include/wireforge/expr_support.hpp +38 -0
  15. wireforge/_data/runtime/include/wireforge/frame_decoder.hpp +262 -0
  16. wireforge/_data/runtime/include/wireforge/frame_layout.hpp +20 -0
  17. wireforge/_data/runtime/include/wireforge/link.hpp +223 -0
  18. wireforge/_data/runtime/include/wireforge/lock.hpp +146 -0
  19. wireforge/_data/runtime/include/wireforge/ring_buffer.hpp +226 -0
  20. wireforge/_data/runtime/include/wireforge/span.hpp +116 -0
  21. wireforge/_data/runtime/include/wireforge/static_vector.hpp +264 -0
  22. wireforge/_data/runtime/include/wireforge/status.hpp +75 -0
  23. wireforge/_data/runtime/include/wireforge/transport.hpp +95 -0
  24. wireforge/_data/runtime/include/wireforge/validation.hpp +153 -0
  25. wireforge/_data/runtime/include/wireforge/version.hpp +22 -0
  26. wireforge/_data/runtime/requirements.yaml +438 -0
  27. wireforge/_data/schema/deviations.schema.json +47 -0
  28. wireforge/_data/schema/fragment.schema.json +79 -0
  29. wireforge/_data/schema/llr_ids.schema.json +25 -0
  30. wireforge/_data/schema/project.schema.json +1240 -0
  31. wireforge/_data/schema/runtime_requirements.schema.json +34 -0
  32. wireforge/_data/ui/assets/index-6Pomu6U4.js +44 -0
  33. wireforge/_data/ui/assets/index-GVdDyUAG.css +1 -0
  34. wireforge/_data/ui/index.html +13 -0
  35. wireforge/bundle.py +177 -0
  36. wireforge/ci_check.py +155 -0
  37. wireforge/cli.py +617 -0
  38. wireforge/codec.py +897 -0
  39. wireforge/compat.py +536 -0
  40. wireforge/coverage_points.py +64 -0
  41. wireforge/cpp.py +273 -0
  42. wireforge/cpp_codec.py +440 -0
  43. wireforge/cpp_interface.py +874 -0
  44. wireforge/cpp_tests.py +1993 -0
  45. wireforge/differential.py +586 -0
  46. wireforge/docs_markdown.py +72 -0
  47. wireforge/expr.py +598 -0
  48. wireforge/findings.py +187 -0
  49. wireforge/fmt.py +351 -0
  50. wireforge/generate.py +644 -0
  51. wireforge/golden.py +253 -0
  52. wireforge/hlr_import.py +112 -0
  53. wireforge/impact.py +249 -0
  54. wireforge/importers.py +402 -0
  55. wireforge/issues.py +27 -0
  56. wireforge/layout.py +681 -0
  57. wireforge/lifecycle.py +1266 -0
  58. wireforge/lifecycle_results.py +205 -0
  59. wireforge/lifecycle_rules.py +78 -0
  60. wireforge/lifecycle_runtime.py +375 -0
  61. wireforge/llr.py +693 -0
  62. wireforge/llr_ids.py +104 -0
  63. wireforge/loader.py +422 -0
  64. wireforge/mcp_server.py +357 -0
  65. wireforge/model.py +322 -0
  66. wireforge/plain.py +15 -0
  67. wireforge/py.typed +0 -0
  68. wireforge/reqif.py +289 -0
  69. wireforge/resources.py +43 -0
  70. wireforge/results.py +377 -0
  71. wireforge/runtime_reqs.py +147 -0
  72. wireforge/schema.py +151 -0
  73. wireforge/server/__init__.py +1 -0
  74. wireforge/server/app.py +619 -0
  75. wireforge/server/oidc.py +99 -0
  76. wireforge/server/storage.py +146 -0
  77. wireforge/service.py +1354 -0
  78. wireforge/templates/command/project.yaml +38 -0
  79. wireforge/templates/raw/project.yaml +28 -0
  80. wireforge/templates/telemetry/project.yaml +51 -0
  81. wireforge/test_cases.py +475 -0
  82. wireforge/test_project.py +478 -0
  83. wireforge/trace.py +442 -0
  84. wireforge/validate.py +1241 -0
  85. wireforge/vcs.py +44 -0
  86. wireforge/vectors.py +222 -0
  87. wireforge/versioning.py +137 -0
  88. wireforge/wireshark.py +792 -0
  89. wireforge/wireshark_runtime.lua +725 -0
  90. wireforge-0.1.0.dist-info/METADATA +91 -0
  91. wireforge-0.1.0.dist-info/RECORD +96 -0
  92. wireforge-0.1.0.dist-info/WHEEL +5 -0
  93. wireforge-0.1.0.dist-info/entry_points.txt +2 -0
  94. wireforge-0.1.0.dist-info/licenses/LICENSE +98 -0
  95. wireforge-0.1.0.dist-info/licenses/runtime/LICENSE +21 -0
  96. wireforge-0.1.0.dist-info/top_level.txt +1 -0
wireforge/llr_ids.py ADDED
@@ -0,0 +1,104 @@
1
+ """Stable numbers for the low-level requirements (`llr_ids.lock`).
2
+
3
+ A low-level requirement is identified by a key made of names (`SensorReading.temperature.RANGE`).
4
+ Customers' tools want ids that never change, so a project can pin a number to each key in an
5
+ optional lock file. The file is append-only: a number is never changed, moved or given again; a key
6
+ that disappears is moved to `retired`, and a renamed element keeps its number (`rename`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from typing import TYPE_CHECKING, Any, cast
13
+
14
+ from ruamel.yaml import YAML
15
+
16
+ from wireforge import schema
17
+ from wireforge.model import IdMap, Source
18
+ from wireforge.plain import to_plain
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+
24
+ LOCK_FILE = "llr_ids.lock"
25
+ DEFAULT_FORMAT = "SWLLR-{n:05d}"
26
+ FIRST_NUMBER = 1
27
+
28
+
29
+ class LockError(ValueError):
30
+ """The lock file is invalid or an operation on it is not allowed."""
31
+
32
+
33
+ def load(project_dir: Path) -> IdMap | None:
34
+ """Read the lock file of a project directory (None without one)."""
35
+ path = project_dir / LOCK_FILE
36
+ if not path.exists():
37
+ return None
38
+ document = schema.load_yaml(path)
39
+ issues = schema.validate_document(
40
+ document, "llr_ids.schema.json", file=str(path), source=document
41
+ )
42
+ if issues:
43
+ raise LockError("\n".join(str(issue) for issue in issues))
44
+ plain = cast("dict[str, Any]", to_plain(document))
45
+ return IdMap(
46
+ plain["format"],
47
+ plain["next"],
48
+ dict(plain["ids"]),
49
+ dict(plain.get("retired", {})),
50
+ Source(str(path), 1, ""),
51
+ )
52
+
53
+
54
+ def number(ids: IdMap, n: int) -> str:
55
+ """Return the displayed id of the number `n`."""
56
+ return ids.format.format(n=n)
57
+
58
+
59
+ def assign(keys: Iterable[str], current: IdMap | None, *, text: str = DEFAULT_FORMAT) -> IdMap:
60
+ """Return the lock for `keys`: existing numbers kept, new keys numbered in order.
61
+
62
+ Keys that are no longer derived move to `retired` with their number.
63
+ """
64
+ wanted = list(dict.fromkeys(keys))
65
+ base = current or IdMap(text, FIRST_NUMBER, {}, {}, Source("", None, ""))
66
+ ids = {key: value for key, value in base.ids.items() if key in set(wanted)}
67
+ retired = dict(base.retired)
68
+ retired.update({key: value for key, value in base.ids.items() if key not in set(wanted)})
69
+ following = base.next
70
+ for key in wanted:
71
+ if key not in ids:
72
+ ids[key] = number(base, following)
73
+ following += 1
74
+ return IdMap(base.format, following, ids, retired, base.source)
75
+
76
+
77
+ def rename(current: IdMap, old: str, new: str) -> IdMap:
78
+ """Move a number to a new key (an element was renamed); the new key must not be numbered."""
79
+ if old not in current.ids:
80
+ message = f"{old!r} has no number"
81
+ raise LockError(message)
82
+ if new in current.ids:
83
+ message = f"{new!r} already has the number {current.ids[new]}"
84
+ raise LockError(message)
85
+ ids = {(new if key == old else key): value for key, value in current.ids.items()}
86
+ return IdMap(current.format, current.next, ids, dict(current.retired), current.source)
87
+
88
+
89
+ def dump(ids: IdMap) -> str:
90
+ """Return the canonical text of the lock file."""
91
+ document: dict[str, Any] = {
92
+ "schema_version": 1,
93
+ "format": ids.format,
94
+ "next": ids.next,
95
+ "ids": dict(ids.ids),
96
+ }
97
+ if ids.retired:
98
+ document["retired"] = dict(ids.retired)
99
+ yaml = YAML(typ="rt")
100
+ yaml.default_flow_style = False
101
+ yaml.width = 4096
102
+ out = io.StringIO()
103
+ yaml.dump(document, out)
104
+ return out.getvalue()
wireforge/loader.py ADDED
@@ -0,0 +1,422 @@
1
+ """Project loader: root file + fragments -> `wireforge.model.Project`.
2
+
3
+ Structural problems (YAML, JSON Schema, version stamps) stop the load; semantic rules are checked
4
+ afterwards by `wireforge.validate`. Duplicate names across files are reported here, because
5
+ merging is where they become visible. The `requirements` catalog is a section like the others and
6
+ `reqs` lists are read into the elements that carry them; unknown ids are left to the validator.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, cast
14
+
15
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
16
+ from ruamel.yaml.error import YAMLError
17
+
18
+ from wireforge import llr_ids, schema, versioning
19
+ from wireforge.issues import Issue, errors_only
20
+ from wireforge.model import (
21
+ Checksum,
22
+ ChecksumUse,
23
+ Define,
24
+ Enum,
25
+ Field,
26
+ Framing,
27
+ Interface,
28
+ InterfaceMessage,
29
+ MessageInfo,
30
+ Occurs,
31
+ Profile,
32
+ Project,
33
+ Requirement,
34
+ Source,
35
+ Struct,
36
+ UserConstant,
37
+ UserHeader,
38
+ UserType,
39
+ Vector,
40
+ )
41
+ from wireforge.plain import to_plain
42
+
43
+ if TYPE_CHECKING:
44
+ from pathlib import Path
45
+
46
+ SECTIONS = (
47
+ "requirements",
48
+ "defines",
49
+ "profiles",
50
+ "checksums",
51
+ "enums",
52
+ "types",
53
+ "user_types",
54
+ "messages",
55
+ "interfaces",
56
+ "vectors",
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class LoadResult:
62
+ """Outcome of `load_project`: the model (None after structural errors) and all issues."""
63
+
64
+ project: Project | None
65
+ issues: list[Issue]
66
+ documents: dict[str, CommentedMap] = field(default_factory=dict)
67
+
68
+
69
+ def _mapping(node: object) -> dict[str, Any]:
70
+ """Plain copy of a YAML mapping (the JSON Schema already checked its shape)."""
71
+ return cast("dict[str, Any]", to_plain(node))
72
+
73
+
74
+ def _escape(part: str | int) -> str:
75
+ return str(part).replace("~", "~0").replace("/", "~1")
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _Where:
80
+ """A YAML node plus its file and JSON pointer, used to derive child sources."""
81
+
82
+ file: str
83
+ node: Any
84
+ pointer: str
85
+
86
+ def child(self, key: str | int) -> _Where:
87
+ return _Where(self.file, self.node[key], f"{self.pointer}/{_escape(key)}")
88
+
89
+ def source_of(self, key: str | int) -> Source:
90
+ line: int | None = None
91
+ try:
92
+ if isinstance(self.node, CommentedMap):
93
+ line = self.node.lc.key(key)[0] + 1
94
+ elif isinstance(self.node, CommentedSeq) and isinstance(key, int):
95
+ line = self.node.lc.item(key)[0] + 1
96
+ except (KeyError, IndexError, TypeError):
97
+ line = None
98
+ return Source(self.file, line, f"{self.pointer}/{_escape(key)}")
99
+
100
+ def items(self) -> list[tuple[str, _Where, Source]]:
101
+ return [(str(key), self.child(key), self.source_of(key)) for key in self.node]
102
+
103
+
104
+ def _occurs(raw: dict[str, Any] | None) -> Occurs | None:
105
+ if raw is None:
106
+ return None
107
+ return Occurs(
108
+ max=raw["max"], min=raw.get("min"), count=raw.get("count"), fill=bool(raw.get("fill"))
109
+ )
110
+
111
+
112
+ def _checksum_use(raw: dict[str, Any] | None) -> ChecksumUse | None:
113
+ if raw is None:
114
+ return None
115
+ covers = raw["covers"]
116
+ return ChecksumUse(
117
+ use=raw["use"], scope=covers.get("scope"), first=covers.get("from"), last=covers.get("to")
118
+ )
119
+
120
+
121
+ def _reqs(raw: dict[str, Any]) -> tuple[str, ...]:
122
+ return tuple(raw.get("reqs", ()))
123
+
124
+
125
+ def _field(raw: dict[str, Any], source: Source) -> Field:
126
+ return Field(
127
+ name=raw["name"],
128
+ type=raw["type"],
129
+ source=source,
130
+ bits=raw.get("bits"),
131
+ min=raw.get("min"),
132
+ max=raw.get("max"),
133
+ default=raw.get("default"),
134
+ const=raw.get("const"),
135
+ reserved=bool(raw.get("reserved", False)),
136
+ occurs=_occurs(raw.get("occurs")),
137
+ checksum=_checksum_use(raw.get("checksum")),
138
+ role=raw.get("role"),
139
+ condition=raw.get("if"),
140
+ description=raw.get("description"),
141
+ units=raw.get("units"),
142
+ reqs=_reqs(raw),
143
+ )
144
+
145
+
146
+ def _fields(where: _Where) -> tuple[Field, ...]:
147
+ fields = where.child("fields")
148
+ return tuple(
149
+ _field(_mapping(fields.node[index]), fields.source_of(index))
150
+ for index in range(len(fields.node))
151
+ )
152
+
153
+
154
+ def _struct(name: str, where: _Where, source: Source, *, message: bool) -> Struct:
155
+ raw = _mapping(where.node)
156
+ info = None
157
+ if message:
158
+ info = MessageInfo(
159
+ id=raw.get("id"),
160
+ endian=raw.get("endian"),
161
+ bit_order=raw.get("bit_order"),
162
+ range_check=raw.get("range_check"),
163
+ )
164
+ return Struct(
165
+ name,
166
+ _fields(where),
167
+ source,
168
+ condition=raw.get("if"),
169
+ message=info,
170
+ description=raw.get("description"),
171
+ reqs=_reqs(raw),
172
+ )
173
+
174
+
175
+ def _checksum(name: str, where: _Where, source: Source) -> Checksum:
176
+ raw = _mapping(where.node)
177
+ return Checksum(
178
+ name=name,
179
+ algo=raw["algo"],
180
+ source=source,
181
+ width=raw.get("width"),
182
+ poly=raw.get("poly"),
183
+ init=raw.get("init", 0),
184
+ refin=raw.get("refin", False),
185
+ refout=raw.get("refout", False),
186
+ xorout=raw.get("xorout", 0),
187
+ check=raw.get("check"),
188
+ impl=raw.get("impl"),
189
+ include=raw.get("include"),
190
+ size_bytes=raw.get("size_bytes"),
191
+ reqs=_reqs(raw),
192
+ )
193
+
194
+
195
+ def _user_type(name: str, where: _Where, source: Source) -> UserType:
196
+ raw = _mapping(where.node)
197
+ return UserType(
198
+ name=name,
199
+ cpp=raw["cpp"],
200
+ size_bytes=raw["size_bytes"],
201
+ source=source,
202
+ include=raw.get("include"),
203
+ style=raw.get("style", "quote"),
204
+ description=raw.get("description"),
205
+ reqs=_reqs(raw),
206
+ )
207
+
208
+
209
+ def _interface_messages(where: _Where) -> tuple[InterfaceMessage, ...]:
210
+ entries = where.child("messages")
211
+ result = []
212
+ for index in range(len(entries.node)):
213
+ source = entries.source_of(index)
214
+ if isinstance(entries.node[index], str):
215
+ result.append(InterfaceMessage(str(entries.node[index]), source))
216
+ else:
217
+ raw = _mapping(entries.node[index])
218
+ result.append(
219
+ InterfaceMessage(
220
+ raw["name"],
221
+ source,
222
+ id=raw.get("id"),
223
+ condition=raw.get("if"),
224
+ reqs=_reqs(raw),
225
+ )
226
+ )
227
+ return tuple(result)
228
+
229
+
230
+ def _interface(name: str, where: _Where, source: Source) -> Interface:
231
+ raw = _mapping(where.node)
232
+ header_raw = raw.get("header", "none")
233
+ header = None if header_raw == "none" else _fields(where.child("header"))
234
+ trailer = _fields(where.child("trailer")) if "trailer" in raw else ()
235
+ framing_raw = raw.get("framing", {"kind": "none"})
236
+ framing = Framing(
237
+ kind=framing_raw["kind"],
238
+ frame_size=framing_raw.get("frame_size"),
239
+ max_frame_size=framing_raw.get("max_frame_size"),
240
+ length_counts=framing_raw.get("length_counts", "payload"),
241
+ resync=bool(framing_raw.get("resync", False)),
242
+ reqs=_reqs(framing_raw),
243
+ )
244
+ return Interface(
245
+ name=name,
246
+ version=raw["version"],
247
+ direction=raw["direction"],
248
+ source=source,
249
+ messages=_interface_messages(where),
250
+ header=header,
251
+ trailer=trailer,
252
+ framing=framing,
253
+ buffers=raw.get("buffers", {}),
254
+ endian=raw.get("endian"),
255
+ bit_order=raw.get("bit_order"),
256
+ allow_trailing_bytes=bool(raw.get("allow_trailing_bytes", False)),
257
+ description=raw.get("description"),
258
+ reqs=_reqs(raw),
259
+ )
260
+
261
+
262
+ def _requirement(name: str, raw: dict[str, Any], source: Source) -> Requirement:
263
+ return Requirement(
264
+ name,
265
+ raw["text"],
266
+ source,
267
+ reference=raw.get("source"),
268
+ verification=raw.get("verification", "test"),
269
+ )
270
+
271
+
272
+ def _vector(name: str, raw: dict[str, Any], source: Source) -> Vector:
273
+ text = raw["bytes"]
274
+ try:
275
+ data: bytes | None = bytes.fromhex(re.sub(r"[\s_]", "", text))
276
+ except ValueError:
277
+ data = None
278
+ expect = raw["expect"]
279
+ return Vector(
280
+ name=name,
281
+ message=raw["message"],
282
+ text=text,
283
+ data=data,
284
+ source=source,
285
+ interface=raw.get("interface"),
286
+ profile=raw.get("profile"),
287
+ fields=expect.get("fields"),
288
+ status=expect.get("status"),
289
+ direction=raw.get("direction", "decode"),
290
+ reqs=_reqs(raw),
291
+ description=raw.get("description"),
292
+ )
293
+
294
+
295
+ def _add_entry(project: Project, section: str, name: str, where: _Where, source: Source) -> None:
296
+ if section == "requirements":
297
+ project.requirements[name] = _requirement(name, _mapping(where.node), source)
298
+ elif section == "defines":
299
+ project.defines[name] = Define(name, int(where.node), source)
300
+ elif section == "profiles":
301
+ project.profiles[name] = Profile(name, _mapping(where.node)["defines"], source)
302
+ elif section == "checksums":
303
+ project.checksums[name] = _checksum(name, where, source)
304
+ elif section == "enums":
305
+ raw = _mapping(where.node)
306
+ project.enums[name] = Enum(name, raw["type"], raw["values"], source, reqs=_reqs(raw))
307
+ elif section == "types":
308
+ project.types[name] = _struct(name, where, source, message=False)
309
+ elif section == "user_types":
310
+ project.user_types[name] = _user_type(name, where, source)
311
+ elif section == "messages":
312
+ project.messages[name] = _struct(name, where, source, message=True)
313
+ elif section == "vectors":
314
+ project.vectors[name] = _vector(name, _mapping(where.node), source)
315
+ else:
316
+ project.interfaces[name] = _interface(name, where, source)
317
+
318
+
319
+ def _user_includes(project: Project, root: _Where) -> None:
320
+ if "user_includes" not in root.node:
321
+ return
322
+ includes = root.child("user_includes")
323
+ raw = _mapping(includes.node)
324
+ for index, directory in enumerate(raw.get("include_dirs", [])):
325
+ project.include_dirs.append((directory, includes.child("include_dirs").source_of(index)))
326
+ for index, header in enumerate(raw.get("headers", [])):
327
+ source = includes.child("headers").source_of(index)
328
+ style = header.get("style", "quote")
329
+ project.headers.append(UserHeader(header["path"], source, style, header.get("if")))
330
+ if "constants" in raw:
331
+ for name, _, source in includes.child("constants").items():
332
+ constant = raw["constants"][name]
333
+ project.constants[name] = UserConstant(
334
+ name,
335
+ constant["cpp"],
336
+ source,
337
+ value=constant.get("value"),
338
+ profile_values=constant.get("profile_values", {}),
339
+ )
340
+
341
+
342
+ def _merge(project: Project, document: _Where, seen: dict[tuple[str, str], Source]) -> list[Issue]:
343
+ issues = []
344
+ for section in SECTIONS:
345
+ if section not in document.node:
346
+ continue
347
+ for name, where, source in document.child(section).items():
348
+ previous = seen.get((section, name))
349
+ if previous is not None:
350
+ where_first = f"{previous.file}:{previous.line}"
351
+ message = f"{section} entry {name!r} is already defined at {where_first}"
352
+ issues.append(source.issue("SR-1", message))
353
+ continue
354
+ seen[(section, name)] = source
355
+ _add_entry(project, section, name, where, source)
356
+ return issues
357
+
358
+
359
+ def _load_checked(path: Path, schema_file: str) -> tuple[CommentedMap | None, list[Issue]]:
360
+ try:
361
+ document = schema.load_yaml(path)
362
+ except (YAMLError, OSError) as error:
363
+ return None, [Issue(str(path), None, "", f"cannot load YAML: {error}")]
364
+ if not isinstance(document, CommentedMap):
365
+ return None, [Issue(str(path), 1, "", "top level must be a mapping")]
366
+ issues = versioning.check_document(document, file=str(path), line=1)
367
+ if not errors_only(issues):
368
+ issues += schema.validate_document(document, schema_file, file=str(path), source=document)
369
+ return (None if errors_only(issues) else document), issues
370
+
371
+
372
+ def _fragment_paths(root: Path, document: CommentedMap) -> list[Path]:
373
+ found: list[Path] = []
374
+ for pattern in document.get("include", []):
375
+ for path in sorted(root.parent.glob(str(pattern))):
376
+ if path.resolve() != root.resolve() and path not in found:
377
+ found.append(path)
378
+ return found
379
+
380
+
381
+ def _numbers(project: Project, directory: Path, issues: list[Issue]) -> None:
382
+ """Read the optional lock file with the numbers of the low-level requirements."""
383
+ try:
384
+ project.llr_ids = llr_ids.load(directory)
385
+ except llr_ids.LockError as error:
386
+ issues.append(Issue(str(directory / llr_ids.LOCK_FILE), None, "", str(error)))
387
+
388
+
389
+ def load_project(location: Path) -> LoadResult:
390
+ """Load a project directory (or its root file), merging every included fragment."""
391
+ root = location / schema.ROOT_FILE_NAME if location.is_dir() else location
392
+ document, issues = _load_checked(root, "project.schema.json")
393
+ if document is None:
394
+ return LoadResult(None, issues)
395
+ documents = {str(root): document}
396
+ for path in _fragment_paths(root, document):
397
+ fragment, fragment_issues = _load_checked(path, "fragment.schema.json")
398
+ issues += fragment_issues
399
+ if fragment is not None:
400
+ documents[str(path)] = fragment
401
+ if errors_only(issues):
402
+ return LoadResult(None, issues, documents)
403
+ top = _mapping(document)
404
+ project = Project(
405
+ name=top["project"],
406
+ source=Source(str(root), 1, ""),
407
+ version=top.get("version"),
408
+ namespace=top.get("namespace"),
409
+ defaults=top.get("defaults", {}),
410
+ files=list(documents),
411
+ )
412
+ seen: dict[tuple[str, str], Source] = {}
413
+ for file, loaded in documents.items():
414
+ issues += _merge(project, _Where(file, loaded, ""), seen)
415
+ _user_includes(project, _Where(str(root), document, ""))
416
+ _numbers(project, root.parent, issues)
417
+ for loaded in documents.values():
418
+ if "requirements_source" in loaded:
419
+ project.requirements_source = {
420
+ str(key): str(value) for key, value in loaded["requirements_source"].items()
421
+ }
422
+ return LoadResult(project, issues, documents)