voight-kampff-forensics 0.1.0.dev0__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.
@@ -0,0 +1,15 @@
1
+ """Voight-Kampff : analyse de provenance et d'intégrité de fichiers.
2
+
3
+ L'outil relève les preuves et les indices de génération ou de retouche, sans
4
+ modèle d'IA embarqué, et ne délivre aucun verdict certain (ADR-0008, ADR-0018).
5
+
6
+ L'interface publique de la bibliothèque n'exposera que l'analyse par un
7
+ processus de travail isolé (ADR-0036, ES-17) ; elle est construite à partir de
8
+ T11 et T12.
9
+ """
10
+
11
+ from importlib.metadata import version
12
+
13
+ __all__ = ["__version__"]
14
+
15
+ __version__: str = version("voight-kampff-forensics")
@@ -0,0 +1,7 @@
1
+ """Permet ``python -m voight_kampff``."""
2
+
3
+ import sys
4
+
5
+ from voight_kampff.cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Processus principal : manifestes des modules, lancement du processus de travail, réception.
2
+
3
+ Rien ici n'interprète le contenu du fichier analysé : seul le processus de travail le lit
4
+ (ADR-0028, ADR-0036, ES-01).
5
+ """
@@ -0,0 +1,123 @@
1
+ """Manifestes des modules : lecture, validation, ordre, désactivation (ADR-0042, ADR-0064).
2
+
3
+ Les manifestes sont lus et validés sans importer le code des modules. Ils sont livrés avec
4
+ l'outil : un manifeste invalide est un défaut de l'outil, qui arrête l'analyse avec un message
5
+ explicite, comme un catalogue invalide (ADR-0029).
6
+ """
7
+
8
+ import tomllib
9
+ from collections.abc import Collection, Iterable
10
+ from dataclasses import dataclass
11
+ from importlib.resources.abc import Traversable
12
+
13
+ from voight_kampff import schemas
14
+ from voight_kampff.modules import MANIFESTE, dossiers
15
+ from voight_kampff.rapport.modele import Direction
16
+
17
+ __all__ = ["Manifeste", "ManifesteInvalide", "ModuleInconnu", "decouvrir", "lire", "selectionner"]
18
+
19
+
20
+ class ManifesteInvalide(ValueError):
21
+ """Manifeste d'un module livré illisible, non conforme ou incohérent avec les autres."""
22
+
23
+
24
+ class ModuleInconnu(ValueError):
25
+ """Module à désactiver absent de cette version de l'outil."""
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Manifeste:
30
+ """Ce que le processus principal sait d'un module sans importer son code."""
31
+
32
+ identifiant: str
33
+ """Nom du dossier du module, donc nom d'import."""
34
+ version: str
35
+ formats: tuple[str, ...]
36
+ """Formats réels traités ; le module n'est appliqué qu'à ceux-là (T13)."""
37
+ exigences: tuple[str, ...]
38
+ regles: tuple[str, ...]
39
+ """Règles du catalogue employées ; leur existence est vérifiée avec le catalogue (T15)."""
40
+ fiche: str
41
+ """Fiche technique ``docs/techniques/<fiche>.md`` (NF-09)."""
42
+ directions: tuple[Direction, ...]
43
+ """Directions permises par la fiche technique (ADR-0022)."""
44
+ requiert: tuple[str, ...] = ()
45
+ """Modules exécutés avant celui-ci, dans le même processus de travail."""
46
+
47
+
48
+ def lire(dossier: Traversable) -> Manifeste:
49
+ """Lit et valide le manifeste d'un dossier de module."""
50
+ nom = dossier.name
51
+ try:
52
+ document = tomllib.loads((dossier / MANIFESTE).read_text(encoding="utf-8"))
53
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError):
54
+ raise ManifesteInvalide(f"module {nom} : manifeste illisible") from None
55
+ try:
56
+ schemas.valider("manifeste", document)
57
+ except schemas.DocumentInvalide as erreur:
58
+ raise ManifesteInvalide(f"module {nom} : {erreur}") from None
59
+ if document["identifiant"] != nom:
60
+ raise ManifesteInvalide(f"module {nom} : l'identifiant doit être le nom du dossier")
61
+ return Manifeste(
62
+ identifiant=document["identifiant"],
63
+ version=document["version"],
64
+ formats=tuple(document["formats"]),
65
+ exigences=tuple(document["exigences"]),
66
+ regles=tuple(document["regles"]),
67
+ fiche=document["fiche"],
68
+ directions=tuple(Direction(direction) for direction in document["directions"]),
69
+ requiert=tuple(document["requiert"]),
70
+ )
71
+
72
+
73
+ def decouvrir(racine: Traversable | None = None) -> tuple[Manifeste, ...]:
74
+ """Manifestes des modules livrés, dans l'ordre d'exécution.
75
+
76
+ Un module vient après ceux qu'il requiert ; à rang égal, l'ordre est celui des
77
+ identifiants, pour que deux analyses exécutent les modules dans le même ordre (NF-06).
78
+ """
79
+ manifestes = {manifeste.identifiant: manifeste for manifeste in map(lire, dossiers(racine))}
80
+ for manifeste in manifestes.values():
81
+ for requis in manifeste.requiert:
82
+ if requis == manifeste.identifiant or requis not in manifestes:
83
+ raise ManifesteInvalide(
84
+ f"module {manifeste.identifiant} : module requis absent ou lui-même"
85
+ )
86
+ return _ordonner(manifestes)
87
+
88
+
89
+ def _ordonner(manifestes: dict[str, Manifeste]) -> tuple[Manifeste, ...]:
90
+ restants = {identifiant: set(m.requiert) for identifiant, m in manifestes.items()}
91
+ ordre: list[Manifeste] = []
92
+ while restants:
93
+ prets = sorted(identifiant for identifiant, requis in restants.items() if not requis)
94
+ if not prets:
95
+ cycle = ", ".join(sorted(restants))
96
+ raise ManifesteInvalide(f"modules {cycle} : dépendance circulaire")
97
+ for identifiant in prets:
98
+ ordre.append(manifestes[identifiant])
99
+ del restants[identifiant]
100
+ for requis in restants.values():
101
+ requis.difference_update(prets)
102
+ return tuple(ordre)
103
+
104
+
105
+ def selectionner(
106
+ manifestes: Iterable[Manifeste], desactives: Collection[str]
107
+ ) -> tuple[tuple[Manifeste, ...], tuple[Manifeste, ...]]:
108
+ """Sépare les modules actifs des modules désactivés par l'utilisateur, ordre conservé.
109
+
110
+ Un module qui en requiert un désactivé est désactivé lui aussi : il ne pourrait pas
111
+ s'exécuter. ``manifestes`` doit être dans l'ordre d'exécution (:func:`decouvrir`).
112
+ """
113
+ manifestes = tuple(manifestes)
114
+ connus = {manifeste.identifiant for manifeste in manifestes}
115
+ inconnus = sorted(set(desactives) - connus)
116
+ if inconnus:
117
+ raise ModuleInconnu(f"module inconnu : {', '.join(inconnus)}")
118
+ eteints = set(desactives)
119
+ for manifeste in manifestes:
120
+ if eteints.intersection(manifeste.requiert):
121
+ eteints.add(manifeste.identifiant)
122
+ actifs = tuple(m for m in manifestes if m.identifiant not in eteints)
123
+ return actifs, tuple(m for m in manifestes if m.identifiant in eteints)
@@ -0,0 +1,448 @@
1
+ """Lancement du processus de travail et réception de ses messages (ADR-0036, ADR-0062, ADR-0065).
2
+
3
+ Le processus principal ne lit jamais le fichier pour l'interpréter : il transmet un descripteur
4
+ déjà ouvert, envoie la configuration une fois l'isolation annoncée, puis valide chaque ligne
5
+ reçue, dans cet ordre : taille, JSON strict, schéma, modèle, place dans le protocole.
6
+
7
+ Tout écart arrête le processus de travail. Les modules terminés gardent leurs résultats ; les
8
+ autres deviennent des analyses non menées, avec la raison de l'arrêt, et l'analyse est
9
+ interrompue (ADR-0056). Un délai et des tailles **provisoires** bornent l'échange ; T14 fixe
10
+ les valeurs.
11
+ """
12
+
13
+ import os
14
+ import queue
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable, Collection, Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import IO, Any
22
+
23
+ from voight_kampff import schemas
24
+ from voight_kampff._orchestration.manifestes import Manifeste, selectionner
25
+ from voight_kampff._protocole import (
26
+ TAILLE_MAX_CONFIGURATION,
27
+ TAILLE_MAX_MESSAGE,
28
+ TAILLE_MAX_SORTIE,
29
+ MessageInvalide,
30
+ decoder,
31
+ encoder,
32
+ )
33
+ from voight_kampff.rapport.modele import (
34
+ RAISONS_D_INTERRUPTION,
35
+ VERSION_SCHEMA,
36
+ Constat,
37
+ Couverture,
38
+ Dependance,
39
+ EtatIsolation,
40
+ EtatTechnique,
41
+ ManqueIsolation,
42
+ NiveauIsolation,
43
+ NonMenee,
44
+ Observation,
45
+ RaisonNonMenee,
46
+ TechniqueCouverte,
47
+ )
48
+
49
+ __all__ = ["DELAI", "ResultatTravail", "commande_travail", "executer"]
50
+
51
+ DELAI = 60.0
52
+ """Délai total d'une analyse en secondes, provisoire (T14)."""
53
+
54
+ _DELAI_ARRET = 5.0
55
+ """Attente de la fin du processus de travail après son dernier message ou son arrêt forcé."""
56
+
57
+ _RAISONS_DU_FICHIER = frozenset(
58
+ {RaisonNonMenee.FORMAT_PARTIEL, RaisonNonMenee.FICHIER_TRONQUE, RaisonNonMenee.LIMITE_DEPASSEE}
59
+ )
60
+ """Raisons qu'une analyse non menée sans module peut porter quand elle vient du travail."""
61
+
62
+ _RAISONS_DU_MOTEUR = frozenset({RaisonNonMenee.ERREUR_MODULE, RaisonNonMenee.LIMITE_DEPASSEE})
63
+ """Raisons posées par le moteur à la place des résultats d'un module (ADR-0060)."""
64
+
65
+ _RAISONS_D_UN_MODULE = _RAISONS_DU_MOTEUR | {
66
+ RaisonNonMenee.FORMAT_PARTIEL,
67
+ RaisonNonMenee.FICHIER_TRONQUE,
68
+ RaisonNonMenee.REFERENCE_MANQUANTE,
69
+ RaisonNonMenee.RESEAU_DESACTIVE,
70
+ }
71
+
72
+ _ISOLATION_INCONNUE = EtatIsolation(NiveauIsolation.REDUITE, tuple(ManqueIsolation))
73
+ """Retenue si le processus de travail s'arrête avant d'annoncer son isolation."""
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class ResultatTravail:
78
+ """Ce que le processus de travail a établi pour un fichier, validé par le principal.
79
+
80
+ Le rapport (T16) y ajoute l'identification (T13) et les versions de l'outil et du
81
+ catalogue (T15).
82
+ """
83
+
84
+ isolation: EtatIsolation
85
+ couverture: Couverture
86
+ observations: tuple[Observation, ...]
87
+ constats: tuple[Constat, ...]
88
+ non_menees: tuple[NonMenee, ...]
89
+ dependances: tuple[Dependance, ...]
90
+ empreinte: str | None
91
+ """Empreinte recalculée par le processus de travail ; comparée à celle du principal (T13)."""
92
+
93
+ @property
94
+ def interrompue(self) -> bool:
95
+ """Analyse interrompue, hors garantie de reproductibilité (ADR-0023)."""
96
+ return any(non_menee.raison in RAISONS_D_INTERRUPTION for non_menee in self.non_menees)
97
+
98
+
99
+ def commande_travail(descripteur: int) -> list[str]:
100
+ """Commande du processus de travail : Python isolé (``-I``), sans fichier ``.pyc`` (``-B``)."""
101
+ return [
102
+ sys.executable,
103
+ "-I",
104
+ "-B",
105
+ "-m",
106
+ "voight_kampff._travail",
107
+ "--descripteur",
108
+ str(descripteur),
109
+ ]
110
+
111
+
112
+ def executer(
113
+ descripteur: int,
114
+ manifestes: Sequence[Manifeste],
115
+ desactives: Collection[str] = (),
116
+ *,
117
+ delai: float = DELAI,
118
+ commande: Callable[[int], Sequence[str]] = commande_travail,
119
+ ) -> ResultatTravail:
120
+ """Analyse le fichier ouvert en ``descripteur`` dans un processus de travail neuf.
121
+
122
+ ``manifestes`` est dans l'ordre d'exécution (``decouvrir``) ; ``desactives`` nomme les
123
+ modules désactivés par l'utilisateur. ``commande`` remplace, pour les tests, le processus
124
+ de travail livré.
125
+ """
126
+ actifs, eteints = selectionner(manifestes, desactives)
127
+ reception = _Reception(actifs)
128
+ configuration = encoder(
129
+ {
130
+ "version_schema": VERSION_SCHEMA,
131
+ "modules": [
132
+ {
133
+ "identifiant": manifeste.identifiant,
134
+ "formats": list(manifeste.formats),
135
+ "directions": list(manifeste.directions),
136
+ }
137
+ for manifeste in actifs
138
+ ],
139
+ }
140
+ )
141
+ if len(configuration) > TAILLE_MAX_CONFIGURATION:
142
+ raise ValueError("configuration : trop longue pour le processus de travail")
143
+ echeance = time.monotonic() + delai
144
+ with _lancer(descripteur, commande) as processus:
145
+ try:
146
+ reception.recevoir(processus, configuration, echeance)
147
+ except _Interruption as interruption:
148
+ reception.interrompre(interruption.raison)
149
+ finally:
150
+ if processus.poll() is None:
151
+ processus.kill()
152
+ processus.wait(_DELAI_ARRET)
153
+ return reception.resultat(manifestes, {manifeste.identifiant for manifeste in eteints})
154
+
155
+
156
+ # Lancement
157
+
158
+
159
+ def _environnement() -> dict[str, str]:
160
+ """Environnement vidé ; Windows ne démarre pas Python sans ``SYSTEMROOT``."""
161
+ if sys.platform == "win32" and "SYSTEMROOT" in os.environ: # pragma: no cover - CI Windows
162
+ return {"SYSTEMROOT": os.environ["SYSTEMROOT"]}
163
+ return {}
164
+
165
+
166
+ def _lancer(
167
+ descripteur: int, commande: Callable[[int], Sequence[str]]
168
+ ) -> "subprocess.Popen[bytes]":
169
+ """Lance le processus de travail ; seuls le fichier et les trois flux standard sont hérités."""
170
+ options: dict[str, Any] = {
171
+ "stdin": subprocess.PIPE,
172
+ "stdout": subprocess.PIPE,
173
+ # Jamais lue ni recopiée : rien de ce que le processus de travail y écrit n'est gardé.
174
+ "stderr": subprocess.DEVNULL,
175
+ "env": _environnement(),
176
+ "close_fds": True,
177
+ }
178
+ if sys.platform == "win32": # pragma: no cover - exécuté par la CI Windows
179
+ import msvcrt
180
+
181
+ handle = msvcrt.get_osfhandle(descripteur)
182
+ os.set_handle_inheritable(handle, True)
183
+ try:
184
+ options["startupinfo"] = subprocess.STARTUPINFO(
185
+ lpAttributeList={"handle_list": [handle]}
186
+ )
187
+ return subprocess.Popen(commande(handle), **options) # noqa: S603 - commande fixe
188
+ finally:
189
+ os.set_handle_inheritable(handle, False)
190
+ return subprocess.Popen(commande(descripteur), pass_fds=(descripteur,), **options) # noqa: S603
191
+
192
+
193
+ # Lecture bornée de la sortie
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class _Evenement:
198
+ ligne: bytes | None = None
199
+ """Ligne complète, saut de ligne compris ; absente pour un événement de flux."""
200
+ raison: RaisonNonMenee | None = None
201
+ """Raison d'arrêt détectée par la lecture : ligne ou sortie trop longue, ligne coupée."""
202
+
203
+
204
+ _FIN_DU_FLUX = _Evenement()
205
+
206
+
207
+ def _lire(flux: IO[bytes], file: "queue.Queue[_Evenement]") -> None:
208
+ total = 0
209
+ while ligne := flux.readline(TAILLE_MAX_MESSAGE + 1):
210
+ total += len(ligne)
211
+ if len(ligne) > TAILLE_MAX_MESSAGE or total > TAILLE_MAX_SORTIE:
212
+ file.put(_Evenement(raison=RaisonNonMenee.LIMITE_DEPASSEE))
213
+ return
214
+ if not ligne.endswith(b"\n"):
215
+ file.put(_Evenement(raison=RaisonNonMenee.MESSAGE_INVALIDE))
216
+ return
217
+ file.put(_Evenement(ligne=ligne))
218
+ file.put(_FIN_DU_FLUX)
219
+
220
+
221
+ def _ecrire(flux: IO[bytes], donnees: bytes) -> None:
222
+ try:
223
+ flux.write(donnees)
224
+ flux.close()
225
+ except (OSError, ValueError):
226
+ pass # processus de travail arrêté, tube fermé : la lecture le constatera
227
+
228
+
229
+ # Réception
230
+
231
+
232
+ class _Interruption(Exception):
233
+ """Arrêt de l'échange, avec la raison qui rendra l'analyse non menée."""
234
+
235
+ def __init__(self, raison: RaisonNonMenee) -> None:
236
+ super().__init__(raison)
237
+ self.raison = raison
238
+
239
+
240
+ def _invalide(condition: bool) -> None:
241
+ if not condition:
242
+ raise _Interruption(RaisonNonMenee.MESSAGE_INVALIDE)
243
+
244
+
245
+ @dataclass(slots=True)
246
+ class _Module:
247
+ manifeste: Manifeste
248
+ observations: list[Observation] = field(default_factory=list)
249
+ constats: list[Constat] = field(default_factory=list)
250
+ non_menees: list[NonMenee] = field(default_factory=list)
251
+ remplace: bool = False
252
+ """Résultats remplacés par une raison du moteur : plus rien n'est admis ensuite."""
253
+ applicable: bool | None = None
254
+ """Fixé par le message « module » ; ``None`` tant que le module n'est pas terminé."""
255
+
256
+ @property
257
+ def vide(self) -> bool:
258
+ return not (self.observations or self.constats or self.non_menees)
259
+
260
+ def etat(self) -> EtatTechnique:
261
+ if not self.applicable:
262
+ return EtatTechnique.NON_APPLICABLE
263
+ if not self.non_menees:
264
+ return EtatTechnique.APPLIQUEE
265
+ if self.observations or self.constats:
266
+ return EtatTechnique.PARTIELLE
267
+ return EtatTechnique.NON_MENEE
268
+
269
+
270
+ class _Reception:
271
+ """Suit le protocole message par message ; tout écart lève :class:`_Interruption`."""
272
+
273
+ def __init__(self, actifs: Sequence[Manifeste]) -> None:
274
+ self._modules = [_Module(manifeste) for manifeste in actifs]
275
+ self._rang = 0
276
+ self._identifiants: set[str] = set()
277
+ self._isolation: EtatIsolation | None = None
278
+ self._dependances: tuple[Dependance, ...] | None = None
279
+ self._non_menees_du_fichier: list[NonMenee] = []
280
+ self._empreinte: str | None = None
281
+ self._interruption: RaisonNonMenee | None = None
282
+
283
+ # Déroulé
284
+
285
+ def recevoir(
286
+ self, processus: "subprocess.Popen[bytes]", configuration: bytes, echeance: float
287
+ ) -> None:
288
+ if processus.stdin is None or processus.stdout is None:
289
+ raise RuntimeError("processus de travail lancé sans ses tubes")
290
+ evenements: queue.Queue[_Evenement] = queue.Queue()
291
+ threading.Thread(target=_lire, args=(processus.stdout, evenements), daemon=True).start()
292
+
293
+ premier = self._message(evenements, processus, echeance)
294
+ _invalide(premier["type"] == "isolation")
295
+ self._isolation = EtatIsolation.depuis_dict(premier["isolation"])
296
+ # La configuration part seulement maintenant : le travail a fini de se verrouiller.
297
+ ecriture = threading.Thread(
298
+ target=_ecrire, args=(processus.stdin, configuration), daemon=True
299
+ )
300
+ ecriture.start()
301
+
302
+ while self._empreinte is None:
303
+ self._traiter(self._message(evenements, processus, echeance))
304
+ dernier = self._evenement(evenements, echeance)
305
+ _invalide(dernier is _FIN_DU_FLUX)
306
+ try:
307
+ code = processus.wait(max(0.0, echeance - time.monotonic()))
308
+ except subprocess.TimeoutExpired:
309
+ raise _Interruption(RaisonNonMenee.DELAI_DEPASSE) from None
310
+ if code != 0:
311
+ raise _Interruption(RaisonNonMenee.ARRET_PROCESSUS)
312
+
313
+ def _evenement(self, evenements: "queue.Queue[_Evenement]", echeance: float) -> _Evenement:
314
+ try:
315
+ return evenements.get(timeout=max(0.0, echeance - time.monotonic()))
316
+ except queue.Empty:
317
+ raise _Interruption(RaisonNonMenee.DELAI_DEPASSE) from None
318
+
319
+ def _message(
320
+ self,
321
+ evenements: "queue.Queue[_Evenement]",
322
+ processus: "subprocess.Popen[bytes]",
323
+ echeance: float,
324
+ ) -> dict[str, Any]:
325
+ evenement = self._evenement(evenements, echeance)
326
+ if evenement.raison is not None:
327
+ raise _Interruption(evenement.raison)
328
+ if evenement.ligne is None: # flux fermé avant le message de fin
329
+ raise _Interruption(RaisonNonMenee.ARRET_PROCESSUS)
330
+ try:
331
+ message = decoder(evenement.ligne)
332
+ schemas.valider("messages", message)
333
+ except (MessageInvalide, schemas.DocumentInvalide):
334
+ raise _Interruption(RaisonNonMenee.MESSAGE_INVALIDE) from None
335
+ return message
336
+
337
+ # Messages
338
+
339
+ def _traiter(self, message: dict[str, Any]) -> None:
340
+ try:
341
+ match message["type"]:
342
+ case "observation":
343
+ self._observation(Observation.depuis_dict(message["observation"]))
344
+ case "constat":
345
+ self._constat(Constat.depuis_dict(message["constat"]))
346
+ case "non_menee":
347
+ self._non_menee(NonMenee.depuis_dict(message["non_menee"]))
348
+ case "module":
349
+ self._fin_de_module(message["module"], message["applicable"])
350
+ case "versions":
351
+ _invalide(self._dependances is None)
352
+ self._versions(message["dependances"])
353
+ case "fin":
354
+ _invalide(self._rang == len(self._modules) and self._dependances is not None)
355
+ self._empreinte = message["empreinte"]
356
+ case _: # « isolation » une seconde fois
357
+ raise _Interruption(RaisonNonMenee.MESSAGE_INVALIDE)
358
+ except (ValueError, TypeError, KeyError):
359
+ # Invariant du modèle refusé ; le message d'erreur, qui peut citer un nom de
360
+ # champ, n'est pas gardé.
361
+ raise _Interruption(RaisonNonMenee.MESSAGE_INVALIDE) from None
362
+
363
+ def _courant(self, identifiant: str | None) -> _Module:
364
+ """Module en cours : les modules rendent leurs résultats un par un, dans l'ordre."""
365
+ _invalide(self._rang < len(self._modules))
366
+ module = self._modules[self._rang]
367
+ _invalide(identifiant == module.manifeste.identifiant and not module.remplace)
368
+ return module
369
+
370
+ def _observation(self, observation: Observation) -> None:
371
+ module = self._courant(observation.module)
372
+ _invalide(observation.identifiant.startswith(f"{observation.module}/"))
373
+ _invalide(observation.identifiant not in self._identifiants)
374
+ self._identifiants.add(observation.identifiant)
375
+ module.observations.append(observation)
376
+
377
+ def _constat(self, constat: Constat) -> None:
378
+ module = self._courant(constat.module)
379
+ siennes = {observation.identifiant for observation in module.observations}
380
+ _invalide(set(constat.donnees) <= siennes)
381
+ _invalide(constat.direction in module.manifeste.directions)
382
+ module.constats.append(constat)
383
+
384
+ def _non_menee(self, non_menee: NonMenee) -> None:
385
+ if non_menee.module is None:
386
+ _invalide(non_menee.raison in _RAISONS_DU_FICHIER)
387
+ self._non_menees_du_fichier.append(non_menee)
388
+ return
389
+ module = self._courant(non_menee.module)
390
+ _invalide(non_menee.raison in _RAISONS_D_UN_MODULE)
391
+ if non_menee.raison in _RAISONS_DU_MOTEUR:
392
+ # Les résultats du module ont été écartés : la raison est seule (ADR-0060).
393
+ _invalide(module.vide)
394
+ module.remplace = True
395
+ module.non_menees.append(non_menee)
396
+
397
+ def _fin_de_module(self, identifiant: str, applicable: bool) -> None:
398
+ _invalide(self._rang < len(self._modules))
399
+ module = self._modules[self._rang]
400
+ _invalide(identifiant == module.manifeste.identifiant)
401
+ _invalide(applicable or module.vide)
402
+ module.applicable = applicable
403
+ self._rang += 1
404
+
405
+ def _versions(self, dependances: list[dict[str, Any]]) -> None:
406
+ lues = tuple(Dependance.depuis_dict(dependance) for dependance in dependances)
407
+ _invalide(len({dependance.nom for dependance in lues}) == len(lues))
408
+ self._dependances = lues
409
+
410
+ # Résultat
411
+
412
+ def interrompre(self, raison: RaisonNonMenee) -> None:
413
+ self._interruption = raison
414
+
415
+ def resultat(self, manifestes: Sequence[Manifeste], eteints: set[str]) -> ResultatTravail:
416
+ actifs = {module.manifeste.identifiant: module for module in self._modules}
417
+ techniques: list[TechniqueCouverte] = []
418
+ observations: list[Observation] = []
419
+ constats: list[Constat] = []
420
+ non_menees: list[NonMenee] = []
421
+ for manifeste in manifestes:
422
+ identifiant, version = manifeste.identifiant, manifeste.version
423
+ module = actifs.get(identifiant)
424
+ if identifiant in eteints or module is None:
425
+ techniques.append(TechniqueCouverte(identifiant, EtatTechnique.NON_MENEE, version))
426
+ non_menees.append(NonMenee(RaisonNonMenee.MODULE_DESACTIVE, identifiant))
427
+ elif module.applicable is None: # pas terminé : ses résultats sont écartés
428
+ # Seule une interruption laisse un module inachevé : « fin » exige les autres.
429
+ raison = self._interruption or RaisonNonMenee.ARRET_PROCESSUS
430
+ techniques.append(TechniqueCouverte(identifiant, EtatTechnique.NON_MENEE, version))
431
+ non_menees.append(NonMenee(raison, identifiant))
432
+ else:
433
+ techniques.append(TechniqueCouverte(identifiant, module.etat(), version))
434
+ observations.extend(module.observations)
435
+ constats.extend(module.constats)
436
+ non_menees.extend(module.non_menees)
437
+ non_menees.extend(self._non_menees_du_fichier)
438
+ if self._interruption is not None:
439
+ non_menees.append(NonMenee(self._interruption))
440
+ return ResultatTravail(
441
+ isolation=self._isolation or _ISOLATION_INCONNUE,
442
+ couverture=Couverture(tuple(techniques), ()),
443
+ observations=tuple(observations),
444
+ constats=tuple(constats),
445
+ non_menees=tuple(non_menees),
446
+ dependances=self._dependances or (),
447
+ empreinte=self._empreinte if self._interruption is None else None,
448
+ )
@@ -0,0 +1,78 @@
1
+ """Protocole entre le processus principal et le processus de travail (ADR-0036, ADR-0065).
2
+
3
+ Lignes JSON, une par message, dans les deux processus. Ce module n'importe que la bibliothèque
4
+ standard : le processus de travail s'en sert sans charger ``jsonschema``.
5
+
6
+ Les tailles sont **provisoires** : T14 fixe les limites (ES-03).
7
+ """
8
+
9
+ import json
10
+ import math
11
+ from collections.abc import Mapping
12
+ from typing import Any, NoReturn
13
+
14
+ __all__ = [
15
+ "TAILLE_MAX_CONFIGURATION",
16
+ "TAILLE_MAX_LECTURE",
17
+ "TAILLE_MAX_MESSAGE",
18
+ "TAILLE_MAX_SORTIE",
19
+ "MessageInvalide",
20
+ "decoder",
21
+ "encoder",
22
+ ]
23
+
24
+ TAILLE_MAX_MESSAGE = 1 << 20
25
+ """Taille maximale d'une ligne envoyée par le processus de travail, saut de ligne compris."""
26
+ TAILLE_MAX_SORTIE = 64 << 20
27
+ """Taille maximale de l'ensemble des messages d'une analyse."""
28
+ TAILLE_MAX_CONFIGURATION = 16 << 20
29
+ """Taille maximale de la configuration envoyée par le processus principal."""
30
+ TAILLE_MAX_LECTURE = 16 << 20
31
+ """Taille maximale d'une lecture du fichier par un module."""
32
+
33
+
34
+ class MessageInvalide(ValueError):
35
+ """Ligne qui n'est pas un objet JSON strict. Le message ne cite jamais la ligne reçue."""
36
+
37
+
38
+ def encoder(message: Mapping[str, Any]) -> bytes:
39
+ """Une ligne JSON en UTF-8, terminée par un saut de ligne ; aucun NaN ni infini."""
40
+ texte = json.dumps(message, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
41
+ return texte.encode("utf-8") + b"\n"
42
+
43
+
44
+ def _refuser_constante(_constante: str) -> NoReturn:
45
+ raise MessageInvalide("message : NaN ou infini")
46
+
47
+
48
+ def _reel_fini(texte: str) -> float:
49
+ valeur = float(texte)
50
+ if not math.isfinite(valeur): # 1e400 donne un infini sans passer par parse_constant
51
+ raise MessageInvalide("message : nombre hors des réels finis")
52
+ return valeur
53
+
54
+
55
+ def _sans_doublon(paires: list[tuple[str, Any]]) -> dict[str, Any]:
56
+ objet = dict(paires)
57
+ if len(objet) != len(paires):
58
+ raise MessageInvalide("message : clé en double")
59
+ return objet
60
+
61
+
62
+ def decoder(ligne: bytes) -> dict[str, Any]:
63
+ """Décode une ligne en objet JSON, strictement (modèle de données, §7).
64
+
65
+ Refusés : UTF-8 invalide, NaN et infinis, clés en double, tout ce qui n'est pas un objet.
66
+ """
67
+ try:
68
+ document = json.loads(
69
+ ligne.decode("utf-8"),
70
+ parse_constant=_refuser_constante,
71
+ parse_float=_reel_fini,
72
+ object_pairs_hook=_sans_doublon,
73
+ )
74
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
75
+ raise MessageInvalide("message : JSON invalide") from None
76
+ if not isinstance(document, dict):
77
+ raise MessageInvalide("message : objet JSON attendu")
78
+ return document
@@ -0,0 +1,6 @@
1
+ """Processus de travail : un par fichier, lancé par ``python -I -B -m voight_kampff._travail``.
2
+
3
+ Il importe les modules, se verrouille, envoie l'état de l'isolation avant de lire le moindre
4
+ octet du fichier, reçoit la configuration, puis exécute les modules et renvoie leurs résultats
5
+ en lignes JSON (ADR-0036, ADR-0062, ADR-0065).
6
+ """