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.
- wireforge/__init__.py +3 -0
- wireforge/_data/cmake/WireForgeConfig.cmake +29 -0
- wireforge/_data/cmake/WireForgeConfigVersion.cmake +15 -0
- wireforge/_data/cmake/WireForgeGenerate.cmake +64 -0
- wireforge/_data/runtime/LICENSE +21 -0
- wireforge/_data/runtime/include/wireforge/array_access.hpp +35 -0
- wireforge/_data/runtime/include/wireforge/bit_utils.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/byte_io.hpp +387 -0
- wireforge/_data/runtime/include/wireforge/checksum.hpp +201 -0
- wireforge/_data/runtime/include/wireforge/codec_support.hpp +412 -0
- wireforge/_data/runtime/include/wireforge/config.hpp +41 -0
- wireforge/_data/runtime/include/wireforge/delegate.hpp +101 -0
- wireforge/_data/runtime/include/wireforge/dispatcher.hpp +339 -0
- wireforge/_data/runtime/include/wireforge/expr_support.hpp +38 -0
- wireforge/_data/runtime/include/wireforge/frame_decoder.hpp +262 -0
- wireforge/_data/runtime/include/wireforge/frame_layout.hpp +20 -0
- wireforge/_data/runtime/include/wireforge/link.hpp +223 -0
- wireforge/_data/runtime/include/wireforge/lock.hpp +146 -0
- wireforge/_data/runtime/include/wireforge/ring_buffer.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/span.hpp +116 -0
- wireforge/_data/runtime/include/wireforge/static_vector.hpp +264 -0
- wireforge/_data/runtime/include/wireforge/status.hpp +75 -0
- wireforge/_data/runtime/include/wireforge/transport.hpp +95 -0
- wireforge/_data/runtime/include/wireforge/validation.hpp +153 -0
- wireforge/_data/runtime/include/wireforge/version.hpp +22 -0
- wireforge/_data/runtime/requirements.yaml +438 -0
- wireforge/_data/schema/deviations.schema.json +47 -0
- wireforge/_data/schema/fragment.schema.json +79 -0
- wireforge/_data/schema/llr_ids.schema.json +25 -0
- wireforge/_data/schema/project.schema.json +1240 -0
- wireforge/_data/schema/runtime_requirements.schema.json +34 -0
- wireforge/_data/ui/assets/index-6Pomu6U4.js +44 -0
- wireforge/_data/ui/assets/index-GVdDyUAG.css +1 -0
- wireforge/_data/ui/index.html +13 -0
- wireforge/bundle.py +177 -0
- wireforge/ci_check.py +155 -0
- wireforge/cli.py +617 -0
- wireforge/codec.py +897 -0
- wireforge/compat.py +536 -0
- wireforge/coverage_points.py +64 -0
- wireforge/cpp.py +273 -0
- wireforge/cpp_codec.py +440 -0
- wireforge/cpp_interface.py +874 -0
- wireforge/cpp_tests.py +1993 -0
- wireforge/differential.py +586 -0
- wireforge/docs_markdown.py +72 -0
- wireforge/expr.py +598 -0
- wireforge/findings.py +187 -0
- wireforge/fmt.py +351 -0
- wireforge/generate.py +644 -0
- wireforge/golden.py +253 -0
- wireforge/hlr_import.py +112 -0
- wireforge/impact.py +249 -0
- wireforge/importers.py +402 -0
- wireforge/issues.py +27 -0
- wireforge/layout.py +681 -0
- wireforge/lifecycle.py +1266 -0
- wireforge/lifecycle_results.py +205 -0
- wireforge/lifecycle_rules.py +78 -0
- wireforge/lifecycle_runtime.py +375 -0
- wireforge/llr.py +693 -0
- wireforge/llr_ids.py +104 -0
- wireforge/loader.py +422 -0
- wireforge/mcp_server.py +357 -0
- wireforge/model.py +322 -0
- wireforge/plain.py +15 -0
- wireforge/py.typed +0 -0
- wireforge/reqif.py +289 -0
- wireforge/resources.py +43 -0
- wireforge/results.py +377 -0
- wireforge/runtime_reqs.py +147 -0
- wireforge/schema.py +151 -0
- wireforge/server/__init__.py +1 -0
- wireforge/server/app.py +619 -0
- wireforge/server/oidc.py +99 -0
- wireforge/server/storage.py +146 -0
- wireforge/service.py +1354 -0
- wireforge/templates/command/project.yaml +38 -0
- wireforge/templates/raw/project.yaml +28 -0
- wireforge/templates/telemetry/project.yaml +51 -0
- wireforge/test_cases.py +475 -0
- wireforge/test_project.py +478 -0
- wireforge/trace.py +442 -0
- wireforge/validate.py +1241 -0
- wireforge/vcs.py +44 -0
- wireforge/vectors.py +222 -0
- wireforge/versioning.py +137 -0
- wireforge/wireshark.py +792 -0
- wireforge/wireshark_runtime.lua +725 -0
- wireforge-0.1.0.dist-info/METADATA +91 -0
- wireforge-0.1.0.dist-info/RECORD +96 -0
- wireforge-0.1.0.dist-info/WHEEL +5 -0
- wireforge-0.1.0.dist-info/entry_points.txt +2 -0
- wireforge-0.1.0.dist-info/licenses/LICENSE +98 -0
- wireforge-0.1.0.dist-info/licenses/runtime/LICENSE +21 -0
- wireforge-0.1.0.dist-info/top_level.txt +1 -0
wireforge/server/app.py
ADDED
|
@@ -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
|