mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
fix(salary): reject non-finite workbook numbers (#572)
* fix(salary): reject non-finite workbook numbers * docs(changelog): record fix for #572
This commit is contained in:
1 parent
ef4d12711c
commit
991bda5dd2
3 files changed
+65
-2
No files matched your search
@@ -423,6 +423,8 @@ per-file diff commands.
|
|||||||
harmless, and valid UTF-8 rules decide as before. Nine new tests; the seven that pin the
|
harmless, and valid UTF-8 rules decide as before. Nine new tests; the seven that pin the
|
||||||
fail-opens fail on master.
|
fail-opens fail on master.
|
||||||
|
|
||||||
|
- **Salary workbook conversion rejects non-finite text cells** (#572) - hand-typed `Infinity` or `NaN` text cells now produce validation errors instead of numeric-conversion tracebacks.
|
||||||
|
|
||||||
- **`verify_layout.py` finds an orphaned entry header by where the text starts**
|
- **`verify_layout.py` finds an orphaned entry header by where the text starts**
|
||||||
(`tools/verify_layout.py`, `tests/test_verify_layout.py`) - the orphan rule compared line
|
(`tools/verify_layout.py`, `tests/test_verify_layout.py`) - the orphan rule compared line
|
||||||
left edges against the document margin, and a list marker moves a line's left edge without
|
left edges against the document margin, and a list marker moves a line's left edge without
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Non-finite workbook cells cannot crash conversion or escape as JSON numbers."""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
openpyxl = None
|
||||||
|
|
||||||
|
TOOL = Path(__file__).resolve().parent.parent / "tools" / "convert_salary_excel.py"
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(openpyxl, "requires optional openpyxl (installed in CI)")
|
||||||
|
class NonFiniteWorkbookCLI(unittest.TestCase):
|
||||||
|
def convert(self, headers, rows):
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
workbook, output = root / "salary.xlsx", root / "salary.json"
|
||||||
|
book = openpyxl.Workbook()
|
||||||
|
try:
|
||||||
|
book.active.append(headers)
|
||||||
|
for row in rows:
|
||||||
|
book.active.append(row)
|
||||||
|
book.save(workbook)
|
||||||
|
finally:
|
||||||
|
book.close()
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(TOOL), str(workbook), "--output", str(output)],
|
||||||
|
capture_output=True, text=True, encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
def reject_constant(value):
|
||||||
|
self.fail(f"converter emitted non-JSON numeric constant {value}")
|
||||||
|
return json.loads(output.read_text(encoding="utf-8"), parse_constant=reject_constant)["companies"]
|
||||||
|
|
||||||
|
def test_infinite_paired_count_does_not_abort_the_workbook(self):
|
||||||
|
companies = self.convert(["Company", "Count", "Index"], [
|
||||||
|
["Invalid Count", "Infinity", 108.5], ["Valid Control", 12, 102.5]
|
||||||
|
])
|
||||||
|
self.assertEqual(companies[0]["categories"], {"all_employees": {"count": None, "index": 108.5}})
|
||||||
|
self.assertEqual(companies[1]["categories"], {"all_employees": {"count": 12, "index": 102.5}})
|
||||||
|
|
||||||
|
def test_nonfinite_paired_index_is_skipped_instead_of_emitted_as_nan(self):
|
||||||
|
companies = self.convert(["Company", "Count", "Index"], [["Acme", 12, "NaN"]])
|
||||||
|
self.assertEqual(companies[0]["categories"], {"all_employees": {"count": 12, "index": None}})
|
||||||
|
|
||||||
|
def test_nonfinite_standalone_count_is_skipped(self):
|
||||||
|
companies = self.convert(["Company", "Count"], [["Acme", "NaN"]])
|
||||||
|
self.assertEqual(companies[0]["categories"], {})
|
||||||
@@ -26,6 +26,7 @@ with paired count/index columns per category, it groups them automatically.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
@@ -58,10 +59,17 @@ ID_PATTERNS = {"id", "personnummer"}
|
|||||||
DEFAULT_CATEGORY = "all_employees"
|
DEFAULT_CATEGORY = "all_employees"
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_float(value):
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError("salary values must be finite")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
def parse_numeric_cell(value):
|
def parse_numeric_cell(value):
|
||||||
"""Parse numeric Excel values, including localized string cells."""
|
"""Parse numeric Excel values, including localized string cells."""
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
return float(value)
|
return _finite_float(value)
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise ValueError("not numeric")
|
raise ValueError("not numeric")
|
||||||
|
|
||||||
@@ -84,7 +92,7 @@ def parse_numeric_cell(value):
|
|||||||
elif "." in text:
|
elif "." in text:
|
||||||
if re.fullmatch(r"[+-]?\d+\.\d{3}", text):
|
if re.fullmatch(r"[+-]?\d+\.\d{3}", text):
|
||||||
raise ValueError("ambiguous dot separator")
|
raise ValueError("ambiguous dot separator")
|
||||||
return float(text)
|
return _finite_float(text)
|
||||||
|
|
||||||
|
|
||||||
def header_matches(header, patterns):
|
def header_matches(header, patterns):
|
||||||
|
|||||||
Reference in new issue
Block a user