Skip to content

Commit 5f1e09c

Browse files
committed
fix(i18n): normalize language codes before lookup, guard unknown locale
Both from the cross-family review of this PR. Canonicalization ran *after* the locale-table lookup, so the two passes are collapsed into one that normalizes /B -> /T before anything is looked up. Previously a locale table that ever gained a 'ger' key would capture the code in the first pass and store it verbatim -- a code the rest of the stack cannot render -- and would stop de-duplicating a book that declares both 'ger' and 'deu'. The shipped tables are /T-only so this was latent, not live; the point is not to depend on that holding forever. Collapsing the passes surfaced that names.items() on an unrecognised locale had always raised AttributeError, even though get_language_names() is documented to return None for one and get_language_name() already guards it. The validator guards it too now and falls through to the reference table, so the book imports instead of 500-ing the request. Tests, also from the review: - edit_book_languages(upload_mode=True) is now under test. It is the only production caller and the place remainder becomes the reporter's ValueError, and nothing exercised it -- every helper test could stay green while a refactor stopped passing upload_mode or mishandled the remainder loop. - Replaced the canonical_lang_code monkeypatch test, which only proved the helper was called and would pass if it mapped everything to 'eng', with a public-boundary test that pins the ordering invariant above. - test_alias_targets_resolve_through_the_language_backend claimed to run on either backend but asserted pycountry's specific AttributeError; it now asserts the semantic condition and tolerates the backend's miss form. - Added test_reference_table_is_the_superset_of_every_locale, the property that makes the reference table a valid oracle, and reworded its docstring, which implied an ISO registry rather than generated application data. Kept the three _resolve_lang_code empty-entry tests the review called opportunistic: they pin a contract the docstring now states, and a documented invariant with no test is the drift risk.
1 parent 2034d7d commit 5f1e09c

2 files changed

Lines changed: 158 additions & 29 deletions

File tree

cps/isoLanguages.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,11 @@ def canonical_lang_code(lang_code):
115115

116116

117117
def _reference_language_codes():
118-
"""Every language code the app knows, independent of UI locale.
118+
"""The complete checked-in reference table, independent of UI locale.
119+
120+
This is the application's own language data, not an ISO registry: it is
121+
the one locale table that is complete, and ``test_reference_table_is_the
122+
_superset_of_every_locale`` pins that it stays a superset of every other.
119123
120124
The per-locale tables in LANGUAGE_NAMES are *translation* data and are
121125
legitimately incomplete — 27 of the 28 shipped locales are missing between
@@ -164,23 +168,28 @@ def get_valid_language_codes_from_code(locale, language_names, remainder=None):
164168
lang = list()
165169
if "" in language_names:
166170
language_names.remove("")
167-
names = get_language_names(locale)
168-
for k, __ in names.items():
169-
if k in language_names:
170-
lang.append(k)
171-
language_names.remove(k)
172-
# Second pass over whatever the locale table did not claim. Validity is a
173-
# property of the code, not of the current locale's translation coverage,
174-
# so this pass checks the reference table — which both accepts /B input by
175-
# normalizing it to /T ('ger' from an OPF is a valid language, and 'deu' is
176-
# the form the rest of the stack can render), and stops a valid /T code
177-
# being refused just because the user's locale has no name for it.
178-
# Without this, upload rejects the book with "'ger' is not a valid
179-
# language".
171+
names = get_language_names(locale) or {}
172+
# An unrecognised locale used to crash here (AttributeError on None.items),
173+
# even though get_language_names is explicitly documented to return None
174+
# for one and get_language_name already guards it. Validity does not depend
175+
# on the locale table anyway, so fall through to the reference and let the
176+
# book import rather than 500 the request.
180177
reference = _reference_language_codes()
178+
# Normalize /B -> /T *before* looking anything up. Doing the locale-table
179+
# lookup first and only aliasing the leftovers would let a locale table
180+
# that ever gained a /B key capture 'ger' unchanged — storing a code the
181+
# rest of the stack cannot render, and defeating the de-duplication of a
182+
# book that declares both 'ger' and 'deu'. The current tables are /T-only,
183+
# so this is about not depending on that holding forever.
181184
for code in list(language_names):
182185
canonical = canonical_lang_code(code)
183-
if canonical in reference:
186+
# Validity is a property of the code, not of the current locale's
187+
# translation coverage, so the reference table is what decides it —
188+
# otherwise a valid /T code is refused just because the user's locale
189+
# has no name for it, and upload rejects the book with "'ell' is not
190+
# a valid language". The locale table is still consulted so a code it
191+
# somehow carries that the reference lacks stays accepted.
192+
if canonical in names or canonical in reference:
184193
if canonical not in lang:
185194
lang.append(canonical)
186195
language_names.remove(code)

