Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/dsoxlab/cli/etat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/dsoxlab/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion src/dsoxlab/discovery/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions src/dsoxlab/discovery/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions src/dsoxlab/infra/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -399,15 +399,15 @@ 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,
)
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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(_(
Expand Down Expand Up @@ -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():
Expand Down
8 changes: 4 additions & 4 deletions src/dsoxlab/infra/libvirt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 11 additions & 11 deletions src/dsoxlab/infra/snapshot/kvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)


Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -395,20 +395,20 @@ 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(
["snapshot-delete", domain, name, "--metadata"], check=False, timeout=120
)
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()
)


Expand All @@ -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:
Expand All @@ -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"],
Expand Down
8 changes: 4 additions & 4 deletions src/dsoxlab/infra/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 »
Expand All @@ -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)
Expand Down Expand Up @@ -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] = {
Expand Down
4 changes: 2 additions & 2 deletions src/dsoxlab/interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/dsoxlab/locking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)

Expand Down
Loading