Skip to content

Commit 3cacb11

Browse files
Merge pull request #656 from thomwebb/fix/i18n-audit-false-positives
fix(i18n/audit): eliminate false positives in raw-site classification
2 parents b25b163 + 44f98c4 commit 3cacb11

2 files changed

Lines changed: 140 additions & 3 deletions

File tree

code_puppy/i18n/audit.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,38 @@ def _is_translation_call(node: ast.expr) -> bool:
136136

137137

138138
def _has_string_literal(node: ast.expr) -> bool:
139-
"""True if the expression *contains* a hard-coded string literal."""
139+
"""True if the expression *contains* a hard-coded string literal.
140+
141+
For f-strings we only return True when at least one constant segment
142+
contains non-whitespace text. Pure-variable f-strings like ``f"{x}"``
143+
or ``f" {x}"`` carry no translatable literal and are classified as
144+
dynamic instead.
145+
"""
140146
if isinstance(node, ast.Constant):
141147
return isinstance(node.value, str)
142148
if isinstance(node, ast.JoinedStr): # f-string
143-
return True
149+
# An f-string is only "raw" when it has at least one constant part
150+
# with meaningful (non-whitespace) text, e.g. f"Error: {e}".
151+
# Pure-variable forms like f"{var}" or f" {var}" are dynamic.
152+
#
153+
# Also recurse into ``FormattedValue.value`` so a wrapped literal
154+
# like ``f"{'Error: connection refused'}"`` (which parses as
155+
# ``JoinedStr([FormattedValue(Constant('...'))])``) is still
156+
# classified as raw instead of being dropped as dynamic.
157+
return any(
158+
(
159+
isinstance(v, ast.Constant)
160+
and isinstance(v.value, str)
161+
and v.value.strip()
162+
)
163+
or (
164+
isinstance(v, ast.FormattedValue)
165+
and isinstance(v.value, ast.Constant)
166+
and isinstance(v.value.value, str)
167+
and v.value.value.strip()
168+
)
169+
for v in node.values
170+
)
144171
if isinstance(node, ast.BinOp): # "a" + x, "a" % x
145172
return _has_string_literal(node.left) or _has_string_literal(node.right)
146173
if isinstance(node, ast.Call):
@@ -198,6 +225,16 @@ def audit_source(source: str, path: str) -> List[Site]:
198225

199226

200227
def _iter_py_files(root: str) -> Iterable[str]:
228+
"""Yield every .py file under ``root``.
229+
230+
If ``root`` is itself a ``.py`` file it is yielded directly so that
231+
``python -m code_puppy.i18n.audit path/to/module.py`` works as
232+
expected instead of silently producing an empty report.
233+
"""
234+
if os.path.isfile(root):
235+
if root.endswith(".py"):
236+
yield root
237+
return
201238
for dirpath, dirnames, filenames in os.walk(root):
202239
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
203240
for name in filenames:
@@ -206,7 +243,13 @@ def _iter_py_files(root: str) -> Iterable[str]:
206243

207244

208245
def audit_tree(root: str) -> Report:
209-
"""Audit every Python module under ``root``."""
246+
"""Audit every Python module under ``root``, or ``root`` itself when it is a ``.py`` file."""
247+
if not os.path.isdir(root) and not os.path.isfile(root):
248+
# Silent-zero is worse than useless — an empty Report has
249+
# coverage == 100.0, so a typo'd path would sail past
250+
# ``--fail-under`` and tell CI everything is fine. Fail loud:
251+
# this is a config/programming error, not a data condition.
252+
raise FileNotFoundError(f"audit root does not exist: {root!r}")
210253
report = Report()
211254
for path in sorted(_iter_py_files(root)):
212255
try:

