From 26192a58687702a8d40b09a4b088844ce5d7c149 Mon Sep 17 00:00:00 2001 From: David Baussart Date: Mon, 11 May 2026 11:04:49 +0200 Subject: [PATCH 1/5] fix(dbt): attribute relationships tests to the source node, not the FK target --- src/wlens/adapters/dbt.py | 45 +++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/wlens/adapters/dbt.py b/src/wlens/adapters/dbt.py index 722e00e..211d3d5 100644 --- a/src/wlens/adapters/dbt.py +++ b/src/wlens/adapters/dbt.py @@ -21,6 +21,7 @@ import json import logging +import re from dataclasses import dataclass from pathlib import Path @@ -173,13 +174,24 @@ def _build_test_index(manifest: dict) -> dict[tuple[str, str | None], list[str]] - "accepted_values(values=['shipped','completed','pending'])" """ idx: dict[tuple[str, str | None], list[str]] = {} + sources_by_name = { + f"{node['source_name']}.{node['name']}": node_id + for node_id, node in manifest.get("sources", {}).items() + if "source_name" in node and "name" in node + } + models_by_name = { + node["name"]: node_id + for node_id, node in manifest.get("nodes", {}).items() + if node.get("resource_type") == "model" and "name" in node + } + for node in manifest.get("nodes", {}).values(): if node.get("resource_type") != "test": continue - attached = node.get("attached_node") + attached = node.get("attached_node") or _resolve_attached_from_metadata( + node, sources_by_name, models_by_name + ) if not attached: - # Older manifests: derive from refs/sources. The first model/source - # in depends_on.nodes is usually the target. deps = (node.get("depends_on") or {}).get("nodes") or [] attached = next( (d for d in deps if d.startswith(("model.", "source."))), @@ -188,12 +200,37 @@ def _build_test_index(manifest: dict) -> dict[tuple[str, str | None], list[str]] if not attached: continue idx.setdefault((attached, node.get("column_name")), []).append(_format_test(node)) - # Stable ordering makes markdown diffs boring. for tests in idx.values(): tests.sort() return idx +_MODEL_TEMPLATE_SOURCE = re.compile(r"source\(\s*'([^']+)'\s*,\s*'([^']+)'\s*\)") +_MODEL_TEMPLATE_REF = re.compile(r"ref\(\s*'([^']+)'\s*\)") + + +def _resolve_attached_from_metadata( + test_node: dict, + sources_by_name: dict[str, str], + models_by_name: dict[str, str], +) -> str | None: + """For tests with `attached_node: None`, parse `test_metadata.kwargs.model` + to find the node where the test is defined. The kwarg looks like + `{{ get_where_subquery(source('acme', 'Loss_Payment')) }}` or + `{{ get_where_subquery(ref('my_model')) }}`. + """ + template = (test_node.get("test_metadata") or {}).get("kwargs", {}).get("model") + if not isinstance(template, str): + return None + match = _MODEL_TEMPLATE_SOURCE.search(template) + if match: + return sources_by_name.get(f"{match.group(1)}.{match.group(2)}") + match = _MODEL_TEMPLATE_REF.search(template) + if match: + return models_by_name.get(match.group(1)) + return None + + def _format_test(test_node: dict) -> str: """Render one test node as a compact grep-friendly string.""" meta = test_node.get("test_metadata") or {} From f9610d96f9def8f7e8e55c19957cb30aed328ad5 Mon Sep 17 00:00:00 2001 From: David Baussart Date: Mon, 11 May 2026 11:27:56 +0200 Subject: [PATCH 2/5] feat(render): obfuscate embedded PII shapes in sample-row cells Column-level redaction already replaces flagged columns with , but PII embedded inside innocuous columns (an email or phone in a description) was rendering verbatim. Add a value-level pass over sample-row cells that scrubs known shapes to typed tokens: , , . --- src/wlens/config.py | 4 ++ src/wlens/render/markdown.py | 28 +++++++-- src/wlens/render/obfuscate.py | 77 ++++++++++++++++++++++++ src/wlens/templates/wlens.yml | 10 +++ tests/test_render_markdown.py | 60 ++++++++++++++++++ tests/test_render_obfuscate.py | 107 +++++++++++++++++++++++++++++++++ 6 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 src/wlens/render/obfuscate.py create mode 100644 tests/test_render_obfuscate.py diff --git a/src/wlens/config.py b/src/wlens/config.py index 9f22056..4ab1b70 100644 --- a/src/wlens/config.py +++ b/src/wlens/config.py @@ -77,6 +77,9 @@ class OutputConfig: dir: str = DEFAULT_OUTPUT_DIR include_sample_rows: bool = True sample_size: int = DEFAULT_SAMPLE_SIZE + # Extra value-level obfuscation rules appended to the built-in defaults + # (email, UUID, URL, IP, phone). Each item: {pattern, replacement}. + obfuscate: list[dict] = field(default_factory=list) @dataclass @@ -159,6 +162,7 @@ def _build_config(raw: dict, repo_root: Path) -> Config: dir=output_raw.get("dir", DEFAULT_OUTPUT_DIR), include_sample_rows=bool(output_raw.get("include_sample_rows", True)), sample_size=int(output_raw.get("sample_size", DEFAULT_SAMPLE_SIZE)), + obfuscate=list(output_raw.get("obfuscate") or []), ) entities: list[EntityConfig] = [] diff --git a/src/wlens/render/markdown.py b/src/wlens/render/markdown.py index f2a8be4..a726282 100644 --- a/src/wlens/render/markdown.py +++ b/src/wlens/render/markdown.py @@ -13,12 +13,14 @@ from __future__ import annotations +import functools import logging from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from ..adapters.base import Column, Entity, Parent +from .obfuscate import compile_rules, obfuscate from .pii import PII_REDACTED, pii_column_names from .preserve import read_manual_block @@ -48,10 +50,15 @@ def render_and_write_all( inline_map = _inline_map(custom_entities) sample_size = config.output.sample_size if config.output.include_sample_rows else 0 + obfuscate_value = functools.partial( + obfuscate, rules=compile_rules(config.output.obfuscate) + ) for entity in entities: head_rows = _fetch_head(executor, entity, sample_size) if executor else [] - body = _render_entity(entity, head_rows, inline_map.get(entity.slug)) + body = _render_entity( + entity, head_rows, inline_map.get(entity.slug), obfuscate_value + ) _write_file(output_dir / entity.filename, body) index_path = output_dir / "_index.md" @@ -67,6 +74,7 @@ def _render_entity( entity: Entity, head_rows: list[dict], custom_entity: "TableCatalog | None", + obfuscate_value: Callable[[str], str], ) -> str: kind_label = "model" if entity.kind == "model" else "source" if custom_entity is not None: @@ -80,7 +88,9 @@ def _render_entity( if custom_entity is not None: lines.extend(custom_entity.render()) lines.extend(_render_parents(entity.parents)) - lines.extend(_render_sample_rows(head_rows, pii_column_names(entity.columns))) + lines.extend( + _render_sample_rows(head_rows, pii_column_names(entity.columns), obfuscate_value) + ) lines.extend(_render_compiled_sql(entity.compiled_sql)) return "\n".join(lines).rstrip() + "\n" @@ -115,7 +125,11 @@ def _render_parents(parents: list[Parent]) -> list[str]: return lines -def _render_sample_rows(head_rows: list[dict], pii_columns: set[str]) -> list[str]: +def _render_sample_rows( + head_rows: list[dict], + pii_columns: set[str], + obfuscate_value: Callable[[str], str], +) -> list[str]: """Column-first (transposed) rendering: one bullet per column, pipe-separated values.""" if not head_rows: return [] @@ -126,7 +140,7 @@ def _render_sample_rows(head_rows: list[dict], pii_columns: set[str]) -> list[st if c in pii_columns: values = " | ".join([PII_REDACTED] * n_rows) else: - values = " | ".join(_cell(row.get(c)) for row in head_rows) + values = " | ".join(_cell(row.get(c), obfuscate_value) for row in head_rows) lines.append(f"- `{c}`: {values}") lines.append("") return lines @@ -236,10 +250,12 @@ def _truncate(value) -> str | None: return s -def _cell(v) -> str: +def _cell(v, obfuscate_value: Callable[[str], str]) -> str: if v is None: return "NULL" s = str(v).replace("|", "\\|").replace("\n", " ") + # Obfuscate before truncation so long emails/URLs aren't cut mid-pattern. + s = obfuscate_value(s) if len(s) > CELL_MAX_LEN: s = s[:CELL_MAX_LEN] + "…" return s diff --git a/src/wlens/render/obfuscate.py b/src/wlens/render/obfuscate.py new file mode 100644 index 0000000..4381361 --- /dev/null +++ b/src/wlens/render/obfuscate.py @@ -0,0 +1,77 @@ +"""Value-level PII obfuscation for rendered sample-row cells. + +Complement to render/pii.py (which redacts whole columns by name). This +module runs after column-level redaction and scrubs PII shapes embedded +inside otherwise-innocuous values — e.g. an email pasted into a workspace +name, or a UUID inside a description field. + +Defaults cover only the shapes that are always problematic: email, +phone, IP. URLs and UUIDs are deliberately excluded — UUIDs are warehouse +primary keys everywhere, URLs are not always PII, and scrubbing either +costs more in readability than it gains. Projects can append extra +patterns (including UUID or URL rules) via `output.obfuscate` in +wlens.yml. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ObfuscationRule: + name: str + pattern: re.Pattern[str] + replacement: str + + +# IP before phone: `1.2.3.4` should render as ``, not be partially matched. +DEFAULT_RULES: tuple[ObfuscationRule, ...] = ( + ObfuscationRule( + name="email", + pattern=re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+"), + replacement="", + ), + ObfuscationRule( + name="ip", + pattern=re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), + replacement="", + ), + ObfuscationRule( + # Three numeric groups separated by space/dot/dash, last 3-9 digits. + # Covers `+1 555-123-4567`, `(555) 123-4567`, `555-123-4567`, + # `+44 20 7946 0958`. Won't match ISO dates (last group too short) + # or bare 10-digit ids (no separators). + name="phone", + pattern=re.compile( + r"(?:\+\d{1,3}[\s.-]?)?\(?\d{1,4}\)?[\s.-]\d{1,4}[\s.-]\d{3,9}" + ), + replacement="", + ), +) + + +def compile_rules(extras: list[dict] | None) -> tuple[ObfuscationRule, ...]: + """Return DEFAULT_RULES plus any user-supplied extras (appended).""" + rules: list[ObfuscationRule] = list(DEFAULT_RULES) + for i, extra in enumerate(extras or []): + if not isinstance(extra, dict) or "pattern" not in extra or "replacement" not in extra: + raise ValueError( + f"output.obfuscate[{i}] must have `pattern` and `replacement`. Got: {extra!r}" + ) + rules.append( + ObfuscationRule( + name=str(extra.get("name") or f"extra_{i}"), + pattern=re.compile(str(extra["pattern"])), + replacement=str(extra["replacement"]), + ) + ) + return tuple(rules) + + +def obfuscate(value: str, rules: tuple[ObfuscationRule, ...] = DEFAULT_RULES) -> str: + """Apply rules in order; return the scrubbed string.""" + for rule in rules: + value = rule.pattern.sub(rule.replacement, value) + return value diff --git a/src/wlens/templates/wlens.yml b/src/wlens/templates/wlens.yml index 6238bea..0fc3dd0 100644 --- a/src/wlens/templates/wlens.yml +++ b/src/wlens/templates/wlens.yml @@ -15,3 +15,13 @@ executor: # database: ${WLENS_DB_NAME} # user: ${WLENS_DB_USER} # password: ${WLENS_DB_PASSWORD} + +# Value-level scrubbing of sample-row cells. +# Defaults always on: email, IPv4, phone → / / . +# Append project-specific patterns here. A UUID rule is a common addition: +# output: +# obfuscate: +# - pattern: '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' +# replacement: '' +# - pattern: '\bemp-\d{6}\b' +# replacement: '' diff --git a/tests/test_render_markdown.py b/tests/test_render_markdown.py index c293c40..b7ed2e2 100644 --- a/tests/test_render_markdown.py +++ b/tests/test_render_markdown.py @@ -29,6 +29,66 @@ def _setup_project(tmp_path: Path, dbt_project: Path, extra: str = "") -> Path: return cfg_path +class _StubExecutor: + """Minimal `run_direct(sql)` stand-in for sample-row fetching.""" + + def __init__(self, headers: list[str], rows: list[tuple]): + self._headers = headers + self._rows = rows + + def run_direct(self, sql: str): # noqa: ARG002 — sql ignored, fixed payload + return self._headers, self._rows, False + + +def test_sample_rows_obfuscate_embedded_pii(tmp_path, dbt_project): + uuid_pattern = ( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" + ) + cfg_path = tmp_path / "wlens.yml" + cfg_path.write_text( + textwrap.dedent(f""" + adapter: + kind: dbt + project_dir: {dbt_project.relative_to(tmp_path)} + include_prefixes: [dim_] + output: + dir: .claude/schema + include_sample_rows: true + sample_size: 2 + obfuscate: + - pattern: '{uuid_pattern}' + replacement: '' + - pattern: '\\bemp-\\d{{6}}\\b' + replacement: '' + """).lstrip() + ) + cfg = load_config(cfg_path) + entities = [e for e in DbtAdapter(cfg).list_entities() if e.slug == "prod.dim_widget"] + assert entities, "expected dim_widget in fixtures" + + executor = _StubExecutor( + headers=["widget_id", "color", "notes"], + rows=[ + (1, "red", "owner foo@bar.com id 550e8400-e29b-41d4-a716-446655440000"), + (2, "blue", "ticket from 10.0.0.5 by emp-123456"), + ], + ) + render_and_write_all(entities, [], executor, cfg) + + md = (cfg.output_dir / "prod.dim_widget.md").read_text() + # Built-in patterns scrubbed. + assert "foo@bar.com" not in md + assert "10.0.0.5" not in md + assert "" in md + assert "" in md + # User-supplied extras applied. + assert "550e8400" not in md + assert "" in md + assert "emp-123456" not in md + assert "" in md + + def test_renders_one_file_per_entity(tmp_path, dbt_project): cfg = load_config(_setup_project(tmp_path, dbt_project)) entities = DbtAdapter(cfg).list_entities() diff --git a/tests/test_render_obfuscate.py b/tests/test_render_obfuscate.py new file mode 100644 index 0000000..f551376 --- /dev/null +++ b/tests/test_render_obfuscate.py @@ -0,0 +1,107 @@ +"""Value-level PII obfuscation for rendered sample-row cells.""" + +from __future__ import annotations + +import pytest + +from wlens.render.obfuscate import DEFAULT_RULES, compile_rules, obfuscate + + +# ─── Defaults: positive matches ───────────────────────────────────────────── + + +def test_email_default(): + assert obfuscate("contact foo@bar.com today") == "contact today" + assert obfuscate("foo.bar+baz@example.co.uk") == "" + + +def test_uuid_passes_through(): + # UUIDs are not obfuscated by default — they're warehouse primary keys. + s = "row id 550e8400-e29b-41d4-a716-446655440000 done" + assert obfuscate(s) == s + + +def test_url_passes_through(): + # URLs are not obfuscated by default — not every URL is PII. + s = "see https://example.com/path?x=1" + assert obfuscate(s) == s + + +def test_ip_default(): + assert obfuscate("from 192.168.0.1 yesterday") == "from yesterday" + + +def test_phone_default_us_styles(): + assert obfuscate("call (555) 123-4567") == "call " + assert obfuscate("call 555-123-4567") == "call " + assert obfuscate("call 555.123.4567") == "call " + + +def test_phone_default_intl(): + assert obfuscate("ring +1 555-123-4567") == "ring " + assert obfuscate("UK: +44 20 7946 0958") == "UK: " + + +# ─── Defaults: known-tricky negatives ─────────────────────────────────────── + + +def test_email_local_only_not_matched(): + # No domain → not an email. + assert obfuscate("alice@") == "alice@" + + +def test_version_string_not_phone(): + assert obfuscate("upgraded to v2.1.0 today") == "upgraded to v2.1.0 today" + + +def test_iso_date_not_phone(): + # `2024-01-15`: last group is 2 chars, phone requires 3-9. + assert obfuscate("released 2024-01-15") == "released 2024-01-15" + + +def test_bare_digits_not_phone(): + # No separators → not phone. + assert obfuscate("order 5551234567") == "order 5551234567" + + +# ─── Rule precedence ──────────────────────────────────────────────────────── + + +def test_ip_takes_precedence_over_phone(): + # `1.2.3.4` is a valid IP shape; should render as , not . + assert obfuscate("from 1.2.3.4") == "from " + + +def test_multi_pattern_in_one_string(): + s = "Contact foo@bar.com from 10.0.0.5 at +44 20 7946 0958" + out = obfuscate(s) + assert "" in out + assert "" in out + assert "" in out + assert "@" not in out + assert "10.0.0.5" not in out + + +# ─── Extras via compile_rules ─────────────────────────────────────────────── + + +def test_compile_rules_returns_defaults_when_no_extras(): + assert compile_rules(None) == DEFAULT_RULES + assert compile_rules([]) == DEFAULT_RULES + + +def test_compile_rules_appends_extras(): + rules = compile_rules( + [{"pattern": r"\bemp-\d{6}\b", "replacement": ""}] + ) + assert len(rules) == len(DEFAULT_RULES) + 1 + assert rules[-1].replacement == "" + out = obfuscate("hired emp-123456 last week", rules=rules) + assert out == "hired last week" + + +def test_compile_rules_rejects_missing_keys(): + with pytest.raises(ValueError, match="pattern.*replacement"): + compile_rules([{"pattern": r"\d+"}]) + with pytest.raises(ValueError, match="pattern.*replacement"): + compile_rules([{"replacement": ""}]) From 44e69ec47d2cd94c1c4be35733b5f40cfa81a61a Mon Sep 17 00:00:00 2001 From: David Baussart Date: Mon, 11 May 2026 11:53:38 +0200 Subject: [PATCH 3/5] docs(template): show Stripe ID + UUID rules as obf. examples --- src/wlens/templates/wlens.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wlens/templates/wlens.yml b/src/wlens/templates/wlens.yml index 0fc3dd0..0fccab8 100644 --- a/src/wlens/templates/wlens.yml +++ b/src/wlens/templates/wlens.yml @@ -18,10 +18,15 @@ executor: # Value-level scrubbing of sample-row cells. # Defaults always on: email, IPv4, phone → / / . -# Append project-specific patterns here. A UUID rule is a common addition: +# Append project-specific patterns here. Common additions: # output: # obfuscate: +# # UUIDs (warehouse primary keys — opt in if you don't want them in docs) # - pattern: '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' # replacement: '' +# # Stripe object IDs (cus_, sub_, in_, pi_, ch_, pm_, prod_, price_, inv_) +# - pattern: '\b(cus|sub|in|pi|ch|pm|prod|price|inv)_[A-Za-z0-9]+\b' +# replacement: '' +# # Project-specific identifier example # - pattern: '\bemp-\d{6}\b' # replacement: '' From 5c54733cf478e71449cc115f8f3ac13c336866fb Mon Sep 17 00:00:00 2001 From: David Baussart Date: Mon, 11 May 2026 11:53:59 +0200 Subject: [PATCH 4/5] chore: bump version to 0.2.0 Ships value-level PII obfuscation for sample-row cells. --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 270bd89..705d651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wlens" -version = "0.1.1" +version = "0.2.0" description = "Warehouse lens for AI agents — per-table markdown docs + read-only SQL adaptor, zero-runtime schema exploration for Claude Code, Cursor, Continue." readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index 6edf1aa..f81a924 100644 --- a/uv.lock +++ b/uv.lock @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "wlens" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "anyio" }, From 3c7ce42f3f82d4e4820b30d6a8e37480eb3c7eaa Mon Sep 17 00:00:00 2001 From: David Baussart Date: Mon, 11 May 2026 12:13:14 +0200 Subject: [PATCH 5/5] fix(obfuscate): split phone rule to stop matching non-ISO dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous single pattern matched `15-01-2024`, `01.15.2024`, etc. because the trailing 4-digit year satisfied \d{3,9}. Split phone into phone_intl (must lead with +country-code) and phone_na (strict 3-3-4 shape). Both emit ; neither matches DD-MM-YYYY, MM-DD-YYYY, or YYYY-MM-DD because real phones have ≥10 digits and common date formats have ≤8. --- src/wlens/render/obfuscate.py | 20 ++++++++++++++------ tests/test_render_obfuscate.py | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/wlens/render/obfuscate.py b/src/wlens/render/obfuscate.py index 4381361..7abdff2 100644 --- a/src/wlens/render/obfuscate.py +++ b/src/wlens/render/obfuscate.py @@ -38,17 +38,25 @@ class ObfuscationRule: pattern=re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), replacement="", ), + # Phone is split into two rules so neither matches DD-MM-YYYY / MM-DD-YYYY + # date strings. Real phones have ≥10 digits; common date formats have ≤8. ObfuscationRule( - # Three numeric groups separated by space/dot/dash, last 3-9 digits. - # Covers `+1 555-123-4567`, `(555) 123-4567`, `555-123-4567`, - # `+44 20 7946 0958`. Won't match ISO dates (last group too short) - # or bare 10-digit ids (no separators). - name="phone", + # International with explicit +country-code: `+1 555-123-4567`, + # `+44 20 7946 0958`. The leading `+` is the disambiguator. + name="phone_intl", pattern=re.compile( - r"(?:\+\d{1,3}[\s.-]?)?\(?\d{1,4}\)?[\s.-]\d{1,4}[\s.-]\d{3,9}" + r"\+\d{1,3}[\s.-]?\d{1,4}[\s.-]?\d{2,9}[\s.-]?\d{2,9}" ), replacement="", ), + ObfuscationRule( + # North American 3-3-4 shape: `(555) 123-4567`, `555-123-4567`, + # `555.123.4567`. The strict 3-3-4 grouping rules out date formats, + # whose leading day/month group is 1-2 digits. + name="phone_na", + pattern=re.compile(r"\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}"), + replacement="", + ), ) diff --git a/tests/test_render_obfuscate.py b/tests/test_render_obfuscate.py index f551376..1016b70 100644 --- a/tests/test_render_obfuscate.py +++ b/tests/test_render_obfuscate.py @@ -54,9 +54,20 @@ def test_version_string_not_phone(): assert obfuscate("upgraded to v2.1.0 today") == "upgraded to v2.1.0 today" -def test_iso_date_not_phone(): - # `2024-01-15`: last group is 2 chars, phone requires 3-9. - assert obfuscate("released 2024-01-15") == "released 2024-01-15" +@pytest.mark.parametrize( + "date", + [ + "2024-01-15", # ISO YYYY-MM-DD + "15-01-2024", # DD-MM-YYYY + "01-15-2024", # MM-DD-YYYY + "15.01.2024", # DD.MM.YYYY + "01.15.2024", # MM.DD.YYYY + "2024.01.15", # YYYY.MM.DD + "01/15/2024", # slash-separated (separator not even in our class) + ], +) +def test_date_formats_not_phone(date): + assert obfuscate(f"released {date}") == f"released {date}" def test_bare_digits_not_phone():