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_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())
@@ -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"