appflows 1.0.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.
- appflows/__init__.py +94 -0
- appflows/cli.py +237 -0
- appflows/config.py +533 -0
- appflows/formats.py +574 -0
- appflows/generator.py +934 -0
- appflows/layout.py +845 -0
- appflows/logtext.py +24 -0
- appflows/models.py +307 -0
- appflows/py.typed +0 -0
- appflows/rows.py +207 -0
- appflows/sides.py +189 -0
- appflows/svg.py +494 -0
- appflows/ui/__init__.py +10 -0
- appflows/ui/app.py +346 -0
- appflows/ui/server.py +101 -0
- appflows/ui/service.py +226 -0
- appflows/ui/static/editor.js +646 -0
- appflows/ui/static/index.html +82 -0
- appflows/ui/static/style.css +333 -0
- appflows-1.0.0.dist-info/METADATA +211 -0
- appflows-1.0.0.dist-info/RECORD +24 -0
- appflows-1.0.0.dist-info/WHEEL +4 -0
- appflows-1.0.0.dist-info/entry_points.txt +3 -0
- appflows-1.0.0.dist-info/licenses/LICENSE +21 -0
appflows/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Génération de schémas d'architecture SVG à partir de définitions YAML/JSON.
|
|
2
|
+
|
|
3
|
+
Exemple minimal :
|
|
4
|
+
|
|
5
|
+
>>> from appflows import Generator
|
|
6
|
+
>>> g = Generator()
|
|
7
|
+
>>> g.load_data("examples/yaml/bus.yaml")
|
|
8
|
+
True
|
|
9
|
+
>>> g.calculate_positions()
|
|
10
|
+
>>> svg = g.graph()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from appflows.config import (
|
|
18
|
+
MAIN_APP_CODE,
|
|
19
|
+
AppConfig,
|
|
20
|
+
BusConfig,
|
|
21
|
+
Colors,
|
|
22
|
+
ConnectorConfig,
|
|
23
|
+
FlowConfig,
|
|
24
|
+
GraphConfig,
|
|
25
|
+
MainConfig,
|
|
26
|
+
PageConfig,
|
|
27
|
+
Size,
|
|
28
|
+
TagConfig,
|
|
29
|
+
)
|
|
30
|
+
from appflows.formats import (
|
|
31
|
+
BACKWARD_FLOW,
|
|
32
|
+
FORWARD_FLOW,
|
|
33
|
+
load_file,
|
|
34
|
+
parse_chain,
|
|
35
|
+
parse_flow,
|
|
36
|
+
parse_flows_def,
|
|
37
|
+
split_endpoints,
|
|
38
|
+
)
|
|
39
|
+
from appflows.generator import Generator
|
|
40
|
+
from appflows.models import (
|
|
41
|
+
Application,
|
|
42
|
+
Bus,
|
|
43
|
+
Chain,
|
|
44
|
+
Connector,
|
|
45
|
+
DiagramType,
|
|
46
|
+
Flow,
|
|
47
|
+
LayoutMode,
|
|
48
|
+
Link,
|
|
49
|
+
Position,
|
|
50
|
+
Segment,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
__version__ = version("appflows")
|
|
55
|
+
except PackageNotFoundError: # paquet non installé (exécution depuis les sources)
|
|
56
|
+
__version__ = "0.0.0.dev0"
|
|
57
|
+
|
|
58
|
+
# Convention loguru pour une bibliothèque : ne rien écrire sur la sortie d'erreur
|
|
59
|
+
# de l'application appelante tant qu'elle ne l'a pas demandé. La CLI et l'UI
|
|
60
|
+
# rappellent `logger.enable("appflows")` ; un intégrateur fait de même.
|
|
61
|
+
logger.disable(__name__)
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"BACKWARD_FLOW",
|
|
65
|
+
"FORWARD_FLOW",
|
|
66
|
+
"MAIN_APP_CODE",
|
|
67
|
+
"AppConfig",
|
|
68
|
+
"Application",
|
|
69
|
+
"Bus",
|
|
70
|
+
"BusConfig",
|
|
71
|
+
"Chain",
|
|
72
|
+
"Colors",
|
|
73
|
+
"Connector",
|
|
74
|
+
"ConnectorConfig",
|
|
75
|
+
"DiagramType",
|
|
76
|
+
"Flow",
|
|
77
|
+
"FlowConfig",
|
|
78
|
+
"Generator",
|
|
79
|
+
"GraphConfig",
|
|
80
|
+
"LayoutMode",
|
|
81
|
+
"Link",
|
|
82
|
+
"MainConfig",
|
|
83
|
+
"PageConfig",
|
|
84
|
+
"Position",
|
|
85
|
+
"Segment",
|
|
86
|
+
"Size",
|
|
87
|
+
"TagConfig",
|
|
88
|
+
"__version__",
|
|
89
|
+
"load_file",
|
|
90
|
+
"parse_chain",
|
|
91
|
+
"parse_flow",
|
|
92
|
+
"parse_flows_def",
|
|
93
|
+
"split_endpoints",
|
|
94
|
+
]
|
appflows/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Interface en ligne de commande : traitement par lot d'un répertoire.
|
|
2
|
+
|
|
3
|
+
Chaque fichier de définition trouvé dans le répertoire d'entrée est converti en
|
|
4
|
+
SVG, et en PNG si l'extra ``png`` est installé.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
from appflows.formats import SUPPORTED_SUFFIXES
|
|
16
|
+
from appflows.generator import Generator
|
|
17
|
+
|
|
18
|
+
LOG_FORMAT = (
|
|
19
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
|
20
|
+
"<level>{level: <8}</level> | <level>{message}</level>"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DiagramStatus(Enum):
|
|
25
|
+
"""Issue du traitement d'une définition.
|
|
26
|
+
|
|
27
|
+
Distinguer « ignoré » de « en échec » est ce qui permet au code de retour de
|
|
28
|
+
la commande d'être exploitable en intégration continue : un fichier déjà
|
|
29
|
+
présent n'est pas une erreur, une définition illisible en est une.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
PRODUCED = "produit"
|
|
33
|
+
SKIPPED = "ignoré"
|
|
34
|
+
FAILED = "en échec"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup_logging(verbose: bool = False, log_file: Path | None = None) -> None:
|
|
38
|
+
"""Configure loguru pour la CLI.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
verbose: active le niveau DEBUG sur la sortie d'erreur.
|
|
42
|
+
log_file: si fourni, les erreurs sont également écrites dans ce fichier.
|
|
43
|
+
Sans lui, rien n'est écrit sur disque.
|
|
44
|
+
"""
|
|
45
|
+
logger.enable("appflows") # la bibliothèque est muette par défaut
|
|
46
|
+
logger.remove()
|
|
47
|
+
logger.add(sys.stderr, format=LOG_FORMAT, level="DEBUG" if verbose else "INFO")
|
|
48
|
+
|
|
49
|
+
if log_file is not None:
|
|
50
|
+
logger.add(
|
|
51
|
+
log_file,
|
|
52
|
+
format=LOG_FORMAT,
|
|
53
|
+
level="ERROR",
|
|
54
|
+
rotation="1 week",
|
|
55
|
+
retention="1 month",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
60
|
+
"""Analyse et valide les arguments de la ligne de commande."""
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="appflows",
|
|
63
|
+
description=(
|
|
64
|
+
"Convertit par lot des définitions de flux applicatifs en schémas SVG (et PNG)."
|
|
65
|
+
),
|
|
66
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-i",
|
|
71
|
+
"--input-dir",
|
|
72
|
+
type=Path,
|
|
73
|
+
required=True,
|
|
74
|
+
help=f"Répertoire contenant les définitions ({', '.join(SUPPORTED_SUFFIXES)})",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"-o",
|
|
78
|
+
"--output-dir",
|
|
79
|
+
type=Path,
|
|
80
|
+
required=True,
|
|
81
|
+
help="Répertoire de destination des fichiers générés",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-c",
|
|
85
|
+
"--config",
|
|
86
|
+
type=str,
|
|
87
|
+
help="Nom du fichier de configuration situé dans le répertoire d'entrée",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--overwrite",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="Écraser les fichiers de sortie existants",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--no-png",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Ne produire que le SVG, sans conversion PNG",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--log-file",
|
|
101
|
+
type=Path,
|
|
102
|
+
help="Fichier où consigner les erreurs (aucun par défaut)",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Sortie détaillée")
|
|
105
|
+
|
|
106
|
+
args = parser.parse_args(argv)
|
|
107
|
+
|
|
108
|
+
if not args.input_dir.is_dir():
|
|
109
|
+
parser.error(f"Le répertoire d'entrée n'existe pas : {args.input_dir}")
|
|
110
|
+
|
|
111
|
+
if args.config:
|
|
112
|
+
config_path = args.input_dir / args.config
|
|
113
|
+
if not config_path.is_file():
|
|
114
|
+
parser.error(f"Le fichier de configuration n'existe pas : {config_path}")
|
|
115
|
+
|
|
116
|
+
return args
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def collect_input_files(input_dir: Path, config_file: Path | None = None) -> list[Path]:
|
|
120
|
+
"""Liste les définitions à traiter, en excluant le fichier de configuration."""
|
|
121
|
+
excluded = config_file.resolve() if config_file else None
|
|
122
|
+
return sorted(
|
|
123
|
+
f
|
|
124
|
+
for suffix in SUPPORTED_SUFFIXES
|
|
125
|
+
for f in input_dir.glob(f"*{suffix}")
|
|
126
|
+
if excluded is None or f.resolve() != excluded
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _write_png(svg_content: str, output_file: Path) -> bool:
|
|
131
|
+
"""Convertit le SVG en PNG. Retourne ``False`` si cairosvg est absent."""
|
|
132
|
+
try:
|
|
133
|
+
import cairosvg
|
|
134
|
+
except ImportError:
|
|
135
|
+
logger.warning(
|
|
136
|
+
"PNG non généré : cairosvg n'est pas installé. "
|
|
137
|
+
"Installez l'extra correspondant avec « pip install appflows[png] » "
|
|
138
|
+
"ou passez --no-png pour masquer cet avertissement."
|
|
139
|
+
)
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
cairosvg.svg2png(bytestring=svg_content.encode("utf-8"), write_to=str(output_file))
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def process_diagram(
|
|
147
|
+
input_file: Path,
|
|
148
|
+
output_dir: Path,
|
|
149
|
+
config_file: Path | None = None,
|
|
150
|
+
overwrite: bool = False,
|
|
151
|
+
png: bool = True,
|
|
152
|
+
) -> DiagramStatus:
|
|
153
|
+
"""Traite une définition et écrit le SVG (et le PNG) correspondant.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
L'issue du traitement : produit, ignoré ou en échec.
|
|
157
|
+
"""
|
|
158
|
+
output_svg_file = output_dir / input_file.with_suffix(".svg").name
|
|
159
|
+
output_png_file = output_dir / input_file.with_suffix(".png").name
|
|
160
|
+
|
|
161
|
+
if output_svg_file.exists() and not overwrite:
|
|
162
|
+
logger.debug(f"{input_file.name} ignoré (le SVG existe déjà)")
|
|
163
|
+
return DiagramStatus.SKIPPED
|
|
164
|
+
|
|
165
|
+
logger.debug(f"Traitement de {input_file.name}")
|
|
166
|
+
|
|
167
|
+
generator = Generator()
|
|
168
|
+
if not generator.load_data(input_file):
|
|
169
|
+
logger.warning(f"{input_file.name} écarté : définition illisible")
|
|
170
|
+
return DiagramStatus.FAILED
|
|
171
|
+
|
|
172
|
+
if config_file:
|
|
173
|
+
generator.load_config(config_file)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
generator.calculate_positions()
|
|
177
|
+
svg_content = generator.graph()
|
|
178
|
+
output_svg_file.write_text(svg_content, encoding="utf-8")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
# Un fichier fautif ne doit pas interrompre le lot en cours.
|
|
181
|
+
logger.error(f"{input_file.name} écarté : {e}")
|
|
182
|
+
return DiagramStatus.FAILED
|
|
183
|
+
|
|
184
|
+
produced = output_svg_file.name
|
|
185
|
+
if png and _write_png(svg_content, output_png_file):
|
|
186
|
+
produced += f" et {output_png_file.name}"
|
|
187
|
+
|
|
188
|
+
logger.info(f"{input_file.name} → {produced}")
|
|
189
|
+
return DiagramStatus.PRODUCED
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
193
|
+
args = parse_args(argv)
|
|
194
|
+
setup_logging(args.verbose, args.log_file)
|
|
195
|
+
|
|
196
|
+
config_file = args.input_dir / args.config if args.config else None
|
|
197
|
+
|
|
198
|
+
logger.debug(f"Répertoire d'entrée : {args.input_dir}")
|
|
199
|
+
logger.debug(f"Répertoire de sortie : {args.output_dir}")
|
|
200
|
+
|
|
201
|
+
input_files = collect_input_files(args.input_dir, config_file)
|
|
202
|
+
if not input_files:
|
|
203
|
+
logger.error(
|
|
204
|
+
f"Aucune définition ({', '.join(SUPPORTED_SUFFIXES)}) trouvée dans {args.input_dir}"
|
|
205
|
+
)
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
logger.info(f"{len(input_files)} fichier(s) à traiter")
|
|
210
|
+
|
|
211
|
+
statuses = [
|
|
212
|
+
process_diagram(
|
|
213
|
+
input_file,
|
|
214
|
+
args.output_dir,
|
|
215
|
+
config_file=config_file,
|
|
216
|
+
overwrite=args.overwrite,
|
|
217
|
+
png=not args.no_png,
|
|
218
|
+
)
|
|
219
|
+
for input_file in input_files
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
produced = statuses.count(DiagramStatus.PRODUCED)
|
|
223
|
+
skipped = statuses.count(DiagramStatus.SKIPPED)
|
|
224
|
+
failed = statuses.count(DiagramStatus.FAILED)
|
|
225
|
+
|
|
226
|
+
bilan = f"Terminé : {produced}/{len(input_files)} fichier(s) produit(s)"
|
|
227
|
+
if skipped:
|
|
228
|
+
bilan += f", {skipped} ignoré(s) (déjà présent(s))"
|
|
229
|
+
if failed:
|
|
230
|
+
bilan += f", {failed} en échec"
|
|
231
|
+
logger.info(bilan)
|
|
232
|
+
|
|
233
|
+
return 1 if failed else 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|