arctyp 0.1.2__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.
arctyp/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """arctyp — orchestration CLI for Typst composition at HE-Arc.
2
+
3
+ Renders UML diagrams via the internal PlantUML server and compiles the PDF
4
+ locally.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as _package_version
9
+
10
+ APP_NAME = "arctyp"
11
+
12
+ try:
13
+ # Single source of truth: [project].version in pyproject.toml, read from
14
+ # the installed package's metadata (never duplicated in code).
15
+ __version__: str = _package_version(APP_NAME)
16
+ except PackageNotFoundError: # running from an uninstalled source tree
17
+ __version__ = "0.0.0+unknown"
18
+
19
+
20
+ def user_agent() -> str:
21
+ """Shared User-Agent for the CLI's HTTP requests (name + version, single source)."""
22
+ return f"{APP_NAME}/{__version__} (HE-Arc)"
arctyp/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Support for `python -m arctyp`."""
2
+
3
+ import sys
4
+
5
+ from arctyp.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
arctyp/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ """Entry point of the `arctyp` CLI: parsing, dispatch, and exit codes.
2
+
3
+ Command structure is declared in `arctyp.commands` (decorators), and the
4
+ argparse tree is built by `arctyp.parser`. This module only does the core
5
+ work: call the handler, map errors to exit codes, and never let a traceback
6
+ reach the user.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import signal
12
+ import sys
13
+
14
+ from arctyp.errors import ArctypError
15
+ from arctyp.output import FAIL
16
+ from arctyp.parser import build_parser
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ # Closed pipe (e.g. `arctyp doctor | head`): classic Unix behavior —
21
+ # die silently instead of letting a BrokenPipeError pollute the output.
22
+ if hasattr(signal, "SIGPIPE"):
23
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
24
+
25
+ args = build_parser().parse_args(argv)
26
+ try:
27
+ return args.func(args)
28
+ except ArctypError as exc:
29
+ print(f"{FAIL} {exc}", file=sys.stderr)
30
+ return 1
31
+ except KeyboardInterrupt:
32
+ print(f"\n{FAIL} interrompu.", file=sys.stderr)
33
+ return 130
34
+ except Exception as exc: # safety net: never output a traceback
35
+ print(f"{FAIL} erreur inattendue — {type(exc).__name__}: {exc}", file=sys.stderr)
36
+ return 1
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ """Subcommands of the arctyp CLI.
2
+
3
+ Automatically discovers and imports every module in this package: each one
4
+ declares its commands with the decorators from `arctyp.decorators`. The
5
+ ``COMMANDS`` registry, filled by these imports, is re-exported here and
6
+ consumed by `arctyp.parser.build_parser`. Display order in the help follows
7
+ the alphabetical order of the modules.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import pkgutil
14
+
15
+ from arctyp.decorators import COMMANDS
16
+
17
+
18
+ def _load_command_modules() -> None:
19
+ """Imports each command module in the package (alphabetical order).
20
+
21
+ The import is the trigger: it runs the decorators and fills the
22
+ ``COMMANDS`` registry. Modules prefixed with ``_`` are reserved for
23
+ internal helpers and declare no commands.
24
+ """
25
+ modules = sorted(
26
+ info.name for info in pkgutil.iter_modules(__path__)
27
+ if not info.name.startswith("_")
28
+ )
29
+ for module in modules:
30
+ importlib.import_module(f"{__name__}.{module}")
31
+
32
+
33
+ _load_command_modules()
34
+
35
+ __all__ = ["COMMANDS"]
@@ -0,0 +1,24 @@
1
+ """Argument declarations shared by several commands.
2
+
3
+ Single source of the options and positionals declared identically by more
4
+ than one command (DRY). Underscore-prefixed module: skipped by the commands'
5
+ auto-discovery, which only registers decorated handlers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from arctyp.decorators import argument
11
+
12
+ # One decorator instance reused on several handlers is safe: `argument()`
13
+ # appends to each decorated function's own attribute, with no shared state.
14
+ PUBLIC_OPTION = argument(
15
+ "--public",
16
+ action="store_true",
17
+ help="utilise le serveur PlantUML public au lieu du serveur de l'école",
18
+ )
19
+
20
+ PDF_ARGUMENT = argument(
21
+ "pdf",
22
+ metavar="rapport.pdf",
23
+ help="nom complet du PDF de sortie (obligatoire)",
24
+ )
@@ -0,0 +1,29 @@
1
+ """`arctyp compile <rapport.pdf>`: renders the diagrams, then compiles the Typst entry.
2
+
3
+ Thin handler: the compilation pipeline lives in `arctyp.pipeline`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from arctyp.commands._shared import PDF_ARGUMENT, PUBLIC_OPTION
11
+ from arctyp.decorators import command
12
+ from arctyp.output import ok
13
+ from arctyp.pipeline import compile_typst, render_diagrams
14
+ from arctyp.project import project_root
15
+
16
+
17
+ @command(
18
+ name="compile",
19
+ help="rend les diagrammes du projet puis compile l'entrée Typst en PDF",
20
+ description="Le nom complet du PDF de sortie est un argument obligatoire.",
21
+ )
22
+ @PDF_ARGUMENT
23
+ @PUBLIC_OPTION
24
+ def cmd_compile(args) -> int:
25
+ project = project_root()
26
+ render_diagrams(project, public=args.public)
27
+ compile_typst(project, Path(args.pdf))
28
+ ok(f"PDF généré : {args.pdf}")
29
+ return 0
@@ -0,0 +1,107 @@
1
+ """`arctyp doctor`: diagnoses the project and the toolchain's reachability.
2
+
3
+ Checks, in pipeline order: the project, the PlantUML server, UML diagram
4
+ rendering, the Typst compiler (Python package), then the Git binary (needed
5
+ by `arctyp init`). Each line is prefixed [OK] or [FAIL] and printed as soon
6
+ as its check finishes; the exit code is 0 when everything is OK, 1 as soon
7
+ as a check fails.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import shutil
14
+ from functools import partial
15
+
16
+ from arctyp.commands._shared import PUBLIC_OPTION
17
+ from arctyp.decorators import command
18
+ from arctyp.errors import ArctypError
19
+ from arctyp.output import fail, ok
20
+ from arctyp.plantuml import render_project_diagrams, render_svg
21
+ from arctyp.project import ENTRY_FILE, find_config, project_root
22
+
23
+ # Each check returns (ok, label, detail).
24
+ Check = tuple[bool, str, str]
25
+
26
+
27
+ @command(
28
+ name="doctor",
29
+ help="diagnostique configuration, typst et accessibilité des serveurs",
30
+ description=(
31
+ "Vérifie le projet, le serveur PlantUML, rend les diagrammes UML, puis "
32
+ "contrôle le compilateur Typst et la présence de Git."
33
+ ),
34
+ )
35
+ @PUBLIC_OPTION
36
+ def cmd_doctor(args) -> int:
37
+ all_ok = True
38
+ for check in (
39
+ _check_project,
40
+ partial(_check_plantuml, public=args.public),
41
+ partial(_check_diagrams, public=args.public),
42
+ _check_typst,
43
+ _check_git,
44
+ ):
45
+ check_ok, label, detail = check()
46
+ (ok if check_ok else fail)(f"{label} : {detail}")
47
+ all_ok = all_ok and check_ok
48
+
49
+ # Error recovery (Nielsen H9): clear exit action on failure.
50
+ if not all_ok:
51
+ fail("Des contrôles ont échoué — corrigez puis relancez `arctyp doctor`.")
52
+ return 0 if all_ok else 1
53
+
54
+
55
+ def _check_project() -> Check:
56
+ root = project_root()
57
+ config = find_config(root)
58
+ entry = root / ENTRY_FILE
59
+ ok = entry.is_file()
60
+ detail = (
61
+ f"config {config or 'absente (défauts utilisés)'} ; "
62
+ f"entrée {ENTRY_FILE} {'présente' if ok else 'ABSENTE'}"
63
+ )
64
+ return ok, "Projet", detail
65
+
66
+
67
+ def _check_plantuml(public: bool = False) -> Check:
68
+ try:
69
+ render_svg("@startuml\n@enduml", public=public)
70
+ except ArctypError as exc:
71
+ return False, "Serveur PlantUML", str(exc)
72
+ return True, "Serveur PlantUML", "joignable"
73
+
74
+
75
+ def _check_diagrams(public: bool = False) -> Check:
76
+ """Renders each .puml in the project (first step of the compile pipeline)."""
77
+ outcomes = render_project_diagrams(project_root(), public=public)
78
+ if not outcomes:
79
+ return True, "Diagrammes UML", "aucun fichier .puml (rien à rendre)"
80
+ failures = [outcome for outcome in outcomes if outcome.error]
81
+ if failures:
82
+ first = failures[0]
83
+ return (
84
+ False,
85
+ "Diagrammes UML",
86
+ f"{len(failures)}/{len(outcomes)} échec(s) — {first.puml}: {first.error}",
87
+ )
88
+ return True, "Diagrammes UML", f"{len(outcomes)} diagramme(s) rendu(s)"
89
+
90
+
91
+ def _check_typst() -> Check:
92
+ try:
93
+ module = importlib.import_module("typst")
94
+ except ImportError as exc:
95
+ return False, "Compilateur Typst", f"paquet manquant ({exc}) — lancez uv sync"
96
+ return True, "Compilateur Typst", f"paquet importable (v{getattr(module, '__version__', '?')})"
97
+
98
+
99
+ def _check_git() -> Check:
100
+ """Git is required by `arctyp init` (repository and templates managed with Git)."""
101
+ if shutil.which("git") is None:
102
+ return (
103
+ False,
104
+ "Git",
105
+ "binaire introuvable — installez Git (nécessaire pour `arctyp init`)",
106
+ )
107
+ return True, "Git", "binaire présent"
@@ -0,0 +1,211 @@
1
+ """`arctyp init [directory]`: creates a complete base project.
2
+
3
+ Creates a ready-to-use Typst project: conf.typ, template.typ, uml.typ,
4
+ main.typ, diagrams/ and images/ folders, and an initialized Git repository.
5
+ Official templates are fetched directly with Git (`git clone` of the repo) —
6
+ the CLI does not manage templates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from arctyp.decorators import argument, command
15
+ from arctyp.errors import ArctypError
16
+ from arctyp.output import display_path, ok, steps
17
+ from arctyp.project import CONFIG_FILE, project_config_content
18
+
19
+ BASE_MAIN_TYP = """#import "conf.typ": conf
20
+ #import "template.typ": template
21
+ #import "uml.typ": uml
22
+
23
+ #show: template.with(..conf)
24
+
25
+ = Introduction
26
+
27
+ Écrivez ici le contenu de votre document.
28
+
29
+ // Exemple de diagramme UML (rendu par `arctyp` avant la compilation) :
30
+ // #figure(uml("diagrams/schema.puml"), caption: [Légende])
31
+ """
32
+
33
+ BASE_CONF_TYP = """// conf.typ — Paramètres du document, à modifier.
34
+ #let conf = (
35
+ title: [Titre du document],
36
+ author: [Prénom Nom],
37
+ )
38
+ """
39
+
40
+ BASE_TEMPLATE_TYP = """// template.typ — Mise en page minimale du document.
41
+ #let template(title: none, author: none, body) = {
42
+ set text(lang: "fr", size: 11pt)
43
+ set page(paper: "a4", margin: 2.5cm, numbering: "1")
44
+ set par(justify: true)
45
+
46
+ if title != none {
47
+ align(center)[#text(20pt, weight: "bold", title)]
48
+ }
49
+ if author != none {
50
+ align(center)[#author]
51
+ }
52
+ v(1cm)
53
+ body
54
+ }
55
+ """
56
+
57
+ BASE_UML_TYP = """// uml.typ — Intégration des diagrammes PlantUML dans le document.
58
+ //
59
+ // # `uml(path, ..args)`
60
+ //
61
+ // Charge le SVG (ou PNG) d'un diagramme PlantUML dans le document.
62
+ //
63
+ // **Convention** : chaque fichier `foo.puml` doit avoir été rendu à côté de
64
+ // lui par la CLI `arctyp` **avant** la compilation (`foo.svg`, ou `foo.png`
65
+ // si `[plantuml] format = "png"` dans l'arctyp.toml). Cette fonction ne fait
66
+ // aucune requête réseau — Typst interdit tout accès au réseau au moment de la
67
+ // compilation — elle résout simplement le chemin du fichier rendu et le
68
+ // charge comme une image.
69
+ //
70
+ // **Arguments** : `path` est le chemin du fichier `.puml`, relatif à la racine
71
+ // du projet. Tous les autres arguments sont transmis tels quels à la fonction
72
+ // `image` (mêmes arguments, mêmes valeurs par défaut) : `width`, `height`,
73
+ // `fit`, `alt`, `kind`, etc.
74
+ //
75
+ // **Exemple** :
76
+ //
77
+ // #figure(
78
+ // uml("diagrams/schema.puml", width: 100%, alt: "Schéma"),
79
+ // caption: [Légende],
80
+ // )
81
+
82
+ #let uml(path, ..args) = {
83
+ // Le format vient de l'arctyp.toml du projet ([plantuml] format) :
84
+ // "svg" par défaut, "png" pour un rendu PNG.
85
+ let conf = toml("arctyp.toml")
86
+ let format = conf.at("plantuml", default: (:)).at("format", default: "svg")
87
+ let ext = if format == "png" { ".png" } else { ".svg" }
88
+
89
+ // foo.puml -> foo.svg (ou foo.png), rendu par `arctyp` en amont.
90
+ // Le chemin est résolu par `image` relativement à ce fichier, donc à la
91
+ // racine du projet : d'où la convention « chemins relatifs à la racine ».
92
+ let output = path.replace(".puml", ext)
93
+ image(output, ..args)
94
+ }
95
+ """
96
+
97
+ BASE_GITIGNORE = """# PDF générés par `arctyp compile`
98
+ /*.pdf
99
+ """
100
+
101
+
102
+ @command(
103
+ name="init",
104
+ help="initialise un projet de base complet avec dépôt Git",
105
+ description=(
106
+ "Crée un projet Typst prêt à l'emploi (conf.typ, template.typ, "
107
+ "uml.typ, main.typ, diagrams/, images/) et initialise un dépôt Git. "
108
+ "Les templates officiels se récupèrent avec `git clone`."
109
+ ),
110
+ )
111
+ @argument(
112
+ "target",
113
+ nargs="?",
114
+ default=".",
115
+ metavar="répertoire",
116
+ help="répertoire de destination (défaut : répertoire courant)",
117
+ )
118
+ @argument(
119
+ "--force",
120
+ "-f",
121
+ action="store_true",
122
+ help="écrase les fichiers existants d'un répertoire non vide, sans demander",
123
+ )
124
+ def cmd_init(args) -> int:
125
+ target = Path(args.target or ".").resolve()
126
+ target_existed = target.exists() and any(target.iterdir())
127
+ if target_existed and not args.force:
128
+ raise ArctypError(
129
+ f"répertoire cible non vide ({display_path(target)})\n"
130
+ " passez --force pour écraser les fichiers existants."
131
+ )
132
+ target.mkdir(parents=True, exist_ok=True)
133
+ existing_before = (
134
+ {path.resolve() for path in target.rglob("*") if path.is_file()} if target_existed else set()
135
+ )
136
+
137
+ written = _write_base_project(target)
138
+ ok(f"Projet de base créé dans {display_path(target)}")
139
+ if target_existed and written:
140
+ files = [
141
+ f"{display_path(path)} ({'écrasé' if path.resolve() in existing_before else 'créé'})"
142
+ for path in written
143
+ ]
144
+ steps("Fichiers écrits :", files)
145
+ _print_next_steps(target)
146
+ return 0
147
+
148
+
149
+ def _write_base_project(target: Path) -> list[Path]:
150
+ """Writes a new project's base files, then initializes Git.
151
+
152
+ Returns only the files actually written (created or with changed content)
153
+ — an already-identical file is not counted as overwritten.
154
+ """
155
+ written: list[Path] = []
156
+ for name, content in (
157
+ ("main.typ", BASE_MAIN_TYP),
158
+ ("conf.typ", BASE_CONF_TYP),
159
+ ("template.typ", BASE_TEMPLATE_TYP),
160
+ ("uml.typ", BASE_UML_TYP),
161
+ (".gitignore", BASE_GITIGNORE),
162
+ ):
163
+ path = target / name
164
+ if _write_if_changed(path, content):
165
+ written.append(path)
166
+ for folder in ("diagrams", "images"):
167
+ keep = target / folder / ".gitkeep"
168
+ keep.parent.mkdir(parents=True, exist_ok=True)
169
+ if _write_if_changed(keep, ""):
170
+ written.append(keep)
171
+
172
+ config_path = target / CONFIG_FILE
173
+ if _write_if_changed(config_path, project_config_content()):
174
+ written.append(config_path)
175
+
176
+ _git_init(target)
177
+ return written
178
+
179
+
180
+ def _write_if_changed(path: Path, content: str) -> bool:
181
+ """Writes `content` only if it differs; True if the file was written."""
182
+ if path.is_file() and path.read_text(encoding="utf-8") == content:
183
+ return False
184
+ path.write_text(content, encoding="utf-8")
185
+ return True
186
+
187
+
188
+ def _git_init(target: Path) -> None:
189
+ """Initializes a Git repository in the project (best effort, message on failure)."""
190
+ try:
191
+ proc = subprocess.run(
192
+ ["git", "init", "-q"], cwd=target, capture_output=True, text=True
193
+ )
194
+ except FileNotFoundError as exc:
195
+ raise ArctypError(
196
+ "git introuvable dans le PATH — initialisez le dépôt manuellement (git init)."
197
+ ) from exc
198
+ if proc.returncode != 0:
199
+ raise ArctypError(f"échec de git init : {(proc.stderr or '').strip()}")
200
+ ok("Dépôt Git initialisé")
201
+
202
+
203
+ def _print_next_steps(target: Path) -> None:
204
+ """Prints the next steps (cd only if the project is not here).
205
+
206
+ The last line is the bare command, for direct copy-paste.
207
+ """
208
+ next_steps = ["éditez main.typ", "arctyp watch rapport.pdf"]
209
+ if target != Path.cwd().resolve():
210
+ next_steps.insert(0, f"cd {display_path(target)}")
211
+ steps("Prochaines étapes :", next_steps)
arctyp/commands/uml.py ADDED
@@ -0,0 +1,38 @@
1
+ """`arctyp uml <file.puml>`: renders a UML diagram to SVG via the PlantUML server.
2
+
3
+ The SVG is written next to the `.puml` file (`foo.puml` -> `foo.svg`), which
4
+ lets the templates' `uml()` function load it at compile time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from arctyp.commands._shared import PUBLIC_OPTION
12
+ from arctyp.decorators import argument, command
13
+ from arctyp.errors import ArctypError
14
+ from arctyp.output import display_path, ok
15
+ from arctyp.plantuml import render_file
16
+
17
+
18
+ @command(
19
+ name="uml",
20
+ help="rend un diagramme UML en SVG via le serveur PlantUML",
21
+ description=(
22
+ "Envoie le fichier .puml au serveur PlantUML et écrit le SVG rendu "
23
+ "à côté du fichier (foo.puml -> foo.svg)."
24
+ ),
25
+ )
26
+ @argument(
27
+ "file",
28
+ metavar="fichier.puml",
29
+ help="chemin vers le fichier .puml",
30
+ )
31
+ @PUBLIC_OPTION
32
+ def cmd_uml(args) -> int:
33
+ puml = Path(args.file)
34
+ if not puml.is_file():
35
+ raise ArctypError(f"fichier introuvable : {args.file}")
36
+ svg = render_file(puml, public=args.public)
37
+ ok(f"SVG rendu : {display_path(svg)}")
38
+ return 0
@@ -0,0 +1,144 @@
1
+ """`arctyp watch <report.pdf>`: recompiles on every project change.
2
+
3
+ Watches the project with `watchdog` (filesystem events, instant, no polling).
4
+ Compiles once, then recompiles once per burst of changes (debounce) —
5
+ Ctrl-C stops (code 130).
6
+
7
+ Generated paths are excluded to avoid loops: the output PDF, `.git`, caches,
8
+ and the `.svg`/`.png` files rendered next to the `.puml` files.
9
+
10
+ `watchdog` is imported here *on demand*: loading it costs ~400 ms (system
11
+ backends), which would be too expensive on every CLI call.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from arctyp.commands._shared import PDF_ARGUMENT, PUBLIC_OPTION
21
+ from arctyp.decorators import command
22
+ from arctyp.output import info, ok
23
+ from arctyp.pipeline import compile_typst, render_diagrams
24
+ from arctyp.plantuml import find_puml_files
25
+ from arctyp.project import project_root
26
+
27
+ DEBOUNCE_SECONDS = 0.5 # silence before recompiling after a burst
28
+
29
+ # Folders whose events must never trigger a recompile.
30
+ _IGNORED_DIRS = frozenset({".git", "__pycache__", ".venv", ".arctyp"})
31
+
32
+
33
+ @command(
34
+ name="watch",
35
+ help="recompile automatiquement à chaque modification du projet (Ctrl-C pour arrêter)",
36
+ description=(
37
+ "Compile une première fois (nom du PDF obligatoire), puis recompile "
38
+ "à chaque changement de fichier du projet."
39
+ ),
40
+ )
41
+ @PDF_ARGUMENT
42
+ @PUBLIC_OPTION
43
+ def cmd_watch(args) -> int:
44
+ from watchdog.events import FileSystemEventHandler
45
+ from watchdog.observers import Observer
46
+
47
+ project = project_root().resolve()
48
+ output = Path(args.pdf).resolve()
49
+
50
+ _compile(project, output, public=args.public)
51
+ generated = _generated_outputs(project)
52
+ debouncer = _Debouncer(
53
+ DEBOUNCE_SECONDS, _recompile(project, output, public=args.public)
54
+ )
55
+
56
+ class _Handler(FileSystemEventHandler):
57
+ def on_any_event(self, event) -> None:
58
+ rel = _relevant_path(event, project, output, generated)
59
+ if rel is not None:
60
+ debouncer.bump(str(rel))
61
+
62
+ observer = Observer()
63
+ observer.schedule(_Handler(), str(project), recursive=True)
64
+ observer.start()
65
+ info("Surveillance du projet (Ctrl-C pour arrêter)…")
66
+ try:
67
+ while True:
68
+ time.sleep(1)
69
+ except KeyboardInterrupt:
70
+ pass
71
+ finally:
72
+ observer.stop()
73
+ observer.join()
74
+ info("Surveillance arrêtée.")
75
+ return 130
76
+
77
+
78
+ def _recompile(project: Path, output: Path, public: bool = False):
79
+ """Returns the recompilation function (with its own message)."""
80
+ def recompile(detail: str | None = None) -> None:
81
+ message = f"Fichier modifié : {detail} — recompilation…" if detail else "Modification détectée — recompilation…"
82
+ info(message)
83
+ _compile(project, output, public=public)
84
+
85
+ return recompile
86
+
87
+
88
+ def _compile(project: Path, output: Path, public: bool = False) -> None:
89
+ """The same pipeline as `arctyp compile`: diagrams, then Typst."""
90
+ render_diagrams(project, public=public)
91
+ compile_typst(project, output)
92
+ ok(f"PDF généré : {output.name}")
93
+
94
+
95
+ def _generated_outputs(project: Path) -> set[Path]:
96
+ """The .svg/.png rendered next to the .puml files: regenerated on every compile."""
97
+ outputs = set()
98
+ for puml in find_puml_files(project):
99
+ outputs.add(puml.with_suffix(".svg").resolve())
100
+ outputs.add(puml.with_suffix(".png").resolve())
101
+ return outputs
102
+
103
+
104
+ def _relevant_path(
105
+ event,
106
+ project: Path,
107
+ output: Path,
108
+ generated: set[Path],
109
+ ) -> Path | None:
110
+ """Relative path if the event should recompile, None otherwise."""
111
+ if event.is_directory or event.event_type in ("opened", "closed"):
112
+ return None
113
+ path = Path(event.src_path).resolve()
114
+ try:
115
+ rel = path.relative_to(project)
116
+ except ValueError:
117
+ return None # outside the project: ignored
118
+ if any(part in _IGNORED_DIRS for part in rel.parts):
119
+ return None
120
+ if path == output or path in generated:
121
+ return None
122
+ return rel
123
+
124
+
125
+ class _Debouncer:
126
+ """Triggers `callback(detail)` once after `delay` seconds of silence.
127
+
128
+ Each `bump()` cancels the previous timer: a burst of events (an editor's
129
+ atomic save) triggers only one recompilation.
130
+ """
131
+
132
+ def __init__(self, delay: float, callback) -> None:
133
+ self._delay = delay
134
+ self._callback = callback
135
+ self._timer: threading.Timer | None = None
136
+ self._lock = threading.Lock()
137
+
138
+ def bump(self, detail: str | None = None) -> None:
139
+ with self._lock:
140
+ if self._timer is not None:
141
+ self._timer.cancel()
142
+ self._timer = threading.Timer(self._delay, lambda: self._callback(detail))
143
+ self._timer.daemon = True
144
+ self._timer.start()