diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c16f55b..a4d3206 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -47,6 +47,11 @@ box reads as "forgotten", an explicit `N/A` reads as "considered". EN **and** FR — `fullhelp` must never describe a command that no longer exists - [ ] Checked with `DSOXLAB_LANG=en` and `DSOXLAB_LANG=fr` +- [ ] Any new `logger.*` message is written **in English**. The log is not + interface text — it never goes through `_()` — but it is the file + `dsoxlab support` collects: it gets searched word for word, and compared + between machines with different locales. `test_journal_en_anglais.py` + enforces this. ### When `.github/workflows/` is touched — otherwise N/A diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 282d100..5606977 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,33 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.83] - 2026-08-24 + +### Modifié + +- **Le journal parle désormais une seule langue, et c'est l'anglais** + (issue #140). Il mélangeait le français et l'anglais, ce qui n'est pas une + question de goût : c'est le fichier que `dsoxlab support` collecte et qu'un + utilisateur colle dans un rapport de bug. Mesuré avant d'être corrigé — + **41 messages français contre 4 anglais**. + + Les appels `logger.*` restent **délibérément hors** du garde-fou i18n : un + message de journal n'est pas un texte d'interface, il ne passe par `_()` nulle + part, et le traduire à l'exécution rendrait deux rapports de bug incomparables + selon la locale de qui les produit. Cette exclusion justifiait de ne pas le + *traduire*, pas de le laisser incohérent. + + L'anglais l'emporte pour trois raisons : un message de journal se cherche + **mot pour mot** dans un moteur de recherche, il se compare entre deux + machines aux locales différentes, et il est lu par quelqu'un qui diagnostique + — à côté des sorties de terraform, ansible et virsh, qui sont déjà anglaises. + + La règle est écrite là où un contributeur la rencontre (le gabarit de PR) et + tenue par `test_journal_en_anglais.py`. Sans test, elle se redéferait ligne + par ligne, ce qui est exactement ce qui est arrivé à l'interface avant que son + propre garde-fou n'existe. + + ## [0.1.82] - 2026-08-24 ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba4d53..ff8f993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.83] - 2026-08-24 + +### Changed + +- **The log now speaks one language, and it is English** (issue #140). It mixed + French and English, which is not a matter of taste: this is the file + `dsoxlab support` collects and that a user pastes into a bug report. Measured + before being fixed — **41 French messages against 4 English ones**. + + `logger.*` calls stay **deliberately outside** the i18n guard: a log line is + not interface text, it never goes through `_()`, and translating it at + runtime would make two bug reports incomparable depending on the locale of + whoever produced them. That exclusion justified not *translating* the log, + not leaving it incoherent. + + English wins for three reasons: a log line gets searched **word for word**, + it gets compared between machines with different locales, and it is read by + someone diagnosing — next to the output of terraform, ansible and virsh, + which is already English. + + The rule is written where a contributor meets it (the PR template) and held + by `test_journal_en_anglais.py`. Without a test it would come undone line by + line, which is exactly what happened to the interface before its own guard + existed. + + ## [0.1.82] - 2026-08-24 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 3848360..6fb14ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.82" +version = "0.1.83" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/cli/etat.py b/src/dsoxlab/cli/etat.py index 726378e..1f784f7 100644 --- a/src/dsoxlab/cli/etat.py +++ b/src/dsoxlab/cli/etat.py @@ -202,7 +202,7 @@ def _etat_domaine(fqdn: str) -> libvirt.DomainStatus | None: try: return libvirt.inspect_host(fqdn, known=domaines_connus) except CommandError as exc: - logger.debug("état libvirt indisponible pour %s : %s", fqdn, exc) + logger.debug("libvirt state unavailable for %s: %s", fqdn, exc) return None ok_count = 0 diff --git a/src/dsoxlab/config.py b/src/dsoxlab/config.py index edb3798..13f3798 100644 --- a/src/dsoxlab/config.py +++ b/src/dsoxlab/config.py @@ -113,14 +113,14 @@ def read_context(root: Path) -> ActiveContext: except (json.JSONDecodeError, OSError, UnicodeDecodeError): # UnicodeDecodeError descend de ValueError, pas d'OSError : un fichier # d'octets arbitraires passait donc à travers l'ancien filet. - logger.warning("Contexte illisible, ignoré : %s", path) + logger.warning("Unreadable context, ignored: %s", path) return ActiveContext() # La racine du document peut être n'importe quel type JSON. Sur une liste # ou une chaîne, `.get()` n'existe pas et lève un AttributeError que rien # n'attrapait. if not isinstance(data, dict): - logger.warning("Contexte non conforme (racine %s), ignoré : %s", + logger.warning("Malformed context (root %s), ignored: %s", type(data).__name__, path) return ActiveContext() diff --git a/src/dsoxlab/discovery/repo.py b/src/dsoxlab/discovery/repo.py index 68d306f..5ed872a 100644 --- a/src/dsoxlab/discovery/repo.py +++ b/src/dsoxlab/discovery/repo.py @@ -85,7 +85,7 @@ def read_repo_metadata( # Erreur de résolution provider : on remonte le message # complet à l'utilisateur via stderr (la CLI catch et # affiche), au lieu d'un simple warning silencieux. - logger.error("meta.yml : %s", exc) + logger.error("meta.yml: %s", exc) # Re-raise pour que l'erreur de provider ambigu remonte au # CLI (qui guidera vers ``dsoxlab use``). raise diff --git a/src/dsoxlab/discovery/scanner.py b/src/dsoxlab/discovery/scanner.py index 73cb96b..8453342 100644 --- a/src/dsoxlab/discovery/scanner.py +++ b/src/dsoxlab/discovery/scanner.py @@ -142,13 +142,13 @@ def scan_catalog( # la même chose, dont une en français quel que soit DSOXLAB_LANG. La # trace reste dans le fichier de journal, qui garde tout le DEBUG. logger.debug( - "lab.yaml écarté (%s) : il déclare schema_version %d, " - "au-delà de la version %d que ce dsoxlab sait lire.", + "lab.yaml skipped (%s): it declares schema_version %d, " + "beyond version %d that this dsoxlab can read.", yaml_path, exc.found, exc.supported, ) scan.unsupported.append(exc) except (KeyError, ValueError, yaml.YAMLError) as exc: - logger.warning("lab.yaml ignoré (%s) : %s", yaml_path, exc) + logger.warning("lab.yaml ignored (%s): %s", yaml_path, exc) scan.illisibles.append((yaml_path, f"{type(exc).__name__}: {exc}")) scan.labs = _sort_labs(scan.labs, root, repo_meta) diff --git a/src/dsoxlab/infra/inventory.py b/src/dsoxlab/infra/inventory.py index c98d5b3..9aaa271 100644 --- a/src/dsoxlab/infra/inventory.py +++ b/src/dsoxlab/infra/inventory.py @@ -128,8 +128,8 @@ def build_inventory( ip = tf_hosts.get(host_def.name) or host_def.ip if not ip: logger.warning( - "Host %s sans IP : ni outputs Terraform ni meta.yml ip:. " - "Lance d'abord 'dsoxlab provision'.", + "Host %s has no IP: neither Terraform outputs nor meta.yml ip:. " + "Run 'dsoxlab provision' first.", host_def.name, ) continue @@ -399,7 +399,7 @@ def _host_ready_timeout(explicite: float | None) -> float: valeur = float(brut) except ValueError: logger.warning( - "%s=%r n'est pas un nombre : on garde %.0f s.", + "%s=%r is not a number: keeping %.0f s.", HOST_READY_TIMEOUT_ENV, brut, HOST_READY_TIMEOUT_DEFAULT, @@ -407,7 +407,7 @@ def _host_ready_timeout(explicite: float | None) -> float: return HOST_READY_TIMEOUT_DEFAULT if valeur <= 0: logger.warning( - "%s=%r doit être positif : on garde %.0f s.", + "%s=%r must be positive: keeping %.0f s.", HOST_READY_TIMEOUT_ENV, brut, HOST_READY_TIMEOUT_DEFAULT, @@ -440,7 +440,7 @@ def _reset_kvm_domain(repo_meta: RepoMetadata, fqdn: str) -> bool: except CommandError: return False if res.ok: - logger.info("reset envoyé à %s (déblocage du premier boot)", fqdn) + logger.info("reset sent to %s (unblocking first boot)", fqdn) return True return False @@ -589,7 +589,7 @@ def wait_for_hosts_ready( check=False, ) if proc.returncode == 0: - logger.info("Host %s prêt (tentative %d).", fqdn, attempt) + logger.info("Host %s ready (attempt %d).", fqdn, attempt) code, detail = _etat_cloud_init(proc.stdout) if code is not None and code != 0: avertissements.append(_( @@ -662,7 +662,7 @@ def read_terraform_outputs(repo_meta: RepoMetadata) -> dict[str, Any] | None: check=False, ) except Exception as exc: # noqa: BLE001 — best-effort - logger.debug("Lecture outputs Terraform impossible : %s", exc) + logger.debug("Cannot read Terraform outputs: %s", exc) return None if not result.ok or not result.stdout.strip(): diff --git a/src/dsoxlab/infra/libvirt.py b/src/dsoxlab/infra/libvirt.py index 16f6e91..306c0d5 100644 --- a/src/dsoxlab/infra/libvirt.py +++ b/src/dsoxlab/infra/libvirt.py @@ -132,12 +132,12 @@ def _prefixe(*, timeout: int = _TIMEOUT) -> list[str]: for candidat in ([], ["sudo", "-n"]): if _sonder(candidat, timeout=timeout): logger.debug( - "virsh joignable avec le préfixe %r sur %s", candidat, _uri() + "virsh reachable with prefix %r on %s", candidat, _uri() ) _prefixe_retenu = candidat return candidat - logger.debug("virsh injoignable, ni en direct ni par sudo -n") + logger.debug("virsh unreachable, neither directly nor through sudo -n") _prefixe_retenu = [] return _prefixe_retenu @@ -232,7 +232,7 @@ def resolve_domain(host_fqdn: str, *, known: list[str] | None = None) -> str: candidates = [host_fqdn] if short == host_fqdn else [host_fqdn, short] for candidate in candidates: if candidate in domains: - logger.debug("domaine libvirt résolu : %s → %s", host_fqdn, candidate) + logger.debug("libvirt domain resolved: %s -> %s", host_fqdn, candidate) return candidate raise DomainNotFound(host_fqdn, candidates, domains) @@ -345,7 +345,7 @@ def inspect_host(host_fqdn: str, *, known: list[str] | None = None) -> DomainSta except CommandError as exc: # Le domaine existe — on vient de le résoudre. Ne pas connaître son état # n'autorise pas à le déclarer absent. - logger.debug("état indisponible pour %s : %s", domain, exc) + logger.debug("state unavailable for %s: %s", domain, exc) return DomainStatus(host=host_fqdn, domain=domain) adresses = lease_addresses(domain) if etat in RUNNING_STATES else [] return DomainStatus(host=host_fqdn, domain=domain, state=etat, addresses=adresses) diff --git a/src/dsoxlab/infra/snapshot/kvm.py b/src/dsoxlab/infra/snapshot/kvm.py index 11b3c34..3f82fb2 100644 --- a/src/dsoxlab/infra/snapshot/kvm.py +++ b/src/dsoxlab/infra/snapshot/kvm.py @@ -251,7 +251,7 @@ def _delete_volume(pool: str, path: str) -> bool: result = run_virsh(["vol-delete", "--pool", pool, nom], check=False, timeout=120) if not result.ok: logger.warning( - "vol-delete sans effet pour %s dans %s : %s", + "vol-delete had no effect for %s in %s: %s", nom, pool, result.stderr.strip(), ) return result.ok @@ -298,7 +298,7 @@ def create(repo_meta: RepoMetadata, hosts: list[str], name: str) -> None: for fqdn in hosts: domain = resolve_domain(fqdn, known=known) if name in _snapshot_names(domain): - logger.info("snapshot %s déjà présent sur %s : remplacé", name, domain) + logger.info("snapshot %s already present on %s: replaced", name, domain) _drop(domain, name) args = [ "snapshot-create-as", @@ -310,7 +310,7 @@ def create(repo_meta: RepoMetadata, hosts: list[str], name: str) -> None: ] for cible, chemin in sorted(_writable_disks(domain).items()): args += ["--diskspec", f"{cible},snapshot=external,file={chemin}.{name}"] - logger.info("virsh snapshot-create-as %s %s (externe)", domain, name) + logger.info("virsh snapshot-create-as %s %s (external)", domain, name) run_virsh(args, timeout=300) @@ -337,7 +337,7 @@ def revert(repo_meta: RepoMetadata, hosts: list[str], name: str) -> None: if tournait: run_virsh(["destroy", domain], timeout=120) for couche in couches: - logger.info("retour arrière %s:%s → %s", domain, couche.target, couche.base) + logger.info("revert %s:%s -> %s", domain, couche.target, couche.base) _reset_overlay(couche) if tournait: run_virsh(["start", domain], timeout=300) @@ -374,7 +374,7 @@ def delete(repo_meta: RepoMetadata, hosts: list[str], name: str) -> None: try: domain = resolve_domain(fqdn, known=known) except DomainNotFound as exc: - logger.warning("snapshot-delete ignoré : %s", exc) + logger.warning("snapshot-delete skipped: %s", exc) continue _drop(domain, name) @@ -395,7 +395,7 @@ def _drop(domain: str, name: str) -> None: return except CommandError as exc: logger.warning( - "snapshot-delete a échoué pour %s/%s : %s", + "snapshot-delete failed for %s/%s: %s", domain, name, exc.result.stderr.strip(), ) result = run_virsh( @@ -403,12 +403,12 @@ def _drop(domain: str, name: str) -> None: ) if result.ok: logger.warning( - "snapshot %s/%s oublié sans fusion : le recouvrement reste la " - "couche vive du disque", domain, name, + "snapshot %s/%s dropped without merge: the overlay remains the " + "live disk layer", domain, name, ) else: logger.warning( - "snapshot %s/%s non supprimé : %s", domain, name, result.stderr.strip() + "snapshot %s/%s not deleted: %s", domain, name, result.stderr.strip() ) @@ -434,7 +434,7 @@ def purge(repo_meta: RepoMetadata, hosts: list[str]) -> list[str]: try: known = list_domains() except CommandError as exc: - logger.warning("purge des snapshots impossible : %s", exc) + logger.warning("cannot purge snapshots: %s", exc) return retires for fqdn in hosts: @@ -446,7 +446,7 @@ def purge(repo_meta: RepoMetadata, hosts: list[str]) -> list[str]: try: couches = _snapshot_layers(domain, name) except (SnapshotError, CommandError) as exc: - logger.warning("snapshot %s/%s illisible : %s", domain, name, exc) + logger.warning("snapshot %s/%s unreadable: %s", domain, name, exc) couches = [] run_virsh( ["snapshot-delete", domain, name, "--metadata"], diff --git a/src/dsoxlab/infra/terraform.py b/src/dsoxlab/infra/terraform.py index a36dc58..a6dce04 100644 --- a/src/dsoxlab/infra/terraform.py +++ b/src/dsoxlab/infra/terraform.py @@ -334,7 +334,7 @@ def _domains_in_state(state_file: Path) -> set[str] | None: try: data = json.loads(state_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - logger.warning("tfstate illisible (%s) : %s", state_file, exc) + logger.warning("tfstate unreadable (%s): %s", state_file, exc) return None if not isinstance(data, dict): return None @@ -526,7 +526,7 @@ def _ensure_kvm_dhcp_leases( except CommandError as exc: res = exc.result if res.ok: - logger.info("bail DHCP ajouté à chaud: %s -> %s (%s)", host.name, ip, mac) + logger.info("DHCP lease added live: %s -> %s (%s)", host.name, ip, mac) else: # Best-effort ne veut pas dire muet. Sans ce bail, l'hôte n'obtiendra # pas son IP et l'attente échouera plus tard sur un « injoignable » @@ -535,7 +535,7 @@ def _ensure_kvm_dhcp_leases( # est un silence. erreur = (res.stderr or res.stdout).strip() logger.warning( - "bail DHCP refusé pour %s (%s) : %s", host.name, mac, erreur, + "DHCP lease refused for %s (%s): %s", host.name, mac, erreur, ) avertissements.append( _("provision_lease_refused", host=host.name, mac=mac, error=erreur) @@ -900,7 +900,7 @@ def _read_outputs(tf_dir: Path, *, env: dict[str, str] | None = None) -> Provisi try: outputs = json.loads(result.stdout) except json.JSONDecodeError: - logger.warning("Sortie 'terraform output -json' non parsable.") + logger.warning("Output of 'terraform output -json' is not parsable.") hosts_output = outputs.get("hosts", {}).get("value", {}) hosts: dict[str, str] = { diff --git a/src/dsoxlab/interrupt.py b/src/dsoxlab/interrupt.py index e1f6805..84f7c71 100644 --- a/src/dsoxlab/interrupt.py +++ b/src/dsoxlab/interrupt.py @@ -155,12 +155,12 @@ def is_requested(self) -> bool: def _handler(self, signum: int, frame: FrameType | None) -> None: del frame self.count += 1 - logger.info("signal %d reçu (%d fois)", signum, self.count) + logger.info("signal %d received (%d times)", signum, self.count) if self._on_notice is not None: try: self._on_notice(self.count) except Exception: # un affichage ne casse pas un arrêt - logger.exception("notification d'interruption en échec") + logger.exception("interrupt notification failed") if self.count >= 2: # Le premier signal a demandé l'annulation ; le second dit que # l'utilisateur n'attend plus. On repasse par le chemin normal de diff --git a/src/dsoxlab/locking.py b/src/dsoxlab/locking.py index ead02fa..b5e1acf 100644 --- a/src/dsoxlab/locking.py +++ b/src/dsoxlab/locking.py @@ -237,8 +237,8 @@ def acquire(self) -> None: # Dégradé assumé et tracé : mieux vaut un outil qui travaille # sans filet qu'un outil qui refuse de démarrer. logger.warning( - "verrou indisponible sur %s (%s) : la commande continue " - "sans protection contre une invocation concurrente", + "lock unavailable on %s (%s): the command proceeds " + "without protection against a concurrent invocation", self.path, exc.strerror, ) self._fd = fd @@ -262,7 +262,7 @@ def _ecrire_detenteur(self, fd: int) -> None: os.lseek(fd, 0, os.SEEK_SET) os.write(fd, charge.encode("utf-8")) except OSError: - logger.warning("verrou pris, mais son détenteur n'a pas pu être inscrit") + logger.warning("lock acquired, but its holder could not be recorded") def release(self) -> None: """Relâche le verrou et efface la trace du détenteur. @@ -278,7 +278,7 @@ def release(self) -> None: try: os.ftruncate(fd, 0) except OSError: - logger.debug("troncature du verrou impossible", exc_info=True) + logger.debug("cannot truncate the lock file", exc_info=True) self._degrade = False os.close(fd) diff --git a/src/dsoxlab/runtimes/shell.py b/src/dsoxlab/runtimes/shell.py index d92ee2a..00305c0 100644 --- a/src/dsoxlab/runtimes/shell.py +++ b/src/dsoxlab/runtimes/shell.py @@ -109,7 +109,7 @@ def start( # l'arborescence s'écrasaient l'un l'autre). dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) - logger.info("fixture %s → %s", src.name, dst) + logger.info("fixture %s -> %s", src.name, dst) def session_spec(self, lab: LabDefinition) -> SessionSpec: """Un sous-shell dans ``/``. @@ -152,7 +152,7 @@ def clean( workdir = self._workdir_path(lab) if workdir.exists(): shutil.rmtree(workdir) - logger.info("workdir supprimé : %s", workdir) + logger.info("workdir removed: %s", workdir) def status(self, lab: LabDefinition, target_name: str | None = None) -> str: del target_name diff --git a/src/dsoxlab/runtimes/vm.py b/src/dsoxlab/runtimes/vm.py index 309c36a..cf7365f 100644 --- a/src/dsoxlab/runtimes/vm.py +++ b/src/dsoxlab/runtimes/vm.py @@ -188,7 +188,7 @@ def clean( snapshot_infra.delete(repo_meta, [target.host], self._snap_name(lab)) except Exception as exc: # noqa: BLE001 — nettoyage best-effort logger.warning( - "Point de reprise %s non retiré : %s", self._snap_name(lab), exc + "Checkpoint %s not removed: %s", self._snap_name(lab), exc ) def status(self, lab: LabDefinition, target_name: str | None = None) -> str: diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py index 2aa1e5c..a590ac6 100644 --- a/tests/test_i18n_coverage.py +++ b/tests/test_i18n_coverage.py @@ -57,8 +57,12 @@ rapports de bug incomparables selon la locale de qui les produit, et se heurte au formatage paresseux (``logger.info("x %s", v)``) que la famille de règles ``G`` impose ici : ``_()`` formate à l'appel, ``logging`` au rendu. Le journal -doit être *cohérent* — il mélange aujourd'hui le français et l'anglais, ce qui -est un vrai défaut — mais cohérent n'est pas traduit, et c'est un autre lot. +doit être *cohérent* sans être traduit, et il l'est depuis #140 : **il s'écrit +en anglais**, règle tenue par ``test_journal_en_anglais.py``. Un message de +journal se cherche mot pour mot dans un moteur de recherche, se compare entre +deux machines aux locales différentes, et voisine déjà avec les sorties de +terraform, ansible et virsh. Il reste hors du périmètre de CE garde-fou : ce +n'est pas de l'interface, et il ne passe pas par ``_()``. **``models/`` est dans le périmètre depuis #139**, et la dette qui l'en tenait dehors est soldée. Les 24 ``ValueError`` du contrat ont été triées sur une seule diff --git a/tests/test_journal_en_anglais.py b/tests/test_journal_en_anglais.py new file mode 100644 index 0000000..7d657a0 --- /dev/null +++ b/tests/test_journal_en_anglais.py @@ -0,0 +1,126 @@ +"""Le journal parle une seule langue, et c'est l'anglais (#140). + +Le journal mélangeait le français et l'anglais. Ce n'est pas un détail +d'esthétique : c'est le fichier que `dsoxlab support` collecte et qu'un +utilisateur colle dans un rapport de bug. + +Les appels `logger.*` sont **délibérément exclus** du garde-fou i18n +(`test_i18n_coverage.py`), et cette exclusion tient toujours : un message de +journal n'est pas un texte d'interface, il ne passe pas par `_()`, et le +traduire à l'exécution rendrait deux rapports incomparables selon la locale de +qui les produit. Mais l'exclusion justifiait de ne pas le **traduire**, pas de +le laisser incohérent. + +L'anglais l'emporte pour trois raisons, dans l'ordre de poids : + +1. un message de journal se cherche **mot pour mot** dans un moteur de + recherche ; +2. il se compare entre deux machines aux locales différentes ; +3. il est lu par quelqu'un qui **diagnostique**, pas par quelqu'un qui apprend — + et il voisine déjà avec les sorties de terraform, ansible et virsh, qui sont + anglaises. + +Sans ce test, la règle se redéfera ligne par ligne, ce qui est exactement ce +qui s'est passé pour l'interface avant que son garde-fou n'existe. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import dsoxlab + +RACINE = Path(dsoxlab.__file__).resolve().parent + +_NIVEAUX = {"debug", "info", "warning", "error", "exception", "critical"} + +#: Des mots qui n'existent qu'en français, et qu'aucun nom d'outil, d'option ou +#: de chemin ne porte. La liste est volontairement courte : elle doit produire +#: **zéro** faux positif, faute de quoi on apprendrait à l'ignorer. Elle ne +#: prétend pas détecter tout le français — un message qui y échappe passera, +#: et c'est assumé : ce test est un garde-fou, pas un correcteur. +_MOTS_FRANCAIS = frozenset({ + "aucun", "aucune", "avec", "chemin", "commande", "dans", "depuis", "déjà", + "échec", "échoué", "écarté", "état", "fichier", "ignoré", "ignorée", + "illisible", "impossible", "introuvable", "lecture", "les", "mais", + "nest", "pas", "pour", "sans", "sur", "vers", "verrou", +}) + +_MOT = re.compile(r"[a-zà-ÿ]+", re.IGNORECASE) + + +def _messages_de_journal() -> list[tuple[str, int, str]]: + """Chaque littéral passé en premier argument d'un `logger.*`.""" + trouves: list[tuple[str, int, str]] = [] + for chemin in sorted(RACINE.rglob("*.py")): + arbre = ast.parse(chemin.read_text(encoding="utf-8")) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Call): + continue + fonction = noeud.func + if not (isinstance(fonction, ast.Attribute) + and fonction.attr in _NIVEAUX): + continue + if getattr(fonction.value, "id", "") != "logger" or not noeud.args: + continue + premier = noeud.args[0] + if isinstance(premier, ast.Constant) and isinstance(premier.value, str): + trouves.append(( + str(chemin.relative_to(RACINE)), noeud.lineno, premier.value, + )) + return trouves + + +def _mots_francais(message: str) -> set[str]: + return {m.lower() for m in _MOT.findall(message)} & _MOTS_FRANCAIS + + +def test_la_lecture_des_sources_est_representative() -> None: + """Sans ce contrôle, une lecture cassée rendrait le suivant toujours vert. + + C'est le motif que tout ce lot corrige, et il vaut d'abord pour le + garde-fou lui-même. + """ + assert len(_messages_de_journal()) >= 40 + + +def test_aucun_message_de_journal_n_est_en_francais() -> None: + """La règle, tenue par un test plutôt que par la bonne volonté.""" + coupables = [ + f"{fichier}:{ligne} {message[:60]} ← {sorted(mots)}" + for fichier, ligne, message in _messages_de_journal() + if (mots := _mots_francais(message)) + ] + + assert coupables == [], ( + "ces messages de journal sont en français ; le journal est en anglais, " + "parce qu'il se cherche mot pour mot et se compare entre machines :\n " + + "\n ".join(coupables) + ) + + +def test_le_detecteur_mord_sur_un_message_francais() -> None: + """L'autre bout : un détecteur qui ne détecte rien passerait aussi au vert. + + Il faut donc lui montrer un message qu'il **doit** attraper, sinon le test + précédent ne prouve que l'absence de bug dans la liste de mots. + """ + assert _mots_francais("verrou indisponible sur %s : commande ignorée") + assert _mots_francais("Contexte illisible, ignoré : %s") + + +def test_le_detecteur_epargne_l_anglais_et_les_noms_techniques() -> None: + """Un faux positif ferait désactiver le contrôle plutôt que corriger. + + Les noms d'outils, d'options et de chemins ne doivent jamais le déclencher. + """ + for message in ( + "lock unavailable on %s (%s): the command proceeds without protection", + "virsh snapshot-create-as %s %s (external)", + "run: %s (cwd=%s)", + "terraform output -json failed: %s", + "DHCP lease added live: %s -> %s (%s)", + ): + assert not _mots_francais(message), f"faux positif sur : {message}" diff --git a/tests/test_snapshot_kvm.py b/tests/test_snapshot_kvm.py index 0b406f3..e004d36 100644 --- a/tests/test_snapshot_kvm.py +++ b/tests/test_snapshot_kvm.py @@ -746,7 +746,8 @@ def test_un_libvirt_ancien_retombe_sur_l_oubli_de_la_metadonnee( assert kvm.list_(meta, "web1.lab") == [] assert "travail-de-l-apprenant" in faux.contenu("web1.lab") assert faux.domaines["web1.lab"]["disques"]["vda"] in faux.fichiers() - assert "sans fusion" in caplog.text + # Le journal est en anglais depuis #140 : l'assertion suit la règle. + assert "without merge" in caplog.text def test_delete_tolere_un_domaine_absent_mais_le_journalise( diff --git a/uv.lock b/uv.lock index e70f8e6..c1bd419 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.82" +version = "0.1.83" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },