Skip to content
Open
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
43 changes: 38 additions & 5 deletions scripts/epic_body_staleness.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,38 @@ def list_merged_prs(repo: str, limit: int) -> list[MergedPullRequest]:
)[:limit]


def _emit_utf8(text: str, indent: int | None = None) -> None:
"""Ecrit `text` sur stdout en UTF-8, quel que soit le codepage de la console.

Les titres d'EPIC du depot portent des caracteres hors cp1252 (U+21C4 par
exemple). Sur une console Windows cp1252, un `json.dump(..., sys.stdout)`
leve alors UnicodeEncodeError et le rapport entier est perdu, y compris
pour les EPIC dont le titre est encodable.
"""
stream = sys.stdout
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (ValueError, OSError):
# Flux deja detache ou non reconfigurable : on tente l'ecriture,
# le repli ASCII ci-dessous prendra le relais si besoin.
pass
try:
stream.write(text)
except UnicodeEncodeError:
# Dernier recours : ne jamais perdre le rapport pour un caractere.
# On re-serialise en JSON ASCII plutot qu'echapper le texte deja rendu :
# backslashreplace produisait \U0001f9ed pour un caractere astral
# (emoji de titre, mesure : U+1F9ED dans le corpus du 2026-11-05), et
# \Uxxxxxxxx n'est PAS un echappement JSON valide (\uXXXX, 4 chiffres)
# -- le repli cassait lui-meme le contrat qu'il gardait.
stream.write(
json.dumps(json.loads(text), ensure_ascii=True, indent=indent)
)
stream.flush()


def _cli(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", default=None)
Expand All @@ -459,13 +491,14 @@ def _cli(argv: list[str] | None = None) -> int:
print(f"epic_body_staleness: {exc}", file=sys.stderr)
return 1

json.dump(
build_payload(epics, merged_prs),
sys.stdout,
payload = build_payload(epics, merged_prs)
indent = 2 if args.pretty else None
rendered = json.dumps(
payload,
ensure_ascii=False,
indent=2 if args.pretty else None,
indent=indent,
)
print()
_emit_utf8(rendered + "\n", indent)
return 0


Expand Down
62 changes: 62 additions & 0 deletions scripts/tests/test_epic_body_staleness.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import importlib.util
import io
import json
import sys
from pathlib import Path
Expand Down Expand Up @@ -419,6 +420,67 @@ def test_cli_prints_json_and_never_writes(monkeypatch, capsys):
assert payload["corpus"]["open_epics_examined"] == 1


def test_cli_writes_utf8_even_on_a_cp1252_stdout(monkeypatch):
"""Regression: un titre d'EPIC hors cp1252 faisait perdre le rapport entier.

Sur une console Windows cp1252, `json.dump(..., sys.stdout)` levait
UnicodeEncodeError sur U+21C4 et n'ecrivait rien du tout -- pas meme les
EPIC dont le titre etait encodable.
"""
epic = Epic(42, "[EPIC] chaine ⇄ collision", "")
pr = _pr(101, "See #42")
monkeypatch.setattr(_MODULE, "list_open_epics", lambda repo: [epic])
monkeypatch.setattr(_MODULE, "list_merged_prs", lambda repo, limit: [pr])

raw = io.BytesIO()
console = io.TextIOWrapper(raw, encoding="cp1252", newline="")
monkeypatch.setattr(_MODULE.sys, "stdout", console)

assert _MODULE._cli(["--repo", "example/repo"]) == 0
payload = json.loads(raw.getvalue().decode("utf-8"))
assert payload["finding_count"] == 1
assert "⇄" in payload["findings"][0]["title"]


class _StreamSansReconfigure:
"""Flux cp1252 sans ``reconfigure`` : force le repli ASCII de ``_emit_utf8``."""

def __init__(self) -> None:
self._raw = io.BytesIO()

@property
def encoding(self) -> str:
return "cp1252"

def write(self, text: str) -> int:
data = text.encode("cp1252") # strict : leve comme une vraie console
self._raw.write(data)
return len(data)

def flush(self) -> None:
pass

def ascii_text(self) -> str:
return self._raw.getvalue().decode("ascii")


def test_ascii_fallback_stays_valid_json_for_an_astral_title(monkeypatch):
"""Regression (repli de #24622) : un emoji astral cassait l'echappement.

Le repli encodait le texte rendu en ``backslashreplace``, qui produit
``\\U0001f9ed`` pour un caractere astral -- pas un echappement JSON
valide (``\\uXXXX``, 4 chiffres). Le repli devait alors un document
indechiffrable, exactement ce qu'il existait pour eviter.
"""
stream = _StreamSansReconfigure()
monkeypatch.setattr(_MODULE.sys, "stdout", stream)

_MODULE._emit_utf8(json.dumps({"title": "constellation 🧭 07"}, ensure_ascii=False))

payload = json.loads(stream.ascii_text())
assert payload["title"] == "constellation 🧭 07"


def test_cli_reports_read_failure(monkeypatch, capsys):
def fail(_repo):
raise RuntimeError("GitHub unavailable")
Expand Down
Loading