tests/i18n/test_i18n_audit.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for the static i18n extraction audit (code_puppy.i18n.audit)."""
22

3+
import ast
4+
35
from code_puppy.i18n import audit
46

57

@@ -16,6 +18,47 @@ def test_fstring_is_raw():
1618
assert _kinds('emit_warning(f"hi {name}")') == ["raw"]
1719

1820

21+
def test_fstring_pure_variable_is_dynamic():
22+
"""f"{var}" has no literal content — must not be reported as raw."""
23+
assert _kinds('emit_info(f"{result}")') == ["dynamic"]
24+
25+
26+
def test_fstring_whitespace_only_literal_is_dynamic():
27+
"""f" {var}" has only whitespace in the constant part — not translatable."""
28+
assert _kinds('emit_info(f" {msg}")') == ["dynamic"]
29+
30+
31+
def test_fstring_with_content_and_variable_is_raw():
32+
"""f"Error: {e}" has a meaningful literal prefix — must stay raw."""
33+
assert _kinds('emit_error(f"Error: {e}")') == ["raw"]
34+
35+
36+
def test_fstring_wrapped_literal_is_raw():
37+
"""f"{'literal'}" wraps a Constant inside a FormattedValue.
38+
39+
The classifier used to only inspect direct Constant children of
40+
JoinedStr, so this parsed as ``JoinedStr([FormattedValue(Constant)])``
41+
and was silently classified as dynamic — a false negative. Recurse
42+
into FormattedValue.value so it now shows up as raw.
43+
"""
44+
src = "emit_info(f\"{'Error: connection refused'}\")"
45+
# Direct AST check so we're not relying on the higher-level pipeline.
46+
tree = ast.parse(src, mode="eval")
47+
call = tree.body
48+
assert isinstance(call, ast.Call)
49+
joined = call.args[0]
50+
assert isinstance(joined, ast.JoinedStr)
51+
assert isinstance(joined.values[0], ast.FormattedValue)
52+
# And end-to-end through the classifier.
53+
assert audit._classify(joined) == "raw"
54+
assert _kinds(src) == ["raw"]
55+
56+
57+
def test_fstring_with_only_arrow_is_raw():
58+
"""Non-whitespace punctuation like an arrow counts as a literal."""
59+
assert _kinds('emit_info(f"-> {item}")') == ["raw"]
60+
61+
1962
def test_string_concat_is_raw():
2063
assert _kinds('emit_error("bad: " + detail)') == ["raw"]
2164

@@ -108,6 +151,57 @@ def test_json_output_is_valid(tmp_path, capsys):
108151
assert payload["coverage"] == 50.0
109152

110153

154+
def test_single_file_path_is_accepted(tmp_path, capsys):
155+
"""Passing a .py file directly must produce a non-empty report.
156+
157+
Previously ``_iter_py_files`` called ``os.walk(file)`` which yields
158+
nothing, so every per-file audit silently returned 0 sites.
159+
"""
160+
import json
161+
162+
mod = tmp_path / "solo.py"
163+
mod.write_text('emit_info("raw string")\n', encoding="utf-8")
164+
assert audit.main([str(mod), "--json"]) == 0
165+
payload = json.loads(capsys.readouterr().out)
166+
assert payload["raw"] == 1, "single-file audit must find the raw site"
167+
168+
169+
def test_single_file_pure_variable_fstring_not_raw(tmp_path, capsys):
170+
"""Single-file audit: f"{var}" must NOT be counted as raw."""
171+
import json
172+
173+
mod = tmp_path / "mod.py"
174+
mod.write_text('emit_info(f"{result}")\n', encoding="utf-8")
175+
assert audit.main([str(mod), "--json"]) == 0
176+
payload = json.loads(capsys.readouterr().out)
177+
assert payload["raw"] == 0
178+
assert payload["dynamic"] == 1
179+
180+
181+
def test_nonexistent_path_raises(tmp_path, capsys):
182+
"""A typo'd path must not silently report 100% coverage.
183+
184+
Previously ``audit_tree('/does/not/exist')`` returned an empty
185+
``Report`` whose ``coverage`` property is ``100.0``, so
186+
``--fail-under`` would happily pass on a typo'd path and tell CI
187+
everything was fine. Fail loud instead: this is a
188+
programming/config error, so ``FileNotFoundError`` propagates all
189+
the way out of ``main()`` and produces a nonzero exit.
190+
"""
191+
import pytest
192+
193+
missing = tmp_path / "totally-not-real"
194+
assert not missing.exists()
195+
196+
# audit_tree raises directly.
197+
with pytest.raises(FileNotFoundError):
198+
audit.audit_tree(str(missing))
199+
200+
# And main() lets it propagate — no accidental swallow, no exit 0.
201+
with pytest.raises(FileNotFoundError):
202+
audit.main([str(missing)])
203+
204+
111205
# --- integration smoke ----------------------------------------------------
112206
def test_audits_the_real_package_without_error():
113207
"""The tool must stay runnable against the live tree as it evolves.

0 commit comments

Comments
 (0)