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,7 @@
1
+ """Point d'entrée du processus de travail : ``python -I -B -m voight_kampff._travail``."""
2
+
3
+ import sys
4
+
5
+ from voight_kampff._travail.principal import canal_du_protocole, principal
6
+
7
+ sys.exit(principal(sys.argv[1:], sys.stdin.buffer, canal_du_protocole()))
@@ -0,0 +1,179 @@
1
+ """Contexte remis à chaque module : lecture du fichier et réception des résultats (ADR-0061).
2
+
3
+ Les résultats d'un module restent dans son contexte jusqu'à ce qu'il rende la main ; s'il lève
4
+ une exception, ils sont écartés (ADR-0060). Le contexte construit les objets du modèle, qui
5
+ vérifient leurs invariants, et numérote les observations ``<module>/<n>``.
6
+ """
7
+
8
+ from collections.abc import Collection, Mapping, Sequence
9
+
10
+ from voight_kampff._protocole import TAILLE_MAX_MESSAGE, encoder
11
+ from voight_kampff._travail import garde
12
+ from voight_kampff._travail.fichier import Fichier
13
+ from voight_kampff.rapport.modele import (
14
+ Categorie,
15
+ CategorieIdentifiante,
16
+ Constat,
17
+ Direction,
18
+ Fondement,
19
+ Graduation,
20
+ Localisation,
21
+ NonMenee,
22
+ Observation,
23
+ Phrase,
24
+ RaisonNonMenee,
25
+ ReferenceRegle,
26
+ Scalaire,
27
+ )
28
+
29
+ __all__ = ["RAISONS_D_UN_MODULE", "Contexte", "LimiteDepassee"]
30
+
31
+ RAISONS_D_UN_MODULE = frozenset(
32
+ {
33
+ RaisonNonMenee.FORMAT_PARTIEL,
34
+ RaisonNonMenee.FICHIER_TRONQUE,
35
+ RaisonNonMenee.REFERENCE_MANQUANTE,
36
+ RaisonNonMenee.RESEAU_DESACTIVE,
37
+ }
38
+ )
39
+ """Raisons qu'un module peut donner lui-même ; les autres sont produites par le moteur."""
40
+
41
+
42
+ class LimiteDepassee(Exception):
43
+ """Résultats d'un module au-delà des tailles permises par le protocole."""
44
+
45
+
46
+ class Contexte:
47
+ """Ce qu'un module voit du fichier et de l'analyse en cours."""
48
+
49
+ __slots__ = (
50
+ "_budget",
51
+ "_depasse",
52
+ "_directions",
53
+ "_fichier",
54
+ "_lignes",
55
+ "_numero",
56
+ "_observations",
57
+ "_taille_lignes",
58
+ "format_reel",
59
+ "module",
60
+ "taille",
61
+ )
62
+
63
+ def __init__(
64
+ self,
65
+ module: str,
66
+ directions: Collection[Direction],
67
+ fichier: Fichier,
68
+ format_reel: str,
69
+ taille: int,
70
+ budget: int,
71
+ ) -> None:
72
+ garde.exiger()
73
+ self.module = module
74
+ self.format_reel = format_reel
75
+ """Format identifié par le contenu (T13)."""
76
+ self.taille = taille
77
+ """Taille du fichier en octets."""
78
+ self._directions = frozenset(directions)
79
+ self._fichier = fichier
80
+ self._budget = budget
81
+ self._lignes: list[bytes] = []
82
+ self._taille_lignes = 0
83
+ self._numero = 0
84
+ self._observations: set[str] = set()
85
+ self._depasse = False
86
+
87
+ # Lecture
88
+
89
+ def lire(self, position: int, taille: int) -> bytes:
90
+ """Au plus ``taille`` octets du fichier à partir de ``position`` ; moins en fin."""
91
+ return self._fichier.lire(position, taille)
92
+
93
+ # Résultats
94
+
95
+ def observer(
96
+ self,
97
+ nom: str,
98
+ valeur: Scalaire | Sequence[Scalaire],
99
+ *,
100
+ unite: str | None = None,
101
+ localisation: Localisation | None = None,
102
+ identifiante: CategorieIdentifiante | None = None,
103
+ ) -> str:
104
+ """Enregistre une observation et renvoie son identifiant, à citer dans un constat."""
105
+ identifiant = f"{self.module}/{self._numero + 1}"
106
+ observation = Observation(
107
+ identifiant,
108
+ self.module,
109
+ nom,
110
+ valeur if isinstance(valeur, str | int | float | bool) else tuple(valeur),
111
+ unite=unite,
112
+ localisation=localisation,
113
+ identifiante=identifiante,
114
+ )
115
+ self._ajouter({"type": "observation", "observation": observation.vers_dict()})
116
+ self._numero += 1
117
+ self._observations.add(identifiant)
118
+ return identifiant
119
+
120
+ def constater(
121
+ self,
122
+ phrase: Phrase,
123
+ donnees: Sequence[str],
124
+ regle: ReferenceRegle,
125
+ *,
126
+ fondement: Fondement,
127
+ categorie: Categorie,
128
+ direction: Direction,
129
+ graduation: Graduation | None = None,
130
+ localisation: Localisation | None = None,
131
+ ) -> None:
132
+ """Enregistre un constat ; ses données sont des observations de ce module."""
133
+ if direction not in self._directions:
134
+ raise ValueError("constat : direction non permise par la fiche technique")
135
+ if not set(donnees) <= self._observations:
136
+ raise ValueError("constat.donnees : observation inconnue de ce module")
137
+ constat = Constat(
138
+ phrase,
139
+ tuple(donnees),
140
+ regle,
141
+ self.module,
142
+ fondement,
143
+ categorie,
144
+ direction,
145
+ graduation=graduation,
146
+ localisation=localisation,
147
+ )
148
+ self._ajouter({"type": "constat", "constat": constat.vers_dict()})
149
+
150
+ def non_mener(
151
+ self, raison: RaisonNonMenee, *, localisation: Localisation | None = None
152
+ ) -> None:
153
+ """Signale une partie de l'analyse que le module n'a pas pu mener."""
154
+ if raison not in RAISONS_D_UN_MODULE:
155
+ raise ValueError("non_menee.raison : réservée au moteur")
156
+ non_menee = NonMenee(raison, self.module, localisation)
157
+ self._ajouter({"type": "non_menee", "non_menee": non_menee.vers_dict()})
158
+
159
+ # Pour le moteur
160
+
161
+ @property
162
+ def depasse(self) -> bool:
163
+ """Vrai si le module a dépassé une limite, même s'il a intercepté l'exception."""
164
+ return self._depasse
165
+
166
+ @property
167
+ def lignes(self) -> tuple[bytes, ...]:
168
+ """Messages encodés, dans l'ordre où le module les a produits."""
169
+ return tuple(self._lignes)
170
+
171
+ def _ajouter(self, message: Mapping[str, object]) -> None:
172
+ if self._depasse:
173
+ raise LimiteDepassee
174
+ ligne = encoder(message)
175
+ if len(ligne) > TAILLE_MAX_MESSAGE or self._taille_lignes + len(ligne) > self._budget:
176
+ self._depasse = True
177
+ raise LimiteDepassee
178
+ self._lignes.append(ligne)
179
+ self._taille_lignes += len(ligne)
@@ -0,0 +1,64 @@
1
+ """Accès du processus de travail au fichier analysé, par le descripteur hérité (ADR-0036)."""
2
+
3
+ import hashlib
4
+ import os
5
+ import sys
6
+
7
+ from voight_kampff._protocole import TAILLE_MAX_LECTURE
8
+
9
+ __all__ = ["Fichier", "ouvrir_herite"]
10
+
11
+ _BLOC = 1 << 20
12
+
13
+
14
+ def ouvrir_herite(valeur: int) -> int:
15
+ """Descripteur du fichier transmis par le processus principal.
16
+
17
+ Sous Windows, le processus principal transmet un handle, converti ici en descripteur.
18
+ """
19
+ if sys.platform == "win32": # pragma: no cover - exécuté par la CI Windows
20
+ import msvcrt
21
+
22
+ return msvcrt.open_osfhandle(valeur, os.O_RDONLY | os.O_BINARY)
23
+ return valeur
24
+
25
+
26
+ class Fichier:
27
+ """Lectures à position explicite : le processus de travail ne dépend d'aucune position."""
28
+
29
+ __slots__ = ("_descripteur",)
30
+
31
+ def __init__(self, descripteur: int) -> None:
32
+ self._descripteur = descripteur
33
+
34
+ def lire(self, position: int, taille: int) -> bytes:
35
+ """Au plus ``taille`` octets à partir de ``position`` ; moins en fin de fichier."""
36
+ if position < 0 or not 0 <= taille <= TAILLE_MAX_LECTURE:
37
+ raise ValueError("lecture : position ou taille hors bornes")
38
+ morceaux: list[bytes] = []
39
+ reste = taille
40
+ while reste:
41
+ morceau = self._lire_a(position, reste)
42
+ if not morceau:
43
+ break
44
+ morceaux.append(morceau)
45
+ position += len(morceau)
46
+ reste -= len(morceau)
47
+ return b"".join(morceaux)
48
+
49
+ def _lire_a(self, position: int, taille: int) -> bytes:
50
+ if sys.platform == "win32": # pragma: no cover - exécuté par la CI Windows
51
+ # Pas de pread sous Windows. Le processus principal ne lit plus le descripteur
52
+ # pendant l'analyse : déplacer la position commune ne gêne personne.
53
+ os.lseek(self._descripteur, position, os.SEEK_SET)
54
+ return os.read(self._descripteur, taille)
55
+ return os.pread(self._descripteur, taille, position)
56
+
57
+ def empreinte(self) -> tuple[str, int]:
58
+ """SHA-256 du fichier entier, en hexadécimal minuscule, et sa taille (ES-08, ADR-0040)."""
59
+ condensat = hashlib.sha256()
60
+ position = 0
61
+ while morceau := self._lire_a(position, _BLOC):
62
+ condensat.update(morceau)
63
+ position += len(morceau)
64
+ return condensat.hexdigest(), position
@@ -0,0 +1,28 @@
1
+ """Garde des analyseurs : ils refusent de s'exécuter hors d'un processus de travail (ES-17).
2
+
3
+ Le processus de travail se déclare verrouillé après avoir appliqué ses restrictions et avant
4
+ d'envoyer l'état de l'isolation (ADR-0036). Un programme qui importerait un module d'analyse
5
+ pour l'appeler dans son propre processus est arrêté par :func:`exiger`.
6
+ """
7
+
8
+ __all__ = ["HorsProcessusDeTravail", "exiger", "verrouiller"]
9
+
10
+ _verrouille = False
11
+
12
+
13
+ class HorsProcessusDeTravail(RuntimeError):
14
+ """Analyse demandée hors d'un processus de travail verrouillé."""
15
+
16
+
17
+ def verrouiller() -> None:
18
+ """Déclare le processus courant processus de travail verrouillé ; sans retour arrière."""
19
+ global _verrouille # état du processus entier, posé une seule fois
20
+ _verrouille = True
21
+
22
+
23
+ def exiger() -> None:
24
+ """Lève :class:`HorsProcessusDeTravail` hors d'un processus de travail verrouillé."""
25
+ if not _verrouille:
26
+ raise HorsProcessusDeTravail(
27
+ "les analyseurs ne s'exécutent que dans un processus de travail (ES-17)"
28
+ )
@@ -0,0 +1,218 @@
1
+ """Déroulé du processus de travail (ADR-0036) et acheminement vers les modules (ADR-0042).
2
+
3
+ 1. Importer les modules livrés, tant que le système de fichiers est accessible.
4
+ 2. Appliquer les restrictions (T14), se déclarer verrouillé, envoyer l'état de l'isolation :
5
+ aucun octet du fichier n'a encore été lu.
6
+ 3. Lire la configuration : modules actifs, dans l'ordre d'exécution.
7
+ 4. Recalculer l'empreinte, identifier le format (T13), exécuter chaque module actif, envoyer
8
+ ses résultats puis son message « module » (ADR-0062), et enfin les versions et la fin.
9
+
10
+ Une exception dans un module ne touche que ce module (NF-03, ADR-0060) ; toute autre erreur
11
+ arrête le processus, et le processus principal en fait une analyse non menée.
12
+ """
13
+
14
+ import argparse
15
+ import importlib
16
+ import json
17
+ import os
18
+ import sys
19
+ from collections.abc import Callable, Mapping, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import BinaryIO
22
+
23
+ from voight_kampff import modules
24
+ from voight_kampff._protocole import (
25
+ TAILLE_MAX_CONFIGURATION,
26
+ TAILLE_MAX_SORTIE,
27
+ encoder,
28
+ )
29
+ from voight_kampff._travail import garde
30
+ from voight_kampff._travail.contexte import Contexte, LimiteDepassee
31
+ from voight_kampff._travail.fichier import Fichier, ouvrir_herite
32
+ from voight_kampff.rapport.modele import (
33
+ VERSION_SCHEMA,
34
+ Direction,
35
+ EtatIsolation,
36
+ ManqueIsolation,
37
+ NiveauIsolation,
38
+ NonMenee,
39
+ RaisonNonMenee,
40
+ )
41
+
42
+ __all__ = [
43
+ "Analyseur",
44
+ "ModuleConfigure",
45
+ "canal_du_protocole",
46
+ "importer",
47
+ "lire_configuration",
48
+ "principal",
49
+ ]
50
+
51
+ type Analyseur = Callable[[Contexte], None]
52
+
53
+ _RESERVE = 64 << 10
54
+ """Part de la sortie gardée pour les messages du moteur après le dernier module."""
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ModuleConfigure:
59
+ """Module actif, tel que le processus principal l'a lu dans son manifeste validé."""
60
+
61
+ identifiant: str
62
+ formats: frozenset[str]
63
+ directions: frozenset[Direction]
64
+
65
+
66
+ def canal_du_protocole() -> BinaryIO:
67
+ """Réserve la sortie standard au protocole et renvoie le flux qui y écrit.
68
+
69
+ Un module qui afficherait du texte écrirait ensuite sur la sortie d'erreur, que le processus
70
+ principal ne lit pas (ES-15) : il ne peut pas glisser une ligne dans le protocole.
71
+ """
72
+ canal = os.fdopen(os.dup(sys.stdout.fileno()), "wb")
73
+ os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
74
+ sys.stdout = sys.stderr
75
+ return canal
76
+
77
+
78
+ def importer() -> tuple[dict[str, Analyseur], frozenset[str]]:
79
+ """Importe les modules livrés ; renvoie leurs analyseurs et ceux qui n'ont pas pu l'être.
80
+
81
+ Appelé avant les restrictions : après, le code ne peut plus être lu sur le disque.
82
+ """
83
+ analyseurs: dict[str, Analyseur] = {}
84
+ echecs: set[str] = set()
85
+ for dossier in modules.dossiers():
86
+ try:
87
+ code = importlib.import_module(f"{modules.__name__}.{dossier.name}")
88
+ analyseur = code.analyser
89
+ if not callable(analyseur):
90
+ raise TypeError("analyser n'est pas appelable")
91
+ except Exception:
92
+ # Le module en échec sera non mené ; les autres continuent (NF-03).
93
+ echecs.add(dossier.name)
94
+ else:
95
+ analyseurs[dossier.name] = analyseur
96
+ return analyseurs, frozenset(echecs)
97
+
98
+
99
+ def lire_configuration(entree: BinaryIO) -> tuple[ModuleConfigure, ...]:
100
+ """Lit la configuration envoyée par le processus principal (ADR-0065).
101
+
102
+ Elle vient d'un processus de confiance : une configuration mal formée est un défaut de
103
+ l'outil, qui arrête le processus de travail.
104
+ """
105
+ ligne = entree.readline(TAILLE_MAX_CONFIGURATION + 1)
106
+ if not ligne.endswith(b"\n") or len(ligne) > TAILLE_MAX_CONFIGURATION:
107
+ raise ValueError("configuration : absente ou trop longue")
108
+ document = json.loads(ligne)
109
+ if document["version_schema"] != VERSION_SCHEMA:
110
+ raise ValueError("configuration : version de schéma différente")
111
+ return tuple(
112
+ ModuleConfigure(
113
+ identifiant=str(module["identifiant"]),
114
+ formats=frozenset(str(f) for f in module["formats"]),
115
+ directions=frozenset(Direction(d) for d in module["directions"]),
116
+ )
117
+ for module in document["modules"]
118
+ )
119
+
120
+
121
+ def _identifier(_fichier: Fichier) -> str | None:
122
+ """Format réel du fichier ; aucun avant T13, donc aucun module n'est applicable."""
123
+ return None
124
+
125
+
126
+ class _Canal:
127
+ """Sortie du protocole : une ligne par message, comptée contre la taille maximale."""
128
+
129
+ __slots__ = ("_sortie", "envoye")
130
+
131
+ def __init__(self, sortie: BinaryIO) -> None:
132
+ self._sortie = sortie
133
+ self.envoye = 0
134
+
135
+ def envoyer(self, lignes: Sequence[bytes]) -> None:
136
+ for ligne in lignes:
137
+ self._sortie.write(ligne)
138
+ self.envoye += len(ligne)
139
+ self._sortie.flush()
140
+
141
+ def message(self, message: Mapping[str, object]) -> None:
142
+ self.envoyer((encoder(message),))
143
+
144
+
145
+ def _executer(
146
+ module: ModuleConfigure,
147
+ analyseur: Analyseur | None,
148
+ fichier: Fichier,
149
+ format_reel: str,
150
+ taille: int,
151
+ canal: _Canal,
152
+ ) -> None:
153
+ raison: RaisonNonMenee | None = RaisonNonMenee.ERREUR_MODULE
154
+ contexte: Contexte | None = None
155
+ if analyseur is not None:
156
+ budget = TAILLE_MAX_SORTIE - canal.envoye - _RESERVE
157
+ contexte = Contexte(
158
+ module.identifiant, module.directions, fichier, format_reel, taille, budget
159
+ )
160
+ try:
161
+ analyseur(contexte)
162
+ except LimiteDepassee:
163
+ raison = RaisonNonMenee.LIMITE_DEPASSEE
164
+ except Exception: # NF-03 : seul ce module est touché
165
+ raison = RaisonNonMenee.ERREUR_MODULE
166
+ else:
167
+ raison = RaisonNonMenee.LIMITE_DEPASSEE if contexte.depasse else None
168
+ if raison is None and contexte is not None:
169
+ canal.envoyer(contexte.lignes)
170
+ else:
171
+ # Résultats écartés (ADR-0060) : rien de ce que le module a produit n'est envoyé.
172
+ non_menee = NonMenee(raison or RaisonNonMenee.ERREUR_MODULE, module.identifiant)
173
+ canal.message({"type": "non_menee", "non_menee": non_menee.vers_dict()})
174
+
175
+
176
+ def principal(
177
+ argv: Sequence[str],
178
+ entree: BinaryIO,
179
+ sortie: BinaryIO,
180
+ *,
181
+ analyseurs: Mapping[str, Analyseur] | None = None,
182
+ identifier: Callable[[Fichier], str | None] = _identifier,
183
+ ) -> int:
184
+ """Analyse le fichier transmis par le processus principal ; renvoie le code de sortie.
185
+
186
+ ``analyseurs`` et ``identifier`` remplacent, pour les tests, les modules livrés et
187
+ l'identification du format.
188
+ """
189
+ arguments = argparse.ArgumentParser(prog="voight_kampff._travail")
190
+ arguments.add_argument("--descripteur", type=int, required=True)
191
+ descripteur = arguments.parse_args(argv).descripteur
192
+
193
+ echecs: frozenset[str] = frozenset()
194
+ if analyseurs is None:
195
+ analyseurs, echecs = importer()
196
+
197
+ # Restrictions du système : T14. Jusque-là, aucune n'est appliquée et le rapport le dit.
198
+ isolation = EtatIsolation(NiveauIsolation.REDUITE, tuple(ManqueIsolation))
199
+ garde.verrouiller()
200
+ canal = _Canal(sortie)
201
+ canal.message(
202
+ {"type": "isolation", "version_schema": VERSION_SCHEMA, "isolation": isolation.vers_dict()}
203
+ )
204
+
205
+ configures = lire_configuration(entree)
206
+ fichier = Fichier(ouvrir_herite(descripteur))
207
+ empreinte, taille = fichier.empreinte()
208
+ format_reel = identifier(fichier)
209
+ for module in configures:
210
+ applicable = format_reel is not None and format_reel in module.formats
211
+ if format_reel is not None and applicable:
212
+ analyseur = None if module.identifiant in echecs else analyseurs.get(module.identifiant)
213
+ _executer(module, analyseur, fichier, format_reel, taille, canal)
214
+ canal.message({"type": "module", "module": module.identifiant, "applicable": applicable})
215
+ # Versions des bibliothèques qui lisent le fichier : avec le premier module qui en emploie.
216
+ canal.message({"type": "versions", "dependances": []})
217
+ canal.message({"type": "fin", "empreinte": empreinte})
218
+ return 0
voight_kampff/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """Ligne de commande, installée sous les noms ``voight-kampff`` et ``vk`` (ADR-0045)."""
2
+
3
+ import argparse
4
+ from collections.abc import Sequence
5
+
6
+ from voight_kampff import __version__
7
+ from voight_kampff._orchestration import manifestes
8
+
9
+ _DESCRIPTION = (
10
+ "Analyse la provenance et l'intégrité d'un fichier : preuves et indices de "
11
+ "génération ou de retouche, sans verdict certain."
12
+ )
13
+
14
+
15
+ def construire_analyseur() -> argparse.ArgumentParser:
16
+ """Construit l'analyseur d'arguments de la ligne de commande."""
17
+ analyseur = argparse.ArgumentParser(prog="voight-kampff", description=_DESCRIPTION)
18
+ analyseur.add_argument(
19
+ "--version",
20
+ action="version",
21
+ version=f"%(prog)s {__version__}",
22
+ )
23
+ analyseur.add_argument(
24
+ "--modules",
25
+ action="store_true",
26
+ help="liste les modules d'analyse livrés, avec leur version",
27
+ )
28
+ return analyseur
29
+
30
+
31
+ def _lister_modules() -> None:
32
+ livres = manifestes.decouvrir()
33
+ if not livres:
34
+ print("Aucun module d'analyse livré dans cette version.")
35
+ for manifeste in livres:
36
+ print(f"{manifeste.identifiant} {manifeste.version}")
37
+
38
+
39
+ def main(argv: Sequence[str] | None = None) -> int:
40
+ """Point d'entrée ; renvoie le code de sortie du processus."""
41
+ analyseur = construire_analyseur()
42
+ arguments = analyseur.parse_args(argv)
43
+ if arguments.modules:
44
+ _lister_modules()
45
+ return 0
46
+ # Aucune commande d'analyse avant T13 : sans option, l'aide est affichée.
47
+ analyseur.print_help()
48
+ return 0
@@ -0,0 +1,31 @@
1
+ """Modules d'analyse : une technique par sous-paquet, avec son manifeste (ADR-0042).
2
+
3
+ Un module est un dossier ``modules/<identifiant>/`` qui contient ``manifeste.toml`` et un
4
+ ``__init__.py`` exposant ``analyser(contexte)`` (ADR-0061). Le processus principal lit les
5
+ manifestes sans importer ce code ; seul le processus de travail l'importe.
6
+
7
+ La découverte se limite à ce dossier : aucune extension tierce n'est chargée (ADR-0042). Aucun
8
+ module n'est livré avant T13.
9
+ """
10
+
11
+ from importlib.resources import files
12
+ from importlib.resources.abc import Traversable
13
+
14
+ __all__ = ["MANIFESTE", "dossiers"]
15
+
16
+ MANIFESTE = "manifeste.toml"
17
+ """Nom du manifeste dans le dossier de chaque module."""
18
+
19
+
20
+ def dossiers(racine: Traversable | None = None) -> tuple[Traversable, ...]:
21
+ """Dossiers des modules, triés par nom : ceux qui contiennent un manifeste.
22
+
23
+ ``racine`` vaut par défaut le dossier de ce paquet ; les tests en donnent un autre.
24
+ """
25
+ racine = files(__name__) if racine is None else racine
26
+ trouves = (
27
+ dossier
28
+ for dossier in racine.iterdir()
29
+ if dossier.is_dir() and (dossier / MANIFESTE).is_file()
30
+ )
31
+ return tuple(sorted(trouves, key=lambda dossier: dossier.name))
voight_kampff/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Rapport d'analyse : modèle de données (T11), exports HTML et JSON (T16)."""