archi-cli 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.
- archi_cli-0.1.0.dist-info/METADATA +214 -0
- archi_cli-0.1.0.dist-info/RECORD +16 -0
- archi_cli-0.1.0.dist-info/WHEEL +4 -0
- archi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- archi_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- archi_tool/__init__.py +1 -0
- archi_tool/cli.py +528 -0
- archi_tool/discovery.py +137 -0
- archi_tool/engine.py +266 -0
- archi_tool/model.py +280 -0
- archi_tool/normalize.py +91 -0
- archi_tool/render.py +207 -0
- archi_tool/render_html.py +645 -0
- archi_tool/render_slides.py +650 -0
- archi_tool/validate.py +150 -0
- archi_tool/views.py +215 -0
archi_tool/cli.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""CLI for deterministic manipulation of native .archimate models.
|
|
2
|
+
|
|
3
|
+
Usage: archi <subcommand> [--model PATH] ...
|
|
4
|
+
Mutating subcommands validate the model in memory and refuse to save when
|
|
5
|
+
validation errors are found. Run `archi normalize` before committing so the
|
|
6
|
+
serialization stays Archi-canonical.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from lxml import etree
|
|
18
|
+
|
|
19
|
+
from .discovery import discover_conventions, discover_model
|
|
20
|
+
from .model import ArchiModel, ModelError, is_element, xsi_type
|
|
21
|
+
from .normalize import normalize
|
|
22
|
+
from .render import render_all, write_if_changed
|
|
23
|
+
from .render_html import render_all_html
|
|
24
|
+
from .render_slides import load_deck, render_all_slides, render_deck_html
|
|
25
|
+
from .validate import validate
|
|
26
|
+
from .views import add_view
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_properties(pairs):
|
|
30
|
+
properties = {}
|
|
31
|
+
for pair in pairs or []:
|
|
32
|
+
key, sep, value = pair.partition("=")
|
|
33
|
+
if not sep:
|
|
34
|
+
raise ModelError(f"Property moet key=value zijn, kreeg: '{pair}'")
|
|
35
|
+
properties[key] = value
|
|
36
|
+
return properties
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def report_validation(model, model_path) -> bool:
|
|
40
|
+
"""Print validation results; return True when the model is sound."""
|
|
41
|
+
# anchor conventions discovery at the model, not the working directory, so
|
|
42
|
+
# validating a model from another project uses that project's conventions
|
|
43
|
+
allowed_keys, _ = discover_conventions(
|
|
44
|
+
model_path, start=Path(model_path).resolve().parent)
|
|
45
|
+
errors, warnings = validate(model, allowed_keys=allowed_keys)
|
|
46
|
+
for warning in warnings:
|
|
47
|
+
print(f"WAARSCHUWING: {warning}")
|
|
48
|
+
for error in errors:
|
|
49
|
+
print(f"FOUT: {error}")
|
|
50
|
+
return not errors
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_validated(model, args) -> int:
|
|
54
|
+
if not report_validation(model, args.model):
|
|
55
|
+
print("Niet opgeslagen: los eerst de fouten hierboven op.")
|
|
56
|
+
return 1
|
|
57
|
+
model.save()
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def describe(model, node) -> str:
|
|
62
|
+
kind = xsi_type(node)
|
|
63
|
+
name = node.get("name") or "(naamloos)"
|
|
64
|
+
return f"{node.get('id')} [{kind}] {name}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- subcommand implementations ---------------------------------------------
|
|
68
|
+
|
|
69
|
+
def cmd_stats(model, args):
|
|
70
|
+
print(f"Model: {model.name}")
|
|
71
|
+
counts = Counter(xsi_type(e) for e in model.elements())
|
|
72
|
+
print(f"Elementen: {sum(counts.values())}")
|
|
73
|
+
for kind, count in counts.most_common():
|
|
74
|
+
print(f" {kind}: {count}")
|
|
75
|
+
rel_counts = Counter(xsi_type(r) for r in model.relationships())
|
|
76
|
+
print(f"Relaties: {sum(rel_counts.values())}")
|
|
77
|
+
for kind, count in rel_counts.most_common():
|
|
78
|
+
print(f" {kind}: {count}")
|
|
79
|
+
diagrams = model.diagrams()
|
|
80
|
+
print(f"Views: {len(diagrams)}")
|
|
81
|
+
for d in diagrams:
|
|
82
|
+
print(f" {d.get('name')}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_list(model, args):
|
|
87
|
+
wanted = parse_properties(args.property)
|
|
88
|
+
for el in model.elements():
|
|
89
|
+
if args.type and xsi_type(el) != args.type:
|
|
90
|
+
continue
|
|
91
|
+
props = model.properties(el)
|
|
92
|
+
if any(props.get(k) != v for k, v in wanted.items()):
|
|
93
|
+
continue
|
|
94
|
+
print(describe(model, el))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_show(model, args):
|
|
99
|
+
node = model.resolve(args.ref)
|
|
100
|
+
print(describe(model, node))
|
|
101
|
+
documentation = model.documentation(node)
|
|
102
|
+
if documentation:
|
|
103
|
+
print(f" documentatie: {documentation}")
|
|
104
|
+
for key, value in model.properties(node).items():
|
|
105
|
+
print(f" {key} = {value}")
|
|
106
|
+
node_id = node.get("id")
|
|
107
|
+
index = model.id_index()
|
|
108
|
+
for rel in model.relations_of(node_id):
|
|
109
|
+
other_id = (rel.get("target") if rel.get("source") == node_id
|
|
110
|
+
else rel.get("source"))
|
|
111
|
+
other = index.get(other_id)
|
|
112
|
+
direction = "->" if rel.get("source") == node_id else "<-"
|
|
113
|
+
other_name = other.get("name") if other is not None else other_id
|
|
114
|
+
print(f" {direction} {xsi_type(rel)} {direction} {other_name}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_tree(model, args):
|
|
119
|
+
def walk(folder, depth):
|
|
120
|
+
children = folder.findall("element")
|
|
121
|
+
indent = " " * depth
|
|
122
|
+
folder_type = folder.get("type")
|
|
123
|
+
label = (f"{folder.get('name')} ({folder_type})" if folder_type
|
|
124
|
+
else folder.get("name"))
|
|
125
|
+
print(f"{indent}{label}: {len(children)} item(s)")
|
|
126
|
+
for el in children:
|
|
127
|
+
if is_element(el) or el.get("name"):
|
|
128
|
+
print(f"{indent} {describe(model, el)}")
|
|
129
|
+
for sub in folder.findall("folder"):
|
|
130
|
+
walk(sub, depth + 1)
|
|
131
|
+
|
|
132
|
+
for folder in model.root.findall("folder"):
|
|
133
|
+
walk(folder, 0)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_validate(model, args):
|
|
138
|
+
if report_validation(model, args.model):
|
|
139
|
+
print("OK: het model is consistent.")
|
|
140
|
+
return 0
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_add_element(model, args):
|
|
145
|
+
el = model.add_element(args.type, args.name, folder_type=args.folder,
|
|
146
|
+
properties=parse_properties(args.property),
|
|
147
|
+
documentation=args.documentation)
|
|
148
|
+
status = save_validated(model, args)
|
|
149
|
+
if status == 0:
|
|
150
|
+
print(f"Toegevoegd: {describe(model, el)}")
|
|
151
|
+
return status
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_add_relation(model, args):
|
|
155
|
+
rel = model.add_relation(args.type, args.source, args.target,
|
|
156
|
+
name=args.name)
|
|
157
|
+
status = save_validated(model, args)
|
|
158
|
+
if status == 0:
|
|
159
|
+
print(f"Toegevoegd: {rel.get('id')} [{xsi_type(rel)}] "
|
|
160
|
+
f"{args.source} -> {args.target}")
|
|
161
|
+
return status
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_set_property(model, args):
|
|
165
|
+
key, sep, value = args.pair.partition("=")
|
|
166
|
+
if not sep:
|
|
167
|
+
raise ModelError(f"Property moet key=value zijn, kreeg: '{args.pair}'")
|
|
168
|
+
model.set_property(args.ref, key, value)
|
|
169
|
+
status = save_validated(model, args)
|
|
170
|
+
if status == 0:
|
|
171
|
+
print(f"Property gezet op '{args.ref}': {key} = {value}")
|
|
172
|
+
return status
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_rename(model, args):
|
|
176
|
+
model.rename(args.ref, args.name)
|
|
177
|
+
status = save_validated(model, args)
|
|
178
|
+
if status == 0:
|
|
179
|
+
print(f"Hernoemd: '{args.ref}' heet nu '{args.name}'")
|
|
180
|
+
return status
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_set_documentation(model, args):
|
|
184
|
+
model.set_documentation(args.ref, args.text)
|
|
185
|
+
status = save_validated(model, args)
|
|
186
|
+
if status == 0:
|
|
187
|
+
print(f"Documentatie gezet op '{args.ref}'")
|
|
188
|
+
return status
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_remove(model, args):
|
|
192
|
+
model.remove(args.ref, cascade=args.cascade)
|
|
193
|
+
status = save_validated(model, args)
|
|
194
|
+
if status == 0:
|
|
195
|
+
print(f"Verwijderd: {args.ref}")
|
|
196
|
+
return status
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_set_model_name(model, args):
|
|
200
|
+
model.set_model_name(args.name)
|
|
201
|
+
status = save_validated(model, args)
|
|
202
|
+
if status == 0:
|
|
203
|
+
print(f"Modelnaam gewijzigd naar '{args.name}'")
|
|
204
|
+
return status
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_add_view(model, args):
|
|
208
|
+
extra = [model.resolve(ref) for ref in (args.element or [])]
|
|
209
|
+
for node in extra:
|
|
210
|
+
if not is_element(node):
|
|
211
|
+
raise ModelError(
|
|
212
|
+
f"'{node.get('name') or node.get('id')}' is geen element")
|
|
213
|
+
diagram = add_view(model, args.name, layout=args.layout,
|
|
214
|
+
element_types=set(args.type) if args.type else None,
|
|
215
|
+
relation_types=(set(args.relation)
|
|
216
|
+
if args.relation else None),
|
|
217
|
+
prop=args.property, root=args.root,
|
|
218
|
+
related=args.related,
|
|
219
|
+
extra_elements=extra or None)
|
|
220
|
+
status = save_validated(model, args)
|
|
221
|
+
if status == 0:
|
|
222
|
+
objects = len(diagram.findall("child"))
|
|
223
|
+
connections = len(list(diagram.iter("sourceConnection")))
|
|
224
|
+
print(f"View '{args.name}' aangemaakt: {objects} objecten, "
|
|
225
|
+
f"{connections} verbindingen")
|
|
226
|
+
return status
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_normalize(model, args):
|
|
230
|
+
normalize(args.model, download=not args.no_download)
|
|
231
|
+
print(f"Genormaliseerd via Archi: {args.model}")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_setup(model, args):
|
|
236
|
+
from .engine import download_engine, find_or_none
|
|
237
|
+
existing = find_or_none()
|
|
238
|
+
if existing:
|
|
239
|
+
print(f"Archi al beschikbaar: {existing}")
|
|
240
|
+
return 0
|
|
241
|
+
binary = download_engine()
|
|
242
|
+
print(f"Archi-engine klaar: {binary}")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_render(model, args):
|
|
247
|
+
written, removed = render_all(model, args.out)
|
|
248
|
+
html_written, html_removed = render_all_html(model, Path(args.out) / "html")
|
|
249
|
+
deck_written, deck_removed = render_all_slides(
|
|
250
|
+
model, Path(args.decks), Path(args.out) / "html" / "slides")
|
|
251
|
+
for path in written + html_written + deck_written:
|
|
252
|
+
print(f"Geschreven: {path}")
|
|
253
|
+
for path in removed + html_removed:
|
|
254
|
+
print(f"Verwijderd (view bestaat niet meer): {path}")
|
|
255
|
+
for path in deck_removed:
|
|
256
|
+
print(f"Verwijderd (deck bestaat niet meer): {path}")
|
|
257
|
+
if not (written or removed or html_written or html_removed
|
|
258
|
+
or deck_written or deck_removed):
|
|
259
|
+
print("Views zijn al actueel.")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_slides(model, args):
|
|
264
|
+
if args.deck:
|
|
265
|
+
written = []
|
|
266
|
+
for deck_path in args.deck:
|
|
267
|
+
deck = load_deck(Path(deck_path), model)
|
|
268
|
+
path = Path(args.out) / f"{deck['slug']}.html"
|
|
269
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
if write_if_changed(path, render_deck_html(model, deck)):
|
|
271
|
+
written.append(path)
|
|
272
|
+
removed = []
|
|
273
|
+
else:
|
|
274
|
+
written, removed = render_all_slides(
|
|
275
|
+
model, Path(args.decks), Path(args.out))
|
|
276
|
+
for path in written:
|
|
277
|
+
print(f"Geschreven: {path}")
|
|
278
|
+
for path in removed:
|
|
279
|
+
print(f"Verwijderd (deck bestaat niet meer): {path}")
|
|
280
|
+
if not (written or removed):
|
|
281
|
+
print("Slides zijn al actueel.")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- Typer app --------------------------------------------------------------
|
|
286
|
+
#
|
|
287
|
+
# Typer owns argument parsing and help; the cmd_* functions above own the
|
|
288
|
+
# behaviour. Each command is a thin typed wrapper that packs its parameters
|
|
289
|
+
# into a namespace (so the cmd_* signatures stay untouched) and hands them to
|
|
290
|
+
# _run, which centralises model discovery, loading and Dutch error handling.
|
|
291
|
+
|
|
292
|
+
HELP = """CLI voor deterministische bewerking van native .archimate-modellen.
|
|
293
|
+
|
|
294
|
+
Muterende subcommando's valideren het model in het geheugen en weigeren op te
|
|
295
|
+
slaan zolang er fouten zijn. Draai `archi normalize` vóór het committen, zodat
|
|
296
|
+
de serialisatie Archi-canoniek blijft.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
app = typer.Typer(
|
|
300
|
+
help=HELP, no_args_is_help=True, add_completion=True,
|
|
301
|
+
context_settings={"help_option_names": ["-h", "--help"]})
|
|
302
|
+
|
|
303
|
+
# the global --model, shared by every command through the app callback
|
|
304
|
+
_state = SimpleNamespace(model=None)
|
|
305
|
+
|
|
306
|
+
ModelOption = typer.Option(
|
|
307
|
+
None, "--model",
|
|
308
|
+
help="pad naar het .archimate-bestand; standaard gevonden via archi.toml "
|
|
309
|
+
"of het enige .archimate-bestand in de huidige map")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@app.callback()
|
|
313
|
+
def _main(model: Optional[str] = ModelOption):
|
|
314
|
+
_state.model = model
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _run(command_fn, *, load_model=True, need_model=True, **fields) -> None:
|
|
318
|
+
"""Discover the model, run a cmd_* function, translate errors, set exit.
|
|
319
|
+
|
|
320
|
+
``load_model=False`` is for normalize, which must not let lxml parse the
|
|
321
|
+
file first (Archi is the canonical serializer and may load what lxml
|
|
322
|
+
refuses). ``need_model=False`` is for commands that touch no model at all,
|
|
323
|
+
such as setup. Raises typer.Exit with the command's status code.
|
|
324
|
+
"""
|
|
325
|
+
args = SimpleNamespace(model=None, **fields)
|
|
326
|
+
try:
|
|
327
|
+
if need_model:
|
|
328
|
+
args.model = discover_model(_state.model)
|
|
329
|
+
model = ArchiModel(args.model) if (need_model and load_model) else None
|
|
330
|
+
status = command_fn(model, args)
|
|
331
|
+
except ModelError as exc:
|
|
332
|
+
typer.echo(f"FOUT: {exc}", err=True)
|
|
333
|
+
raise typer.Exit(1)
|
|
334
|
+
except (etree.XMLSyntaxError, OSError) as exc:
|
|
335
|
+
typer.echo(f"FOUT: kan {args.model} niet lezen: {exc}", err=True)
|
|
336
|
+
raise typer.Exit(1)
|
|
337
|
+
raise typer.Exit(status)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Repeated options declared once; Typer reads the default value + help here.
|
|
341
|
+
PropertyFilter = typer.Option(
|
|
342
|
+
None, "--property", help="filter op property, key=value (herhaalbaar)")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.command(help="aantallen per type, relaties en views")
|
|
346
|
+
def stats():
|
|
347
|
+
_run(cmd_stats)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command("list", help="elementen tonen, optioneel gefilterd")
|
|
351
|
+
def list_elements(
|
|
352
|
+
type: Optional[str] = typer.Option(
|
|
353
|
+
None, help="filter op elementtype (bv. Capability)"),
|
|
354
|
+
property: Optional[list[str]] = PropertyFilter,
|
|
355
|
+
):
|
|
356
|
+
_run(cmd_list, type=type, property=property)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@app.command(help="één element met properties en relaties")
|
|
360
|
+
def show(ref: str = typer.Argument(help="id of (unieke) naam")):
|
|
361
|
+
_run(cmd_show, ref=ref)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command(help="folderstructuur met inhoud")
|
|
365
|
+
def tree():
|
|
366
|
+
_run(cmd_tree)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@app.command("validate", help="integriteitschecks draaien")
|
|
370
|
+
def validate_command():
|
|
371
|
+
_run(cmd_validate)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@app.command("normalize", help="serialisatie canoniek maken via de Archi CLI")
|
|
375
|
+
def normalize_command(
|
|
376
|
+
no_download: bool = typer.Option(
|
|
377
|
+
False, "--no-download",
|
|
378
|
+
help="haal de Archi-engine niet automatisch op; faal als hij "
|
|
379
|
+
"ontbreekt (voor CI en luchtdichte omgevingen)"),
|
|
380
|
+
):
|
|
381
|
+
_run(cmd_normalize, load_model=False, no_download=no_download)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.command("setup", help="de Archi-engine ophalen naar de lokale cache")
|
|
385
|
+
def setup_command():
|
|
386
|
+
_run(cmd_setup, need_model=False)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.command("add-element", help="element toevoegen")
|
|
390
|
+
def add_element(
|
|
391
|
+
type: str = typer.Option(..., help="elementtype (bv. Capability)"),
|
|
392
|
+
name: str = typer.Option(..., help="naam van het element"),
|
|
393
|
+
folder: Optional[str] = typer.Option(
|
|
394
|
+
None, help="folder-type; default volgt uit het type"),
|
|
395
|
+
property: Optional[list[str]] = typer.Option(
|
|
396
|
+
None, "--property", help="key=value (herhaalbaar)"),
|
|
397
|
+
documentation: Optional[str] = typer.Option(None),
|
|
398
|
+
):
|
|
399
|
+
_run(cmd_add_element, type=type, name=name, folder=folder,
|
|
400
|
+
property=property, documentation=documentation)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.command("add-relation", help="relatie toevoegen")
|
|
404
|
+
def add_relation(
|
|
405
|
+
type: str = typer.Option(
|
|
406
|
+
..., help="bv. Aggregation of AggregationRelationship"),
|
|
407
|
+
source: str = typer.Option(..., help="id of unieke naam"),
|
|
408
|
+
target: str = typer.Option(..., help="id of unieke naam"),
|
|
409
|
+
name: Optional[str] = typer.Option(None, help="NL-label op de relatie"),
|
|
410
|
+
):
|
|
411
|
+
_run(cmd_add_relation, type=type, source=source,
|
|
412
|
+
target=target, name=name)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command("set-property", help="property zetten of bijwerken")
|
|
416
|
+
def set_property(
|
|
417
|
+
ref: str = typer.Argument(help="id of (unieke) naam"),
|
|
418
|
+
pair: str = typer.Argument(help="key=value"),
|
|
419
|
+
):
|
|
420
|
+
_run(cmd_set_property, ref=ref, pair=pair)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.command(help="element of relatie hernoemen")
|
|
424
|
+
def rename(
|
|
425
|
+
ref: str = typer.Argument(help="id of (unieke) naam"),
|
|
426
|
+
name: str = typer.Argument(help="nieuwe naam"),
|
|
427
|
+
):
|
|
428
|
+
_run(cmd_rename, ref=ref, name=name)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command("set-documentation", help="documentatie zetten")
|
|
432
|
+
def set_documentation(
|
|
433
|
+
ref: str = typer.Argument(help="id of (unieke) naam"),
|
|
434
|
+
text: str = typer.Argument(help="documentatietekst"),
|
|
435
|
+
):
|
|
436
|
+
_run(cmd_set_documentation, ref=ref, text=text)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.command(help="element of relatie verwijderen")
|
|
440
|
+
def remove(
|
|
441
|
+
ref: str = typer.Argument(help="id of (unieke) naam"),
|
|
442
|
+
cascade: bool = typer.Option(
|
|
443
|
+
False, help="verwijder ook relaties en view-objecten die ernaar "
|
|
444
|
+
"verwijzen"),
|
|
445
|
+
):
|
|
446
|
+
_run(cmd_remove, ref=ref, cascade=cascade)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@app.command("set-model-name", help="modelnaam wijzigen")
|
|
450
|
+
def set_model_name(name: str = typer.Argument(help="nieuwe modelnaam")):
|
|
451
|
+
_run(cmd_set_model_name, name=name)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.command(help="views renderen naar Mermaid, NLDD-HTML en slidedecks")
|
|
455
|
+
def render(
|
|
456
|
+
out: str = typer.Option(
|
|
457
|
+
"views", help="doelmap voor de markdown-bestanden (default: views)"),
|
|
458
|
+
decks: str = typer.Option(
|
|
459
|
+
"decks", help="map met deckdefinities in TOML (default: decks)"),
|
|
460
|
+
):
|
|
461
|
+
_run(cmd_render, out=out, decks=decks)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@app.command(help="slidedecks renderen naar zelfstandige HTML")
|
|
465
|
+
def slides(
|
|
466
|
+
deck: Optional[list[str]] = typer.Option(
|
|
467
|
+
None, "--deck", help="specifiek deckbestand (.toml); herhaalbaar; "
|
|
468
|
+
"default: alle decks in de decks-map"),
|
|
469
|
+
decks: str = typer.Option(
|
|
470
|
+
"decks", help="map met deckdefinities in TOML (default: decks)"),
|
|
471
|
+
out: str = typer.Option(
|
|
472
|
+
"views/html/slides", help="doelmap voor de HTML-bestanden"),
|
|
473
|
+
):
|
|
474
|
+
_run(cmd_slides, deck=deck, decks=decks, out=out)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@app.command("add-view", help="view genereren met berekende layout")
|
|
478
|
+
def add_view_command(
|
|
479
|
+
name: str = typer.Option(..., help="naam van de nieuwe view"),
|
|
480
|
+
layout: str = typer.Option("grid", help="grid of cluster"),
|
|
481
|
+
type: Optional[list[str]] = typer.Option(
|
|
482
|
+
None, help="elementtype in de selectie (herhaalbaar)"),
|
|
483
|
+
relation: Optional[list[str]] = typer.Option(
|
|
484
|
+
None, help="relatietype in de selectie (herhaalbaar)"),
|
|
485
|
+
property: Optional[str] = typer.Option(
|
|
486
|
+
None, "--property", help="selectiefilter, key=value"),
|
|
487
|
+
root: Optional[str] = typer.Option(
|
|
488
|
+
None, help="element (id of naam): dit element plus alles wat het "
|
|
489
|
+
"aggregeert of composeert"),
|
|
490
|
+
related: bool = typer.Option(
|
|
491
|
+
False, help="voeg ook direct gerelateerde elementen toe (één stap, "
|
|
492
|
+
"alleen samen met --root zinvol)"),
|
|
493
|
+
element: Optional[list[str]] = typer.Option(
|
|
494
|
+
None, "--element", help="element (id of unieke naam) toevoegen aan de "
|
|
495
|
+
"selectie (herhaalbaar)"),
|
|
496
|
+
):
|
|
497
|
+
if layout not in ("grid", "cluster"):
|
|
498
|
+
typer.echo("FOUT: --layout moet grid of cluster zijn", err=True)
|
|
499
|
+
raise typer.Exit(1)
|
|
500
|
+
_run(cmd_add_view, name=name, layout=layout, type=type,
|
|
501
|
+
relation=relation, property=property, root=root, related=related,
|
|
502
|
+
element=element)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def main(argv=None) -> int:
|
|
506
|
+
"""Entry point. Returns an exit code so tests can call it directly.
|
|
507
|
+
|
|
508
|
+
With standalone_mode=False, Typer returns the command's exit code (from
|
|
509
|
+
typer.Exit) instead of calling sys.exit, and lets usage errors surface as
|
|
510
|
+
Click exceptions. We translate both into an int so `sys.exit(main())` and
|
|
511
|
+
direct test calls behave identically.
|
|
512
|
+
"""
|
|
513
|
+
# Typer vendors Click; there is no top-level `click` module to import.
|
|
514
|
+
from typer._click.exceptions import ClickException
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
result = app(args=argv, standalone_mode=False)
|
|
518
|
+
except ClickException as exc: # e.g. missing/unknown option
|
|
519
|
+
exc.show()
|
|
520
|
+
return exc.exit_code
|
|
521
|
+
except typer.Abort:
|
|
522
|
+
typer.echo("Afgebroken.", err=True)
|
|
523
|
+
return 1
|
|
524
|
+
return int(result or 0)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
sys.exit(main())
|
archi_tool/discovery.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Locate the model file and the conventions list without assuming this repo.
|
|
2
|
+
|
|
3
|
+
The CLI used to hardcode ``models/ado.archimate`` and find the conventions at
|
|
4
|
+
``<model>/../../docs/conventies.md``. Both assumptions are specific to this
|
|
5
|
+
repository. Discovery makes ``archi`` work on any model, in any layout, while
|
|
6
|
+
keeping the ADO defaults working through an ``archi.toml`` in the repo root.
|
|
7
|
+
|
|
8
|
+
Resolution order for the model:
|
|
9
|
+
1. an explicit ``--model`` path (handled by the caller)
|
|
10
|
+
2. ``[tool.archi] model`` in ``archi.toml`` / ``pyproject.toml``, searched
|
|
11
|
+
upward from the working directory
|
|
12
|
+
3. the single ``.archimate`` file in the working directory
|
|
13
|
+
|
|
14
|
+
Resolution order for the conventions list:
|
|
15
|
+
1. ``[tool.archi] conventions`` in the config file
|
|
16
|
+
2. a ``conventies.md`` / ``conventions.md`` next to the model (legacy layout)
|
|
17
|
+
3. the built-in default set, so the property-key check stays meaningful even
|
|
18
|
+
without a project list
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import tomllib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .model import ModelError
|
|
26
|
+
|
|
27
|
+
CONFIG_NAMES = ("archi.toml", "pyproject.toml")
|
|
28
|
+
CONVENTION_NEIGHBOURS = ("conventies.md", "conventions.md")
|
|
29
|
+
|
|
30
|
+
# Keys that ship with the tool. A project can override this by pointing at its
|
|
31
|
+
# own conventions doc; without one, these keep the property-key check useful.
|
|
32
|
+
# Mirrors docs/conventies.md §3 so behaviour is identical for this repo when no
|
|
33
|
+
# doc is found (it normally is, via archi.toml).
|
|
34
|
+
DEFAULT_PROPERTY_KEYS = {
|
|
35
|
+
"Omschrijving",
|
|
36
|
+
"Toelichting",
|
|
37
|
+
"Bron",
|
|
38
|
+
"Capability-niveau",
|
|
39
|
+
"Niveau (herkomst)",
|
|
40
|
+
"Driver-categorie",
|
|
41
|
+
"constraint-type",
|
|
42
|
+
"Artefact-type",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_archi_config(start):
|
|
47
|
+
"""Return ``([tool.archi]`` table, config file path) searching upward.
|
|
48
|
+
|
|
49
|
+
Looks in the working directory and its parents for the first config file
|
|
50
|
+
that carries a ``[tool.archi]`` table. Returns ``({}, None)`` when none is
|
|
51
|
+
found, so callers can fall back to filesystem discovery.
|
|
52
|
+
"""
|
|
53
|
+
for directory in (start, *start.parents):
|
|
54
|
+
for name in CONFIG_NAMES:
|
|
55
|
+
candidate = directory / name
|
|
56
|
+
if not candidate.exists():
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
data = tomllib.loads(candidate.read_text(encoding="utf-8"))
|
|
60
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
61
|
+
continue
|
|
62
|
+
table = data.get("tool", {}).get("archi")
|
|
63
|
+
if isinstance(table, dict):
|
|
64
|
+
return table, candidate
|
|
65
|
+
return {}, None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def discover_model(explicit=None, start=None):
|
|
69
|
+
"""Resolve the model path. Raise ModelError with guidance when ambiguous.
|
|
70
|
+
|
|
71
|
+
``explicit`` wins when given. Otherwise consult ``[tool.archi] model`` in a
|
|
72
|
+
config file, then fall back to the single ``.archimate`` in ``start``.
|
|
73
|
+
"""
|
|
74
|
+
if explicit:
|
|
75
|
+
path = Path(explicit)
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise ModelError(f"Modelbestand niet gevonden: {path}")
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
start = Path(start or Path.cwd())
|
|
81
|
+
table, config_path = _read_archi_config(start)
|
|
82
|
+
if "model" in table:
|
|
83
|
+
# a relative path in config is relative to the config file's directory
|
|
84
|
+
path = (config_path.parent / table["model"]).resolve()
|
|
85
|
+
if not path.exists():
|
|
86
|
+
raise ModelError(
|
|
87
|
+
f"Modelpad uit {config_path.name} bestaat niet: {path}")
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
candidates = sorted(start.glob("*.archimate"))
|
|
91
|
+
if len(candidates) == 1:
|
|
92
|
+
return candidates[0]
|
|
93
|
+
if not candidates:
|
|
94
|
+
raise ModelError(
|
|
95
|
+
"Geen modelbestand gevonden. Geef --model <pad>, of zet "
|
|
96
|
+
"`[tool.archi] model = \"...\"` in archi.toml, of draai in een map "
|
|
97
|
+
"met precies één .archimate-bestand.")
|
|
98
|
+
names = ", ".join(c.name for c in candidates)
|
|
99
|
+
raise ModelError(
|
|
100
|
+
f"Meerdere .archimate-bestanden gevonden ({names}). Kies er één met "
|
|
101
|
+
"--model <pad> of leg het vast in archi.toml.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def discover_conventions(model_path, start=None):
|
|
105
|
+
"""Return (allowed_property_keys, source_label).
|
|
106
|
+
|
|
107
|
+
``source_label`` names where the keys came from, for diagnostics. Falls
|
|
108
|
+
back to the built-in default set so the check never silently disappears.
|
|
109
|
+
"""
|
|
110
|
+
from .validate import allowed_property_keys
|
|
111
|
+
|
|
112
|
+
start = Path(start or Path.cwd())
|
|
113
|
+
table, config_path = _read_archi_config(start)
|
|
114
|
+
if "conventions" in table:
|
|
115
|
+
# an explicit path in config is authoritative: a missing file or one
|
|
116
|
+
# without a Property-keys section is a config error, not a reason to
|
|
117
|
+
# silently fall back to the built-in set
|
|
118
|
+
path = (config_path.parent / table["conventions"]).resolve()
|
|
119
|
+
if not path.exists():
|
|
120
|
+
raise ModelError(
|
|
121
|
+
f"Conventiepad uit {config_path.name} bestaat niet: {path}")
|
|
122
|
+
keys = allowed_property_keys(path)
|
|
123
|
+
if not keys:
|
|
124
|
+
raise ModelError(
|
|
125
|
+
f"Conventiebestand {path} bevat geen Property-keys-sectie.")
|
|
126
|
+
return keys, str(path)
|
|
127
|
+
|
|
128
|
+
model_dir = Path(model_path).resolve().parent
|
|
129
|
+
for directory in (model_dir, *model_dir.parents):
|
|
130
|
+
for name in CONVENTION_NEIGHBOURS:
|
|
131
|
+
candidate = directory / "docs" / name
|
|
132
|
+
if candidate.exists():
|
|
133
|
+
keys = allowed_property_keys(candidate)
|
|
134
|
+
if keys:
|
|
135
|
+
return keys, str(candidate)
|
|
136
|
+
|
|
137
|
+
return set(DEFAULT_PROPERTY_KEYS), "ingebouwde standaardset"
|