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
@@ -0,0 +1,619 @@
1
+ """REST API over a workspace of projects (FastAPI).
2
+
3
+ Reads go to the files on disk; writes use optimistic locking with ETags (`If-Match`) and are
4
+ serialized per project. Entry and value edits use the service layer, so they keep comments and
5
+ the canonical layout. `GET /api/projects/{id}/wait` long-polls until a project changes, which
6
+ lets clients reload after edits made outside the server. Every operation has a CLI equivalent
7
+ (docs/CLI.md); the OpenAPI document is served at `/openapi.json`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import binascii
14
+ import importlib
15
+ import re
16
+ import secrets
17
+ import tempfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
21
+
22
+ from fastapi import APIRouter, Body, Depends, FastAPI, Header, HTTPException, Query, Request
23
+ from fastapi.responses import JSONResponse, Response
24
+ from fastapi.staticfiles import StaticFiles
25
+ from pydantic import BaseModel, ConfigDict, Field
26
+
27
+ from wireforge import hlr_import, importers, resources, service, versioning
28
+ from wireforge.loader import SECTIONS, load_project
29
+ from wireforge.server.storage import StorageError, Workspace, check_match
30
+ from wireforge.service import EditOptions, Result, ServiceError
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ # Only needed for type checking: a plain `server` install never imports this (optional
36
+ # `oidc` extra), so the real import is deferred to where oidc is actually configured.
37
+ from wireforge.server.oidc import OidcConfig, OidcVerifier
38
+
39
+ PROJECT_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
40
+ MAX_WAIT_SECONDS = 60.0
41
+
42
+
43
+ class FileWrite(BaseModel):
44
+ """New contents of a project file."""
45
+
46
+ text: str
47
+
48
+
49
+ class EntryWrite(BaseModel):
50
+ """Definition of a section entry (type, message, interface, ...)."""
51
+
52
+ definition: dict[str, Any]
53
+ dry_run: bool = False
54
+ strict: bool = False
55
+
56
+
57
+ class ValueWrite(BaseModel):
58
+ """Value for a JSON pointer."""
59
+
60
+ value: Any = None
61
+ dry_run: bool = False
62
+ strict: bool = False
63
+
64
+
65
+ class EditFlags(BaseModel):
66
+ """Options of a delete."""
67
+
68
+ dry_run: bool = False
69
+ strict: bool = False
70
+
71
+
72
+ class ProjectCreate(BaseModel):
73
+ """A new, empty project."""
74
+
75
+ id: str = Field(pattern=PROJECT_ID.pattern)
76
+ name: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
77
+ namespace: str | None = None
78
+ version: str = "0.1.0"
79
+ template: str | None = None
80
+
81
+
82
+ class CodecRequest(BaseModel):
83
+ """Message, interface and profile of an encode or decode request."""
84
+
85
+ message: str | None = None
86
+ interface: str | None = None
87
+ profile: str | None = None
88
+
89
+
90
+ class EncodeRequest(CodecRequest):
91
+ """A message value to encode."""
92
+
93
+ value: dict[str, Any]
94
+
95
+
96
+ class DecodeRequest(CodecRequest):
97
+ """Hex bytes to decode (a payload with `message`, else a frame of `interface`)."""
98
+
99
+ hex: str
100
+
101
+
102
+ class CompatRequest(BaseModel):
103
+ """Two projects of the workspace to compare (old and new version)."""
104
+
105
+ old: str
106
+ new: str
107
+ interfaces: list[str] | None = None
108
+
109
+
110
+ class RevisionCompat(BaseModel):
111
+ """A git revision to compare the working copy of a project with."""
112
+
113
+ revision: str = Field(pattern=r"^[A-Za-z0-9_][A-Za-z0-9_.^~/-]*$")
114
+ interfaces: list[str] | None = None
115
+
116
+
117
+ class ImportRequest(BaseModel):
118
+ """An ICD to import as a new project: CSV or C header text, or a base64 Excel workbook."""
119
+
120
+ id: str = Field(pattern=PROJECT_ID.pattern)
121
+ name: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
122
+ format: Literal["csv", "xlsx", "h"]
123
+ content: str
124
+ namespace: str | None = None
125
+ version: str = "0.1.0"
126
+ endian: Literal["big", "little"] | None = None
127
+ messages: list[str] | None = None
128
+
129
+
130
+ class RequirementsImport(BaseModel):
131
+ """High-level requirements to import: the file (base64) and how to read it."""
132
+
133
+ file: str = Field(pattern=r"^[A-Za-z0-9_.-]+[.](reqif|reqifz|csv)$")
134
+ content: str
135
+ baseline: str = Field(min_length=1)
136
+ tool: str | None = None
137
+ id_attr: str | None = None
138
+ text_attr: str | None = None
139
+ csv_id_column: str = "id"
140
+ csv_text_column: str = "text"
141
+ force: bool = False
142
+
143
+
144
+ def workspace(request: Request) -> Workspace:
145
+ """Return the workspace of the running application."""
146
+ store: Workspace = request.app.state.workspace
147
+ return store
148
+
149
+
150
+ def authorize(request: Request, authorization: Annotated[str | None, Header()] = None) -> None:
151
+ """Check the OIDC bearer JWT or the shared bearer token, whichever the application has."""
152
+ verifier: OidcVerifier | None = request.app.state.oidc_verifier
153
+ token: str | None = request.app.state.token
154
+ if verifier is None and token is None:
155
+ return
156
+ if authorization is None or not authorization.startswith("Bearer "):
157
+ raise HTTPException(401, "missing bearer token")
158
+ if verifier is not None:
159
+ oidc_module = importlib.import_module("wireforge.server.oidc")
160
+ try:
161
+ request.state.user = verifier.verify(authorization.removeprefix("Bearer "))
162
+ except ValueError as error:
163
+ raise HTTPException(401, str(error)) from error
164
+ except oidc_module.OidcUnavailableError as error:
165
+ raise HTTPException(503, str(error)) from error
166
+ return
167
+ if not secrets.compare_digest(authorization, f"Bearer {token}"):
168
+ raise HTTPException(401, "missing or wrong bearer token")
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class WriteContext:
173
+ """Workspace and If-Match header of a write request."""
174
+
175
+ store: Workspace
176
+ if_match: str | None
177
+
178
+
179
+ def write_context(
180
+ store: Annotated[Workspace, Depends(workspace)],
181
+ if_match: Annotated[str | None, Header()] = None,
182
+ ) -> WriteContext:
183
+ """Collect what every write needs."""
184
+ return WriteContext(store, if_match)
185
+
186
+
187
+ Store = Annotated[Workspace, Depends(workspace)]
188
+ Write = Annotated[WriteContext, Depends(write_context)]
189
+ router = APIRouter(prefix="/api", dependencies=[Depends(authorize)])
190
+
191
+
192
+ class IssueModel(BaseModel):
193
+ """A validation issue: file, line, JSON pointer and message."""
194
+
195
+ file: str
196
+ line: int | None
197
+ pointer: str
198
+ message: str
199
+ severity: str = "error"
200
+
201
+
202
+ class ResultResponse(BaseModel):
203
+ """Result of a service operation; some routes add fields such as `etag`."""
204
+
205
+ model_config = ConfigDict(extra="allow")
206
+
207
+ ok: bool
208
+ issues: list[IssueModel]
209
+ data: dict[str, Any]
210
+ text: str
211
+ etag: str | None = None
212
+
213
+
214
+ def _result(result: Result, **extra: object) -> ResultResponse:
215
+ return ResultResponse.model_validate({**result.to_dict(), **extra})
216
+
217
+
218
+ def _service_error(error: ServiceError) -> HTTPException:
219
+ status = 404 if "not found" in str(error) or "no such" in str(error) else 400
220
+ return HTTPException(status, str(error))
221
+
222
+
223
+ def _edit(context: WriteContext, project: str, action: Callable[[Path], Result]) -> ResultResponse:
224
+ store = context.store
225
+ with store.lock(project):
226
+ check_match(store.state(project).etag, context.if_match)
227
+ try:
228
+ result = action(store.project_dir(project))
229
+ except ServiceError as error:
230
+ raise _service_error(error) from error
231
+ return _result(result, etag=store.state(project).etag)
232
+
233
+
234
+ def _read(store: Workspace, project: str, pointer: str) -> ResultResponse:
235
+ try:
236
+ result = service.get(store.project_dir(project), pointer)
237
+ except ServiceError as error:
238
+ raise _service_error(error) from error
239
+ return _result(result, etag=store.state(project).etag)
240
+
241
+
242
+ # --- projects -----------------------------------------------------------------------------------
243
+
244
+
245
+ @router.get("/projects")
246
+ def list_projects(store: Store) -> list[dict[str, Any]]:
247
+ """List the projects of the workspace."""
248
+ result = []
249
+ for identifier in store.projects():
250
+ model = load_project(store.project_dir(identifier)).project
251
+ result.append(
252
+ {
253
+ "id": identifier,
254
+ "name": model.name if model else None,
255
+ "version": model.version if model else None,
256
+ "etag": store.state(identifier).etag,
257
+ }
258
+ )
259
+ return result
260
+
261
+
262
+ @router.post("/import", status_code=201)
263
+ def import_icd(store: Store, body: ImportRequest) -> ResultResponse:
264
+ """Import an ICD table or C header as a new project of the workspace."""
265
+ destination = store.root / body.id
266
+ if destination.exists():
267
+ raise HTTPException(409, f"{body.id} already exists")
268
+ options = importers.Project(body.name, body.namespace, body.version, body.endian)
269
+ with tempfile.TemporaryDirectory(prefix="wireforge-import-") as temporary:
270
+ source = Path(temporary) / f"icd.{body.format}"
271
+ try:
272
+ data = base64.b64decode(body.content, validate=True) if body.format == "xlsx" else None
273
+ except binascii.Error as error:
274
+ raise HTTPException(400, "content is not base64") from error
275
+ if data is None:
276
+ source.write_text(body.content, encoding="utf-8")
277
+ else:
278
+ source.write_bytes(data)
279
+ try:
280
+ result = service.import_icd(source, destination, options, body.messages)
281
+ except ServiceError as error:
282
+ raise _service_error(error) from error
283
+ if not result.ok and destination.exists() and not any(destination.iterdir()):
284
+ destination.rmdir()
285
+ return _result(result)
286
+
287
+
288
+ @router.get("/templates")
289
+ def list_templates() -> list[dict[str, str]]:
290
+ """Return the templates a new project can start from."""
291
+ return service.template_summaries()
292
+
293
+
294
+ @router.post("/projects", status_code=201)
295
+ def create_project(store: Store, body: ProjectCreate) -> dict[str, Any]:
296
+ """Create an empty project directory."""
297
+ directory = store.root / body.id
298
+ if directory.exists():
299
+ raise HTTPException(409, f"{body.id} already exists")
300
+ if body.template is not None:
301
+ try:
302
+ service.init_project(directory, body.template, body.name, body.namespace, body.version)
303
+ except ServiceError as error:
304
+ raise _service_error(error) from error
305
+ return {"id": body.id, "etag": store.state(body.id).etag}
306
+ directory.mkdir(parents=True)
307
+ lines = ["schema_version: 1", f"project: {body.name}", f'version: "{body.version}"']
308
+ if body.namespace:
309
+ lines.append(f"namespace: {body.namespace}")
310
+ (directory / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
311
+ return {"id": body.id, "etag": store.state(body.id).etag}
312
+
313
+
314
+ @router.get("/projects/{project}")
315
+ def get_project(store: Store, project: str) -> ResultResponse:
316
+ """Return a project: entries per section, files, ETags and validation issues."""
317
+ state = store.state(project)
318
+ model = load_project(store.project_dir(project)).project
319
+ sections = {name: list(getattr(model, name)) for name in SECTIONS} if model else {}
320
+ checked = service.validate(store.project_dir(project))
321
+ return _result(checked, id=project, etag=state.etag, files=state.files, sections=sections)
322
+
323
+
324
+ @router.get("/projects/{project}/state")
325
+ def project_state(store: Store, project: str) -> dict[str, Any]:
326
+ """Return the ETags of a project and its files."""
327
+ current = store.state(project)
328
+ return {"etag": current.etag, "files": current.files}
329
+
330
+
331
+ @router.get("/projects/{project}/wait")
332
+ def wait_for_change(
333
+ store: Store,
334
+ project: str,
335
+ etag: str,
336
+ timeout: Annotated[float, Query(ge=0.0, le=MAX_WAIT_SECONDS)] = 30.0,
337
+ ) -> Response:
338
+ """Return the new state once the project ETag differs from `etag` (304 after the timeout)."""
339
+ changed = store.wait(project, etag, timeout)
340
+ if changed is None:
341
+ return Response(status_code=304)
342
+ return JSONResponse({"etag": changed.etag, "files": changed.files})
343
+
344
+
345
+ @router.post("/projects/{project}/requirements/import")
346
+ def requirements_import(context: Write, project: str, body: RequirementsImport) -> ResultResponse:
347
+ """Import the high-level requirements of a ReqIF or CSV file as a baseline snapshot."""
348
+ try:
349
+ data = base64.b64decode(body.content, validate=True)
350
+ except binascii.Error as error:
351
+ raise HTTPException(400, "content is not base64") from error
352
+ options = hlr_import.ImportOptions(
353
+ body.baseline,
354
+ body.tool,
355
+ body.id_attr,
356
+ body.text_attr,
357
+ body.csv_id_column,
358
+ body.csv_text_column,
359
+ body.force,
360
+ )
361
+
362
+ def action(directory: Path) -> Result:
363
+ with tempfile.TemporaryDirectory(prefix="wireforge-hlr-") as temporary:
364
+ source = Path(temporary) / body.file
365
+ source.write_bytes(data)
366
+ return service.requirements_import(directory, source, options)
367
+
368
+ return _edit(context, project, action)
369
+
370
+
371
+ @router.post("/projects/{project}/requirements/lock")
372
+ def requirements_lock(context: Write, project: str) -> ResultResponse:
373
+ """Write `llr_ids.lock`, which numbers the low-level requirements (append-only)."""
374
+ return _edit(context, project, service.requirements_lock)
375
+
376
+
377
+ @router.post("/projects/{project}/validate")
378
+ def validate_project(store: Store, project: str) -> ResultResponse:
379
+ """Validate a project."""
380
+ return _result(service.validate(store.project_dir(project)))
381
+
382
+
383
+ # --- files --------------------------------------------------------------------------------------
384
+
385
+
386
+ @router.get("/projects/{project}/files/{path:path}")
387
+ def read_file(store: Store, project: str, path: str, response: Response) -> dict[str, str]:
388
+ """Return the text and ETag of a project file."""
389
+ text, tag = store.read(project, path)
390
+ response.headers["ETag"] = f'"{tag}"'
391
+ return {"path": path, "text": text, "etag": tag}
392
+
393
+
394
+ @router.put("/projects/{project}/files/{path:path}")
395
+ def write_file(
396
+ context: Write, project: str, path: str, body: FileWrite, response: Response
397
+ ) -> ResultResponse:
398
+ """Replace a project file (If-Match: its ETag, or `*` for a new file)."""
399
+ store = context.store
400
+ tag = store.write(project, path, body.text, context.if_match)
401
+ response.headers["ETag"] = f'"{tag}"'
402
+ checked = service.validate(store.project_dir(project))
403
+ return _result(checked, etag=tag, project_etag=store.state(project).etag)
404
+
405
+
406
+ # --- entries and values -------------------------------------------------------------------------
407
+
408
+
409
+ @router.get("/projects/{project}/entries/{section}/{name}")
410
+ def get_entry(store: Store, project: str, section: str, name: str) -> ResultResponse:
411
+ """Return a section entry as written in the project files."""
412
+ return _read(store, project, f"/{section}/{name}")
413
+
414
+
415
+ @router.put("/projects/{project}/entries/{section}/{name}")
416
+ def put_entry(
417
+ context: Write, project: str, section: str, name: str, body: EntryWrite
418
+ ) -> ResultResponse:
419
+ """Create or update a section entry (If-Match: the project ETag)."""
420
+ options = EditOptions(dry_run=body.dry_run, strict=body.strict)
421
+
422
+ def action(location: Path) -> Result:
423
+ return service.upsert(location, section, name, body.definition, options)
424
+
425
+ return _edit(context, project, action)
426
+
427
+
428
+ @router.delete("/projects/{project}/entries/{section}/{name}")
429
+ def delete_entry(
430
+ context: Write,
431
+ project: str,
432
+ section: str,
433
+ name: str,
434
+ body: Annotated[EditFlags | None, Body()] = None,
435
+ ) -> ResultResponse:
436
+ """Delete a section entry (If-Match: the project ETag)."""
437
+ flags = body or EditFlags()
438
+ options = EditOptions(dry_run=flags.dry_run, strict=flags.strict)
439
+
440
+ def action(location: Path) -> Result:
441
+ return service.remove(location, f"/{section}/{name}", options)
442
+
443
+ return _edit(context, project, action)
444
+
445
+
446
+ @router.get("/projects/{project}/value")
447
+ def get_value(store: Store, project: str, pointer: str) -> ResultResponse:
448
+ """Return the value at a JSON pointer."""
449
+ return _read(store, project, pointer)
450
+
451
+
452
+ @router.put("/projects/{project}/value")
453
+ def put_value(context: Write, project: str, pointer: str, body: ValueWrite) -> ResultResponse:
454
+ """Set the value at a JSON pointer (If-Match: the project ETag)."""
455
+ options = EditOptions(dry_run=body.dry_run, strict=body.strict)
456
+
457
+ def action(location: Path) -> Result:
458
+ return service.set_value(location, pointer, body.value, options)
459
+
460
+ return _edit(context, project, action)
461
+
462
+
463
+ @router.delete("/projects/{project}/value")
464
+ def delete_value(context: Write, project: str, pointer: str) -> ResultResponse:
465
+ """Delete the element at a JSON pointer (If-Match: the project ETag)."""
466
+
467
+ def action(location: Path) -> Result:
468
+ return service.remove(location, pointer)
469
+
470
+ return _edit(context, project, action)
471
+
472
+
473
+ # --- generate, analyze, compat, playground -----------------------------------------------------
474
+
475
+
476
+ @router.get("/projects/{project}/generate")
477
+ def generate_zip(store: Store, project: str, *, tests: bool = True) -> Response:
478
+ """Download the generated code as a self-contained zip (with the test project)."""
479
+ result, data = service.generate_archive(store.project_dir(project), tests=tests)
480
+ if not result.ok:
481
+ return JSONResponse(result.to_dict(), status_code=422)
482
+ name = f"{result.data['project']}-generated.zip"
483
+ headers = {"Content-Disposition": f'attachment; filename="{name}"'}
484
+ return Response(data, media_type="application/zip", headers=headers)
485
+
486
+
487
+ @router.get("/projects/{project}/docs")
488
+ def lifecycle_zip(store: Store, project: str, *, strict: bool = False) -> Response:
489
+ """Download the requirements, design, test and traceability documents as a zip."""
490
+ result, data = service.lifecycle_archive(store.project_dir(project), strict=strict)
491
+ if not data:
492
+ return JSONResponse(result.to_dict(), status_code=422)
493
+ headers = {"Content-Disposition": f'attachment; filename="{project}-docs.zip"'}
494
+ return Response(data, media_type="application/zip", headers=headers)
495
+
496
+
497
+ @router.get("/projects/{project}/preview")
498
+ def preview(store: Store, project: str) -> ResultResponse:
499
+ """Return the generated C++ files without writing them."""
500
+ return _result(service.preview(store.project_dir(project)))
501
+
502
+
503
+ @router.get("/projects/{project}/wireshark")
504
+ def wireshark_plugin(store: Store, project: str) -> Response:
505
+ """Download the Wireshark Lua dissector plugin of the project."""
506
+ result, text = service.wireshark_plugin(store.project_dir(project))
507
+ if not result.ok:
508
+ return JSONResponse(result.to_dict(), status_code=422)
509
+ name = f"{result.data['project']}.lua"
510
+ headers = {"Content-Disposition": f'attachment; filename="{name}"'}
511
+ return Response(text, media_type="text/x-lua; charset=utf-8", headers=headers)
512
+
513
+
514
+ @router.get("/projects/{project}/analyze")
515
+ def analyze(
516
+ store: Store, project: str, profile: str | None = None, export: str = "markdown"
517
+ ) -> ResultResponse:
518
+ """Return the layout report (data) and its export (`text`: markdown, csv or json)."""
519
+ if export not in {"markdown", "csv", "json"}:
520
+ raise HTTPException(400, f"unknown export {export!r}")
521
+ try:
522
+ return _result(service.analyze(store.project_dir(project), profile, export))
523
+ except ServiceError as error:
524
+ raise _service_error(error) from error
525
+
526
+
527
+ @router.post("/compat")
528
+ def compatibility(store: Store, body: CompatRequest) -> ResultResponse:
529
+ """Compare two projects of the workspace (old and new version of the interfaces)."""
530
+ old, new = store.project_dir(body.old), store.project_dir(body.new)
531
+ return _result(service.compatibility(old, new, body.interfaces))
532
+
533
+
534
+ @router.get("/projects/{project}/history")
535
+ def project_history(store: Store, project: str) -> ResultResponse:
536
+ """Return the git commits that changed the project (none outside a repository)."""
537
+ try:
538
+ return _result(service.history(store.project_dir(project)))
539
+ except ServiceError as error:
540
+ raise _service_error(error) from error
541
+
542
+
543
+ @router.post("/projects/{project}/compat")
544
+ def compatibility_with_revision(store: Store, project: str, body: RevisionCompat) -> ResultResponse:
545
+ """Compare the project at a git revision (old) with the working copy (new)."""
546
+ try:
547
+ location = store.project_dir(project)
548
+ return _result(
549
+ service.compatibility_with_revision(location, body.revision, body.interfaces)
550
+ )
551
+ except ServiceError as error:
552
+ raise _service_error(error) from error
553
+
554
+
555
+ def _target(store: Workspace, project: str, body: CodecRequest) -> service.CodecTarget:
556
+ plugins = service.load_plugins(service.find_plugins(store.project_dir(project)))
557
+ return service.CodecTarget(body.message, body.interface, body.profile, plugins)
558
+
559
+
560
+ @router.post("/projects/{project}/encode")
561
+ def encode(store: Store, project: str, body: EncodeRequest) -> ResultResponse:
562
+ """Encode a message value (payload, and the frame when an interface is given)."""
563
+ try:
564
+ target = _target(store, project, body)
565
+ return _result(service.encode(store.project_dir(project), body.value, target))
566
+ except ServiceError as error:
567
+ raise _service_error(error) from error
568
+
569
+
570
+ @router.post("/projects/{project}/decode")
571
+ def decode(store: Store, project: str, body: DecodeRequest) -> ResultResponse:
572
+ """Decode hex bytes: a payload with `message`, otherwise a frame of `interface`."""
573
+ try:
574
+ data = bytes.fromhex(body.hex)
575
+ except ValueError as error:
576
+ raise HTTPException(400, f"{body.hex!r} is not hex") from error
577
+ try:
578
+ target = _target(store, project, body)
579
+ return _result(service.decode(store.project_dir(project), data, target))
580
+ except ServiceError as error:
581
+ raise _service_error(error) from error
582
+
583
+
584
+ # --- application --------------------------------------------------------------------------------
585
+
586
+
587
+ def _storage_error(request: Request, error: Exception) -> Response:
588
+ del request
589
+ status = error.status if isinstance(error, StorageError) else 500
590
+ return JSONResponse({"detail": str(error)}, status_code=status)
591
+
592
+
593
+ def create_app(root: Path, token: str | None = None, oidc: OidcConfig | None = None) -> FastAPI:
594
+ """Return the application serving the projects below `root`.
595
+
596
+ Requests need the bearer `token` or, with `oidc`, a JWT of that issuer and audience.
597
+ """
598
+ app = FastAPI(
599
+ title="WireForge",
600
+ version=versioning.TOOL_VERSION,
601
+ description="Projects, validation and editing of WireForge wire-format definitions.",
602
+ )
603
+ app.state.workspace = Workspace(root)
604
+ app.state.token = token
605
+ app.state.oidc_verifier = None
606
+ if oidc is not None:
607
+ oidc_module = importlib.import_module("wireforge.server.oidc")
608
+ app.state.oidc_verifier = oidc_module.OidcVerifier(oidc)
609
+ app.add_exception_handler(StorageError, _storage_error)
610
+
611
+ @app.get("/api/health")
612
+ def health() -> dict[str, str]:
613
+ return {"status": "ok", "version": versioning.TOOL_VERSION}
614
+
615
+ app.include_router(router)
616
+ ui = resources.ui_dir()
617
+ if ui is not None:
618
+ app.mount("/", StaticFiles(directory=ui, html=True), name="ui")
619
+ return app