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/config.py ADDED
@@ -0,0 +1,533 @@
1
+ """Configuration du rendu graphique.
2
+
3
+ `GraphConfig` regroupe tous les paramètres de mise en page et de couleur du SVG
4
+ produit par :class:`appflows.generator.Generator`, rangés par sujet : page,
5
+ application, centre, flux, tag, bus, connecteur. Chaque sujet est une
6
+ dataclass, et l'arborescence est **celle du fichier** de configuration :
7
+ ``cfg.flow.label.halo.width`` se lit ``flow: {label: {halo: {width: 3}}}``.
8
+
9
+ La configuration est sérialisable via :meth:`GraphConfig.to_dict` /
10
+ :meth:`GraphConfig.from_dict`. Les valeurs lues depuis un fichier sont
11
+ contrôlées avant d'être retenues : une définition peut provenir d'une source
12
+ non maîtrisée, et une couleur non validée finirait telle quelle dans le SVG.
13
+ Le contrôle est porté par les champs eux-mêmes (``field(metadata=...)``), ce
14
+ qui évite de tenir à part la liste des champs de chaque sorte.
15
+ """
16
+
17
+ import re
18
+ from dataclasses import dataclass, field, fields, is_dataclass
19
+ from typing import Any, NamedTuple, TypeGuard, TypeVar, get_args, get_origin
20
+
21
+ from loguru import logger
22
+
23
+ from appflows.logtext import excerpt
24
+
25
+ #: Code de l'application centrale par défaut.
26
+ MAIN_APP_CODE = "m"
27
+
28
+ #: Couleur admise : notation hexadécimale (``#RGB``, ``#RRGGBB``, ``#RRGGBBAA``)
29
+ #: ou mot-clé CSS. Tout le reste est refusé : la valeur est interpolée dans le
30
+ #: SVG, y compris dans un bloc ``<style>`` qui ne peut pas être échappé.
31
+ COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|[a-zA-Z]+")
32
+
33
+
34
+ #: Variations appliquées aux couleurs de l'application pour habiller son tag,
35
+ #: faute de couleurs déclarées pour ce tag : bordure assombrie, fond éclairci.
36
+ #: Le texte, lui, reprend celui de l'application sans retouche.
37
+ TAG_BORDER_DARKEN = 0.30
38
+ TAG_BACKGROUND_LIGHTEN = 0.45
39
+
40
+
41
+ class Colors(NamedTuple):
42
+ """Habillage d'une boîte ou d'une pastille : texte, bordure, fond.
43
+
44
+ Un tuple nommé plutôt qu'une dataclass : le triplet se dépaquette
45
+ (``_, border, fill = colors``) et s'indexe comme le faisait le tuple
46
+ anonyme, tout en donnant un nom à chaque couleur.
47
+ """
48
+
49
+ text: str
50
+ stroke: str
51
+ fill: str
52
+
53
+
54
+ # ----------------------------------------------------------------------
55
+ # Contrôle des feuilles
56
+ # ----------------------------------------------------------------------
57
+ #
58
+ # Chaque feuille porte sa règle dans ``metadata`` :
59
+ # - ``positive`` : entier ou réel strictement positif — une police ou une
60
+ # largeur plancher nulle ferait disparaître ce qu'elle mesure ;
61
+ # - ``signed`` : entier de signe libre — une retouche de centrage doit
62
+ # pouvoir remonter comme descendre ;
63
+ # - ``color`` : chaîne de couleur, contrôlée par ``COLOR_PATTERN``.
64
+ # Sans metadata, un entier est positif ou nul : dimension ou blanc.
65
+
66
+
67
+ def _positive(default: int | float) -> Any:
68
+ return field(default=default, metadata={"positive": True})
69
+
70
+
71
+ def _signed(default: int) -> Any:
72
+ return field(default=default, metadata={"signed": True})
73
+
74
+
75
+ def _color(default: str) -> Any:
76
+ return field(default=default, metadata={"color": True})
77
+
78
+
79
+ # ----------------------------------------------------------------------
80
+ # Sous-objets partagés
81
+ # ----------------------------------------------------------------------
82
+
83
+
84
+ @dataclass
85
+ class Size:
86
+ """Gabarit d'un rectangle d'application.
87
+
88
+ ``width`` est une largeur **plancher** : une colonne dont les libellés
89
+ l'exigent s'élargit (voir ``layout.Layout._lane_width``). ``height`` est
90
+ celle d'un rectangle à un seul flux.
91
+ """
92
+
93
+ width: int = _positive(100)
94
+ height: int = _positive(48)
95
+
96
+
97
+ @dataclass
98
+ class Margin:
99
+ """Marges de la page, avant le premier rectangle."""
100
+
101
+ top: int = 28
102
+ left: int = 28
103
+
104
+
105
+ @dataclass
106
+ class Padding:
107
+ """Blanc laissé autour d'un texte dans sa boîte."""
108
+
109
+ x: int = 12
110
+ y: int = 8
111
+
112
+
113
+ @dataclass
114
+ class Halo:
115
+ """Trait tracé derrière un texte pour qu'il reste lisible sur un fond chargé."""
116
+
117
+ #: ``0`` désactive le halo.
118
+ width: int = 3
119
+ #: La couleur du fond sur lequel le schéma est posé.
120
+ color: str = _color("#ffffff")
121
+
122
+
123
+ @dataclass
124
+ class Line:
125
+ """Un trait : épaisseur et couleur."""
126
+
127
+ width: int = 2
128
+ color: str = _color("#9e9e9e")
129
+
130
+
131
+ @dataclass
132
+ class Port:
133
+ """Port écrit à côté de la pastille d'un connecteur, à l'intérieur de la boîte."""
134
+
135
+ font_size: int = _positive(10)
136
+ gap: int = 4
137
+
138
+
139
+ @dataclass
140
+ class FlowLabel:
141
+ """Libellé dessiné au-dessus d'une flèche de flux."""
142
+
143
+ font_size: int = _positive(12)
144
+ #: Couleur du libellé, distincte de celle du trait.
145
+ color: str = _color("#000000")
146
+ #: Hauteur du libellé au-dessus de son trait.
147
+ offset_y: int = 8
148
+ #: Halo tracé derrière le libellé, pour qu'il reste lisible là où il
149
+ #: croise un trait.
150
+ halo: Halo = field(default_factory=Halo)
151
+
152
+
153
+ # ----------------------------------------------------------------------
154
+ # Sections
155
+ # ----------------------------------------------------------------------
156
+
157
+
158
+ @dataclass
159
+ class PageConfig:
160
+ """La page : marges et espacement entre colonnes et lignes."""
161
+
162
+ margin: Margin = field(default_factory=Margin)
163
+ #: Blanc horizontal entre deux colonnes.
164
+ hspace: int = 28
165
+ #: Blanc vertical entre deux boîtes d'une même colonne.
166
+ vspace: int = 28
167
+
168
+
169
+ @dataclass
170
+ class AppConfig:
171
+ """Les rectangles d'application, centre compris sauf mention contraire."""
172
+
173
+ size: Size = field(default_factory=Size)
174
+ corner: int = 8
175
+ #: Épaisseur de la bordure des rectangles.
176
+ stroke_width: int = 2
177
+ #: ``x`` : blanc de chaque côté du libellé. ``y`` : blanc au-dessus du
178
+ #: libellé et sous le dernier flux, quand une boîte porte des connecteurs
179
+ #: et range donc son nom au-dessus d'eux.
180
+ padding: Padding = field(default_factory=lambda: Padding(x=12, y=8))
181
+ font_size: int = _positive(14)
182
+ #: Retouche fine du centrage vertical d'un libellé. Nulle par défaut :
183
+ #: `dominant-baseline` centre déjà ; à n'ajuster que si une police
184
+ #: substituée tombe mal.
185
+ text_vertical_offset: int = _signed(0)
186
+ colors: Colors = field(default_factory=lambda: Colors("#0050ef", "#6c8ebf", "#dae8fc"))
187
+
188
+
189
+ @dataclass
190
+ class MainConfig:
191
+ """Ce qui distingue l'application centrale des satellites."""
192
+
193
+ font_size: int = _positive(16)
194
+ #: Largeur de l'application centrale, en multiple de celle des satellites.
195
+ #: Son propre libellé reste un plancher : elle ne rétrécit jamais dessous.
196
+ width_ratio: float = _positive(1.2)
197
+ colors: Colors = field(default_factory=lambda: Colors("#000000", "#9673a6", "#e1d5e7"))
198
+
199
+
200
+ @dataclass
201
+ class FlowConfig:
202
+ """Les flèches de flux et leur libellé."""
203
+
204
+ #: Largeur de la bande réservée aux flèches entre deux colonnes.
205
+ width: int = _positive(160)
206
+ #: Blanc entre le bord d'une boîte et le premier flux.
207
+ margin: int = 18
208
+ #: Blanc entre la pointe d'une flèche et ce qu'elle désigne — bord de
209
+ #: rectangle, de boîte de bus ou de pastille de connecteur. Nul par
210
+ #: défaut : la pointe touche sa cible, ce qui donne à lire le raccordement.
211
+ gap: int = 0
212
+ #: Épaisseur du trait d'une flèche.
213
+ stroke_width: int = 2
214
+ #: Longueur de la pointe de flèche.
215
+ arrow: int = 12
216
+ color: str = _color("#004C99")
217
+ label: FlowLabel = field(default_factory=FlowLabel)
218
+
219
+
220
+ @dataclass
221
+ class TagConfig:
222
+ """La pastille de tag, sous le nom d'une application."""
223
+
224
+ font_size: int = _positive(11)
225
+ padding: Padding = field(default_factory=lambda: Padding(x=8, y=3))
226
+ corner: int = 6
227
+ stroke_width: int = 1
228
+ #: Écart entre le libellé de l'application et la pastille de son tag.
229
+ gap: int = 6
230
+ #: Habillage par nom de tag. Un tag absent de la table reprend les
231
+ #: couleurs de son application, nuancées par :func:`derive_tag_colors`.
232
+ colors: dict[str, Colors] = field(default_factory=dict)
233
+
234
+
235
+ @dataclass
236
+ class BusConfig:
237
+ """La boîte d'un bus : angles vifs par défaut, bordure gris moyen et fond
238
+ gris clair, pour qu'elle se lise comme une infrastructure et non comme une
239
+ application."""
240
+
241
+ corner: int = 0
242
+ stroke_width: int = 2
243
+ font_size: int = _positive(13)
244
+ #: ``y`` : blanc au-dessus et au-dessous des tronçons, dans la boîte. Le
245
+ #: blanc du haut porte aussi le nom du bus.
246
+ padding: Padding = field(default_factory=lambda: Padding(x=12, y=8))
247
+ colors: Colors = field(default_factory=lambda: Colors("#333333", "#9e9e9e", "#f0f0f0"))
248
+ #: Trait qui matérialise, dans la boîte, la traversée d'un flux.
249
+ line: Line = field(default_factory=Line)
250
+
251
+
252
+ @dataclass
253
+ class ConnectorConfig:
254
+ """La pastille d'un connecteur, posée à cheval sur le bord de sa boîte."""
255
+
256
+ height: int = _positive(18)
257
+ #: Largeur plancher, et largeur exacte d'un connecteur neutre.
258
+ min_width: int = _positive(14)
259
+ corner: int = 3
260
+ padding_x: int = 6
261
+ font_size: int = _positive(10)
262
+ stroke_width: int = 1
263
+ #: Blanc entre le libellé d'une boîte et le premier connecteur, sous lui.
264
+ label_gap: int = 40
265
+ #: Habillage par protocole. Un protocole absent de la table reprend les
266
+ #: couleurs de sa boîte, nuancées par :func:`derive_connector_colors`.
267
+ colors: dict[str, Colors] = field(default_factory=dict)
268
+ port: Port = field(default_factory=Port)
269
+
270
+
271
+ @dataclass
272
+ class GraphConfig:
273
+ """Paramètres de mise en page du graphe, section par section."""
274
+
275
+ page: PageConfig = field(default_factory=PageConfig)
276
+ app: AppConfig = field(default_factory=AppConfig)
277
+ main: MainConfig = field(default_factory=MainConfig)
278
+ flow: FlowConfig = field(default_factory=FlowConfig)
279
+ tag: TagConfig = field(default_factory=TagConfig)
280
+ bus: BusConfig = field(default_factory=BusConfig)
281
+ connector: ConnectorConfig = field(default_factory=ConnectorConfig)
282
+
283
+ def to_dict(self) -> dict:
284
+ """Retourne la configuration sous forme de dictionnaire sérialisable.
285
+
286
+ L'arborescence est celle du fichier de configuration ; un ``Colors``
287
+ y devient ``{text, stroke, fill}``.
288
+ """
289
+ return _dump(self)
290
+
291
+ @classmethod
292
+ def from_dict(cls, data: dict) -> "GraphConfig":
293
+ """Construit une configuration depuis un dictionnaire arborescent.
294
+
295
+ Les clés inconnues et les valeurs invalides sont signalées puis
296
+ écartées, plutôt que de lever : un fichier écrit pour une autre version
297
+ reste exploitable, et un lot de génération n'est jamais interrompu par
298
+ un paramètre fautif.
299
+ """
300
+ config = _load(cls, data, "")
301
+ config.check_consistency()
302
+ return config
303
+
304
+ def check_consistency(self) -> None:
305
+ """Signale les réglages qui, valides un à un, se contredisent ensemble.
306
+
307
+ Deux flux d'une même boîte sont espacés de ``2 * flow.margin`` ; les
308
+ pastilles de connecteur, hautes de ``connector.height``, sont centrées
309
+ sur eux. En dessous, elles se chevauchent — et les ports avec elles.
310
+ C'est le seul réglage qui mène à une collision de textes ; il se
311
+ décide à la lecture du fichier, sans attendre le rendu.
312
+ """
313
+ spacing = 2 * self.flow.margin
314
+ if spacing < self.connector.height:
315
+ logger.warning(
316
+ f"flow.margin = {self.flow.margin} : deux flux ne sont espacés que de "
317
+ f"{spacing} px, moins que la hauteur d'une pastille de connecteur "
318
+ f"(connector.height = {self.connector.height}) — sur un schéma "
319
+ "appli+tech, les pastilles et les ports se chevaucheront"
320
+ )
321
+
322
+
323
+ # ----------------------------------------------------------------------
324
+ # Sérialisation
325
+ # ----------------------------------------------------------------------
326
+
327
+
328
+ def _dump(value: object) -> Any:
329
+ """Sérialise récursivement, en gardant l'ordre de déclaration des champs."""
330
+ if isinstance(value, Colors):
331
+ return {"text": value.text, "stroke": value.stroke, "fill": value.fill}
332
+ if is_dataclass(value) and not isinstance(value, type):
333
+ return {f.name: _dump(getattr(value, f.name)) for f in fields(value)}
334
+ if isinstance(value, dict):
335
+ return {key: _dump(item) for key, item in value.items()}
336
+ return value
337
+
338
+
339
+ T = TypeVar("T")
340
+
341
+
342
+ def _load(cls: type[T], data: object, path: str) -> T:
343
+ """Construit une dataclass depuis un dictionnaire, champ par champ.
344
+
345
+ Chaque défaut est signalé avec le chemin pointé de la valeur en cause
346
+ (``flow.label.halo.width``), et la valeur par défaut est conservée.
347
+ """
348
+ default = cls()
349
+ if not isinstance(data, dict):
350
+ logger.warning(f"{path or 'configuration'} : dictionnaire attendu, reçu {excerpt(data)}")
351
+ return default
352
+
353
+ where = f"{path}." if path else ""
354
+ known = {f.name for f in fields(default)} # type: ignore[arg-type]
355
+ unknown = sorted(set(data) - known)
356
+ if unknown:
357
+ logger.warning(
358
+ f"Paramètres de configuration inconnus, ignorés : "
359
+ f"{', '.join(where + str(key) for key in unknown)}"
360
+ )
361
+
362
+ values = {}
363
+ for f in fields(default): # type: ignore[arg-type]
364
+ if f.name not in data:
365
+ continue
366
+ values[f.name] = _load_field(
367
+ f.type, getattr(default, f.name), f.metadata, data[f.name], f"{where}{f.name}"
368
+ )
369
+ return cls(**values) # type: ignore[arg-type]
370
+
371
+
372
+ def _load_field(kind: object, default: Any, metadata: Any, value: object, path: str) -> Any:
373
+ """Contrôle une valeur selon le type déclaré du champ, ou garde le défaut."""
374
+ if kind is Colors:
375
+ return _load_colors(default, value, path)
376
+ if get_origin(kind) is dict and get_args(kind)[1] is Colors:
377
+ return _load_color_table(value, path)
378
+ if isinstance(kind, type) and is_dataclass(kind):
379
+ return _load(kind, value, path)
380
+ if kind is float:
381
+ if not _is_number(value) or value <= 0:
382
+ logger.warning(f"{path} : nombre strictement positif attendu, reçu {excerpt(value)}")
383
+ return default
384
+ return value
385
+ if kind is int:
386
+ return _load_int(default, metadata, value, path)
387
+ if metadata.get("color"):
388
+ if not _is_color(value):
389
+ logger.warning(f"{path} : couleur invalide ({excerpt(value)})")
390
+ return default
391
+ return value
392
+ return value
393
+
394
+
395
+ def _load_int(default: int, metadata: Any, value: object, path: str) -> int:
396
+ """Contrôle un entier : son type, puis son intervalle.
397
+
398
+ Contrôler le seul type laissait passer un blanc négatif ou une police
399
+ nulle, retenus en silence pour produire un schéma illisible.
400
+ """
401
+ if not _is_int(value):
402
+ logger.warning(f"{path} : entier attendu, reçu {excerpt(value)}")
403
+ return default
404
+ if metadata.get("signed"):
405
+ return value
406
+ if metadata.get("positive") and value <= 0:
407
+ logger.warning(f"{path} : entier strictement positif attendu, reçu {value}")
408
+ return default
409
+ if value < 0:
410
+ logger.warning(f"{path} : entier positif ou nul attendu, reçu {value}")
411
+ return default
412
+ return value
413
+
414
+
415
+ def _load_colors(default: Colors, value: object, path: str) -> Colors:
416
+ """Un triplet ``{text, stroke, fill}``, complété depuis le défaut s'il est partiel."""
417
+ if not isinstance(value, dict):
418
+ logger.warning(f"{path} : {{text, stroke, fill}} attendu, reçu {excerpt(value)}")
419
+ return default
420
+
421
+ unknown = sorted(set(value) - set(Colors._fields))
422
+ if unknown:
423
+ logger.warning(f"{path} : couleur(s) inconnue(s), ignorée(s) : {unknown}")
424
+
425
+ retained = {}
426
+ for name in Colors._fields:
427
+ if name not in value:
428
+ continue
429
+ if not _is_color(value[name]):
430
+ logger.warning(f"{path}.{name} : couleur invalide ({excerpt(value[name])})")
431
+ continue
432
+ retained[name] = value[name]
433
+ return default._replace(**retained)
434
+
435
+
436
+ def _load_color_table(value: object, path: str) -> dict[str, Colors]:
437
+ """Contrôle une table d'habillage nommée, entrée par entrée.
438
+
439
+ Sert aussi bien aux tags qu'aux connecteurs : une entrée fautive ou
440
+ incomplète est écartée sans emporter les autres, et le tag ou le protocole
441
+ concerné retombe sur les couleurs de sa boîte.
442
+ """
443
+ if not isinstance(value, dict):
444
+ logger.warning(f"{path} : dictionnaire attendu, reçu {excerpt(value)}")
445
+ return {}
446
+
447
+ retained = {}
448
+ for name, entry in value.items():
449
+ where = f"{path}.{name}"
450
+ if not isinstance(entry, dict) or set(entry) != set(Colors._fields):
451
+ logger.warning(
452
+ f"{where} : {{text, stroke, fill}} complet attendu, reçu {excerpt(entry)}"
453
+ )
454
+ continue
455
+ invalid = [c for c in entry.values() if not _is_color(c)]
456
+ if invalid:
457
+ logger.warning(f"{where} : couleur(s) invalide(s) {excerpt(invalid)}")
458
+ continue
459
+ retained[str(name)] = Colors(entry["text"], entry["stroke"], entry["fill"])
460
+ return retained
461
+
462
+
463
+ def _is_number(value: object) -> TypeGuard[int | float]:
464
+ return isinstance(value, int | float) and not isinstance(value, bool)
465
+
466
+
467
+ def _is_int(value: object) -> TypeGuard[int]:
468
+ """Un booléen est un ``int`` pour Python, mais jamais une dimension ici."""
469
+ return isinstance(value, int) and not isinstance(value, bool)
470
+
471
+
472
+ def _is_color(value: object) -> bool:
473
+ return isinstance(value, str) and COLOR_PATTERN.fullmatch(value) is not None
474
+
475
+
476
+ # ----------------------------------------------------------------------
477
+ # Couleurs dérivées
478
+ # ----------------------------------------------------------------------
479
+
480
+
481
+ def _shade(color: str, factor: float) -> str:
482
+ """Éclaircit (`factor > 0`) ou assombrit (`factor < 0`) une couleur.
483
+
484
+ Une couleur nommée (`red`) est rendue telle quelle : sans composantes, il
485
+ n'y a rien à faire varier.
486
+ """
487
+ if not color.startswith("#"):
488
+ return color
489
+
490
+ digits = color[1:]
491
+ if len(digits) in (3, 4): # notation courte : #rgb, #rgba
492
+ digits = "".join(c * 2 for c in digits)
493
+ if len(digits) not in (6, 8):
494
+ return color
495
+
496
+ canaux = [int(digits[i : i + 2], 16) for i in range(0, 6, 2)]
497
+ cible = 255 if factor > 0 else 0
498
+ varies = [round(c + (cible - c) * abs(factor)) for c in canaux]
499
+
500
+ return "#" + "".join(f"{c:02x}" for c in varies) + digits[6:]
501
+
502
+
503
+ def derive_tag_colors(app_colors: Colors) -> Colors:
504
+ """Habillage d'un tag déduit des couleurs de son application.
505
+
506
+ Args:
507
+ app_colors: les couleurs de l'application.
508
+
509
+ Returns:
510
+ Celles du tag : même texte, bordure assombrie, fond éclairci.
511
+ """
512
+ return Colors(
513
+ app_colors.text,
514
+ _shade(app_colors.stroke, -TAG_BORDER_DARKEN),
515
+ _shade(app_colors.fill, TAG_BACKGROUND_LIGHTEN),
516
+ )
517
+
518
+
519
+ def derive_connector_colors(host_colors: Colors) -> Colors:
520
+ """Habillage d'un connecteur déduit des couleurs de la boîte qui le porte.
521
+
522
+ Un protocole absent de ``connector.colors`` reste ainsi lisible sans rien
523
+ déclarer : pastille pleine dans le ton de la bordure de sa boîte, texte et
524
+ bord blancs — c'est ce liseré blanc qui la détache de la boîte et donne à
525
+ lire la notion de connecteur.
526
+
527
+ Args:
528
+ host_colors: les couleurs de la boîte hôte.
529
+
530
+ Returns:
531
+ Celles du connecteur.
532
+ """
533
+ return Colors("#ffffff", "#ffffff", host_colors.stroke)