tests/unit/test_1109_iso6392b_language_codes.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,24 @@ def test_alias_targets_resolve_through_the_language_backend():
5555
This one runs on either backend: every /T target must resolve through the
5656
module's own ``get()``, and no /B code may — if a /B code resolved
5757
directly, it would not need aliasing and the table entry would be wrong.
58+
59+
The *miss* is asserted semantically rather than as a specific exception.
60+
pycountry's wrapper happens to raise AttributeError (it calls
61+
_copy_fields(None)), but iso-639 is free to return None or raise
62+
KeyError, and pinning one backend's failure mode would make this the
63+
pycountry-only test it claims not to be.
5864
"""
65+
def resolves(code):
66+
try:
67+
return bool(getattr(isoLanguages.get(part3=code), "name", None))
68+
except Exception:
69+
return False
70+
5971
for b_code, t_code in isoLanguages.ISO6392B_TO_T.items():
60-
record = isoLanguages.get(part3=t_code)
61-
assert getattr(record, "name", None), \
72+
assert resolves(t_code), \
6273
f"/T target {t_code!r} does not resolve through the backend"
63-
with pytest.raises(AttributeError):
64-
isoLanguages.get(part3=b_code)
74+
assert not resolves(b_code), \
75+
f"/B code {b_code!r} resolves directly, so it does not need aliasing"
6576

6677

6778
def test_iso6392b_table_is_not_self_mapping():
@@ -258,20 +269,26 @@ def test_canonical_lang_code_maps_bibliographic():
258269
assert isoLanguages.canonical_lang_code("ger") == "deu"
259270

260271

261-
def test_validator_normalizes_through_canonical_lang_code(monkeypatch):
262-
"""The normalizer must be the one the validator actually uses.
272+
def test_normalization_precedes_the_locale_table_lookup(monkeypatch):
273+
"""A /B key in a locale table must not capture the code before aliasing.
263274
264-
A public helper that duplicates a lookup the production path does inline
265-
is dead code that can silently drift from it. Patching the helper must
266-
therefore change what the validator stores.
275+
The shipped tables are /T-only, so this is about not silently depending on
276+
that. If the locale lookup ran first and only the leftovers were aliased,
277+
a table that ever gained a "ger" key would store 'ger' verbatim — a code
278+
the rest of the stack cannot render — and a book declaring both forms
279+
would store two rows for one language instead of de-duplicating.
267280
"""
268281
monkeypatch.setattr(
269-
isoLanguages, "canonical_lang_code", lambda code: "eng"
282+
isoLanguages, "get_language_names",
283+
lambda locale: {"ger": "German (B)", "deu": "German (T)"},
270284
)
271-
remainder = []
272-
out = isoLanguages.get_valid_language_codes_from_code("en", ["ger"], remainder)
273-
assert out == ["eng"], "validator does not route through canonical_lang_code"
274-
assert remainder == []
285+
for order in (["ger", "deu"], ["deu", "ger"], ["ger"]):
286+
remainder = []
287+
out = isoLanguages.get_valid_language_codes_from_code(
288+
"en", list(order), remainder
289+
)
290+
assert out == ["deu"], f"{order} stored {out!r}, expected ['deu']"
291+
assert remainder == []
275292

276293

