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.
- voight_kampff/__init__.py +15 -0
- voight_kampff/__main__.py +7 -0
- voight_kampff/_orchestration/__init__.py +5 -0
- voight_kampff/_orchestration/manifestes.py +123 -0
- voight_kampff/_orchestration/travail.py +448 -0
- voight_kampff/_protocole.py +78 -0
- voight_kampff/_travail/__init__.py +6 -0
- voight_kampff/_travail/__main__.py +7 -0
- voight_kampff/_travail/contexte.py +179 -0
- voight_kampff/_travail/fichier.py +64 -0
- voight_kampff/_travail/garde.py +28 -0
- voight_kampff/_travail/principal.py +218 -0
- voight_kampff/cli.py +48 -0
- voight_kampff/modules/__init__.py +31 -0
- voight_kampff/py.typed +0 -0
- voight_kampff/rapport/__init__.py +1 -0
- voight_kampff/rapport/modele.py +958 -0
- voight_kampff/schemas/__init__.py +60 -0
- voight_kampff/schemas/manifeste.schema.json +63 -0
- voight_kampff/schemas/messages.schema.json +85 -0
- voight_kampff/schemas/rapport.schema.json +371 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/METADATA +41 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/RECORD +26 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/WHEEL +4 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- voight_kampff_forensics-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -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,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
|
+
"""
|