fix: cross-tool audit findings + alignment with format standardizer
Closes 12 bugs and 8 gaps surfaced by parallel audits across all core modules, plus aligns the dedup-side normalizers with the new format_standardize behavior where they had silently diverged. Bugs (data integrity / correctness): - dedup: NaN/None values matched as duplicates because str(None)='None'. Two rows with missing email silently merged. - dedup: removed_df had 0 columns when nothing was removed; downstream code expecting matching schema broke. Now preserves column shape. - dedup: ColumnMatchStrategy threshold accepted any value; out-of-range silently broke matching. Validated to [0, 100] in __post_init__. - dedup: strategy referencing a missing column was silently skipped. Now raises ValueError listing available columns. - fixes: replace_null_sentinels crashed on non-string sentinels (int/None from JSON payload). Coerced to str. - fixes: _vectorized_regex_sub raised raw re.error on bad patterns. Now wraps as ValueError with clear message. - io: detect_header_row mis-identified all-empty and metadata-only rows as headers (all([]) is True). Now requires ≥2 non-empty cells. - config: from_dict crashed when JSON had unknown fields, breaking forward compat. Now filters to known fields. - analyze: mixed-case email detector flagged all-None columns because str(None)='None' contains both N and one. Now drops NaN before stringify. New features and gap closures: - io: _detect_excel_header_row mirrors detect_header_row for Excel via openpyxl read-only; _read_excel uses it when header_row=None. - io: write_file gains delimiter + encoding params; .tsv extension defaults to tab. - normalizers: normalize_phone preserves extensions as ;ext=N suffix. - normalizers: normalize_address folds spelled-out US state names to 2-letter codes (California ≡ CA). - normalizers: normalize_name drops surname particles (van, de, von) so "Charles de Gaulle" ≡ "Charles Gaulle" for matching. - analyze: new _detect_inconsistent_date_format detector flags columns with mixed ISO/US/EU date shapes; routes to format standardizer. - analyze: _NULL_LIKE recognizes "<na>" (pd.NA repr). - analyze: duplicate-row finding renamed count → n_extra (rows that would actually be removed) with clarified description. - dedup: group_confidence no longer falsely 100.0 when transitive group members lack a recorded direct pair; falls back to 100.0 only when truly no pairs were observed. - dedup: MatchResult / DeduplicationResult docstrings clarify that row_indices refer to the input frame's positional index (output index is reset). - text_clean: visualize_hidden_html(None) now returns None (matches visualize_hidden_text); strip_bom strips at most one BOM per call; sentence_case dead elif branch removed. Tests: - tests/test_audit_fixes.py — 28 regression tests, one or more per numbered finding, named after BUG/GAP/NIT tags so future readers can trace each test back to its audit. - tests/test_fixes_unit.py — 26 isolated unit tests for previously integration-only fix functions (trim_whitespace, strip_nbsp, strip_zero_width, normalize_line_endings, clean_headers, repair_mojibake — last skipped if ftfy unavailable). - tests/test_io.py — adds CSV / TSV / semicolon / UTF-8-BOM round-trip tests + Excel auto-header-detection tests. - tests/test_normalizers.py — adds 8 tests for the alignment work above (phone extension, state names, particles). Adds .claude/ to .gitignore (agent worktrees + local settings). Full project suite: 1197 passed, 4 skipped, 17 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,3 +156,51 @@ class TestGetNormalizer:
|
||||
def test_unknown_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
get_normalizer("unknown_type")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alignment with format_standardize: extension preservation, state codes,
|
||||
# particle handling. See audit GAPs 15/16/17.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizerAudit:
|
||||
def test_phone_extension_preserved(self):
|
||||
# Two records with different extensions must NOT normalize to
|
||||
# the same key — they're different people at the same business.
|
||||
a = normalize_phone("+15551234567 ext 100")
|
||||
b = normalize_phone("+15551234567 ext 200")
|
||||
assert a != b
|
||||
assert a == "+15551234567;ext=100"
|
||||
|
||||
def test_phone_no_extension_unchanged(self):
|
||||
assert normalize_phone("+15551234567") == "+15551234567"
|
||||
|
||||
def test_address_state_name_to_code(self):
|
||||
# "California" and "CA" produce the same matching key.
|
||||
a = normalize_address("123 Main St, Los Angeles, California 90001")
|
||||
b = normalize_address("123 Main St, Los Angeles, CA 90001")
|
||||
assert a == b
|
||||
|
||||
def test_address_multiword_state_name(self):
|
||||
a = normalize_address("100 Beacon St, Boston, Massachusetts 02101")
|
||||
b = normalize_address("100 Beacon St, Boston, MA 02101")
|
||||
assert a == b
|
||||
|
||||
def test_address_does_not_butcher_city_named_after_state(self):
|
||||
# "New York" appearing as a city should still fold to "ny" —
|
||||
# this is intentional for matching keys (we want ``New York, NY``
|
||||
# and ``NY, NY`` to be the same record) even though the
|
||||
# standardizer (display) would preserve the city name.
|
||||
out = normalize_address("123 Main St, New York, NY 10001")
|
||||
assert "ny" in out
|
||||
|
||||
def test_name_particle_dropped(self):
|
||||
# "Charles de Gaulle" and "Charles Gaulle" produce the same key.
|
||||
assert normalize_name("Charles de Gaulle") == normalize_name("Charles Gaulle")
|
||||
|
||||
def test_name_van_dropped(self):
|
||||
assert normalize_name("Vincent van Gogh") == normalize_name("Vincent Gogh")
|
||||
|
||||
def test_name_particle_idempotent(self):
|
||||
out = normalize_name("Vincent van Gogh")
|
||||
assert normalize_name(out) == out
|
||||
|
||||
Reference in New Issue
Block a user