277294
# --------------------------------------------------------------------------
@@ -298,3 +315,106 @@ def test_present_and_non_empty_entry_resolves():
298315
def test_canonical_lang_code_passes_through_everything_else():
299316
for code in ("deu", "eng", "", "totallymadeupcode"):
300317
assert isoLanguages.canonical_lang_code(code) == code
318+
319+
320+
# --------------------------------------------------------------------------
321+
# The invariant that makes _reference_language_codes() a valid oracle.
322+
# --------------------------------------------------------------------------
323+
324+
def test_reference_table_is_the_superset_of_every_locale():
325+
"""Validation resolves against the reference table, so it must not be
326+
possible for a locale to know a code the reference does not.
327+
328+
If a future translation adds a code to, say, `de` but not `en`, the
329+
reference stops being an oracle and this fails rather than silently
330+
narrowing what uploads accept.
331+
"""
332+
from cps.iso_language_names import LANGUAGE_NAMES
333+
334+
reference = set(isoLanguages._reference_language_codes())
335+
for locale, table in LANGUAGE_NAMES.items():
336+
extra = set(table) - reference
337+
assert not extra, \
338+
f"locale {locale!r} carries codes absent from the reference: {sorted(extra)}"
339+
340+
341+
# --------------------------------------------------------------------------
342+
# The real caller. get_valid_language_codes_from_code() is only reachable in
343+
# production through edit_book_languages(upload_mode=True), which is where
344+
# remainder becomes the ValueError the reporter saw. Pinning the helper alone
345+
# would let a refactor stop passing upload_mode, swap to the name parser, or
346+
# mishandle the remainder loop with every helper test still green.
347+
# --------------------------------------------------------------------------
348+
349+
def _import_editbooks():
350+
import cps.editbooks as editbooks
351+
return editbooks
352+
353+
354+
def _upload_languages(locale, languages):
355+
"""Drive edit_book_languages(upload_mode=True); return stored lang codes.
356+
357+
Everything past the validation branch (the filter-language fixup, the
358+
session write) is mocked out — this pins the accept/reject boundary, not
359+
the DB layer.
360+
"""
361+
from types import SimpleNamespace
362+
from unittest.mock import MagicMock, patch
363+
364+
editbooks = _import_editbooks()
365+
book = SimpleNamespace(id=1, languages=[])
366+
captured = {}
367+
368+
def fake_modify(input_l, db_field, db_type, session, db_name):
369+
captured["langs"] = list(input_l)
370+
return True
371+
372+
user = MagicMock()
373+
user.filter_language.return_value = "all"
374+
375+
with patch.object(editbooks, "get_locale", lambda: locale), \
376+
patch.object(editbooks, "current_user", user), \
377+
patch.object(editbooks, "calibre_db", MagicMock()), \
378+
patch.object(editbooks, "modify_database_object", fake_modify), \
379+
patch.object(editbooks, "log", MagicMock()):
380+
editbooks.edit_book_languages(languages, book, upload_mode=True)
381+
return captured.get("langs", [])
382+
383+
384+
def test_upload_accepts_greek_under_a_locale_missing_the_name():
385+
"""#1109's upload half, at the boundary that actually raised.
386+
387+
'ell' is absent from the pt_BR name table, so validating against that
388+
table dropped it into remainder and edit_book_languages turned it into
389+
ValueError("'ell' is not a valid language") — a Greek book refused for a
390+
Brazilian-Portuguese user and imported fine for an English one.
391+
"""
392+
assert _upload_languages("pt_BR", "ell") == ["ell"]
393+
394+
395+
def test_upload_accepts_bibliographic_code_and_stores_terminological():
396+
assert _upload_languages("en", "ger") == ["deu"]
397+
398+
399+
def test_upload_dedupes_a_book_declaring_both_forms():
400+
assert _upload_languages("en", "ger,deu") == ["deu"]
401+
402+
403+
def test_upload_still_rejects_a_genuinely_invalid_code():
404+
"""The permissive change must not turn the validator into a rubber stamp."""
405+
with pytest.raises(ValueError):
406+
_upload_languages("pt_BR", "zzz")
407+
408+
409+
def test_unknown_locale_does_not_crash_the_validator():
410+
"""get_language_names() is documented to return None for an unrecognised
411+
locale, and get_language_name() has always guarded that. The validator did
412+
not, so the same input raised AttributeError on None.items() and 500'd the
413+
request. Validity does not depend on the locale table, so the book imports.
414+
"""
415+
remainder = []
416+
out = isoLanguages.get_valid_language_codes_from_code(
417+
"xx_YY", ["ger", "eng", "zzz"], remainder
418+
)
419+
assert out == ["deu", "eng"]
420+
assert remainder == ["zzz"]

0 commit comments

Comments
 (0)