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 +22 -0
- arctyp/__main__.py +8 -0
- arctyp/cli.py +40 -0
- arctyp/commands/__init__.py +35 -0
- arctyp/commands/_shared.py +24 -0
- arctyp/commands/compile.py +29 -0
- arctyp/commands/doctor.py +107 -0
- arctyp/commands/init.py +211 -0
- arctyp/commands/uml.py +38 -0
- arctyp/commands/watch.py +144 -0
- arctyp/decorators.py +117 -0
- arctyp/errors.py +18 -0
- arctyp/http.py +36 -0
- arctyp/output.py +56 -0
- arctyp/parser.py +117 -0
- arctyp/pipeline.py +66 -0
- arctyp/plantuml.py +158 -0
- arctyp/project.py +84 -0
- arctyp-0.1.2.dist-info/METADATA +185 -0
- arctyp-0.1.2.dist-info/RECORD +22 -0
- arctyp-0.1.2.dist-info/WHEEL +4 -0
- arctyp-0.1.2.dist-info/entry_points.txt +2 -0
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
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"
|
arctyp/commands/init.py
ADDED
|
@@ -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
|
arctyp/commands/watch.py
ADDED
|
@@ -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()
|