diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd7982..2c68009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [3.0.8] - 2026-09 + +### Added — surname prefix and faker locale (requirements) +- **Surname masks keep the first characters of the original again.** The + 3.0.2 synthetic algorithm dropped the leading letters the old + `original[:3]…` formula had preserved implicitly. Now the mask = + first N characters of the original + synthetic stem + the original's + grammatical ending (`Петренку → Петаченку`, `Іванова → Іварунова`, + `Ґудзь → Ґузій`). N defaults to 3; **short surnames keep at most half + of the word** (`Ґудзь → 2`, `Ткач → 2`, `Рак → 1`). The prefix is taken + from the surface form regardless of where the ending starts, but never + reaches into the ending. All no-leak checks stay in force (mask never + equals/contains the original, its stem, or any document word); the + prefix/stem joint is kept pronounceable (vowel + consonant). +- **`masking_rules.surname_prefix_length`** (YAML) / + **`DATA_MASKING_SURNAME_PREFIX_LENGTH`** (ENV): the N above; `0` + restores fully synthetic masks (3.0.2–3.0.7 behaviour); negative or + non-integer values are a fatal config error. +- **`system.faker_locale`** (YAML) / **`DATA_MASKING_FAKER_LOCALE`** + (ENV), default `uk_UA`: the faker dictionaries used for synthetic + surname stems, given-name fallbacks and patronymics. Ukrainian + morphology (surname endings, rank declension, patronymic gender) is + unchanged; locales without patronymics (most of them) fall back to + `uk_UA` for patronymics; an unknown locale is rejected at start-up. + `Faker('uk_UA')` is no longer hard-wired at import — see + `constants.set_faker_locale()`. +- Tests: `tests/test_surname_prefix.py` (prefix lengths incl. short + surnames, ending/case preservation, hyphenated parts, pronounceable + joint, YAML/ENV wiring, locale switch, unknown locale, patronymic + fallback). + +### Compatibility +- Surname masks differ from 3.0.2–3.0.7 (same input → new, stable masks). + Unmasking of files from any earlier version is unaffected. + ## [3.0.7] - 2026-09 ### Changed — static typing: mypy is clean and blocking diff --git a/data_masking.py b/data_masking.py index 0d281d9..7b85381 100644 --- a/data_masking.py +++ b/data_masking.py @@ -31,7 +31,7 @@ # Re-exports from masking package for backward compatibility # ============================================================================ -__version__ = "3.0.7" +__version__ = "3.0.8" from datamasking.masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, diff --git a/datamasking/_version.py b/datamasking/_version.py index 29f1457..9cbad54 100644 --- a/datamasking/_version.py +++ b/datamasking/_version.py @@ -9,4 +9,4 @@ (і не тягнучи faker під час збірки). """ -__version__ = "3.0.7" +__version__ = "3.0.8" diff --git a/datamasking/extras/config.py b/datamasking/extras/config.py index ae7e7ef..d151d1a 100644 --- a/datamasking/extras/config.py +++ b/datamasking/extras/config.py @@ -47,6 +47,8 @@ class SystemConfig: hash_algorithm: str = "blake2b" preserve_case: bool = True debug_mode: bool = False + # Локаль faker для синтетичних прізвищ/імен (морфологія лишається uk) + faker_locale: str = "uk_UA" @dataclass @@ -82,6 +84,9 @@ class MaskingRulesConfig: enable_units: bool = True enable_orders: bool = True enable_br_numbers: bool = True + # Скільки перших символів оригінального прізвища зберігати в масці + # (0 = не зберігати; для коротких прізвищ — не більше половини слова) + surname_prefix_length: int = 3 # Tuning parameters rank_shift_options: List[int] = field(default_factory=lambda: [-2, -1, 1, 2]) date_shift_days: int = 30 @@ -214,6 +219,8 @@ class ConfigLoader: "DATA_MASKING_HASH_ALGORITHM": ("system", "hash_algorithm", str), "DATA_MASKING_PRESERVE_CASE": ("system", "preserve_case", bool), "DATA_MASKING_DEBUG": ("system", "debug_mode", bool), + "DATA_MASKING_FAKER_LOCALE": ("system", "faker_locale", str), + "DATA_MASKING_SURNAME_PREFIX_LENGTH": ("masking_rules", "surname_prefix_length", int), "DATA_MASKING_ENCRYPT_OUTPUT": ("security", "encrypt_output", bool), # DATA_MASKING_PASSWORD навмисно ВІДСУТНІЙ: це сам пароль, його читає CLI # (до 3.0.4 значення пароля записувалось у security.password_env_var) @@ -464,6 +471,11 @@ def generate_default_config(output_path: str = "config.yaml") -> str: # Enable debug output (verbose logging) debug_mode: false + # Faker locale for synthetic surnames / names / patronymics (e.g. uk_UA, ru_RU, + # pl_PL). Grammar (endings, cases, gender) stays Ukrainian; locales without + # patronymics fall back to uk_UA for them. ENV: DATA_MASKING_FAKER_LOCALE + faker_locale: "uk_UA" + # -------------------------------------------------------------------------- # Password generation settings # -------------------------------------------------------------------------- @@ -499,6 +511,11 @@ def generate_default_config(output_path: str = "config.yaml") -> str: # Masking rules — enable/disable individual data types # -------------------------------------------------------------------------- masking_rules: + # How many leading characters of the ORIGINAL surname to keep in its mask + # (0 = none). Short surnames keep at most half of the word: + # Петренко -> Пет…енко, Ґудзь -> Ґу… ENV: DATA_MASKING_SURNAME_PREFIX_LENGTH + surname_prefix_length: 3 + # Military ranks (with declension and case preservation) enable_ranks: true diff --git a/datamasking/masking/cli.py b/datamasking/masking/cli.py index 0339417..cd29729 100644 --- a/datamasking/masking/cli.py +++ b/datamasking/masking/cli.py @@ -307,10 +307,13 @@ def _apply_selective_filters(args, logger) -> Optional[str]: return None -def _apply_config_settings(args, config, logger) -> None: - """Apply config-based masking rules and system settings.""" +def _apply_config_settings(args, config, logger) -> Optional[str]: + """Apply config-based masking rules and system settings. + + Returns an error message (fatal for the CLI) or None. + """ if config is None: - return + return None # Masking rules from config (only if --only/--exclude not specified) masking_rules = getattr(config, 'masking_rules', None) @@ -353,6 +356,27 @@ def _apply_config_settings(args, config, logger) -> None: if cfg_hash is not None: _cfg.HASH_ALGORITHM = str(cfg_hash) + # system.faker_locale — словники faker для синтетичних прізвищ/імен + locale = getattr(system_cfg, 'faker_locale', None) + if locale and locale != _cfg.FAKER_LOCALE: + try: + _cfg.set_faker_locale(str(locale)) + except ValueError as e: + return str(e) + if logger: + logger.info(f"Faker locale: {locale}") + + # masking_rules.surname_prefix_length — скільки символів оригіналу лишати + prefix_len = getattr(masking_rules, 'surname_prefix_length', None) + if prefix_len is not None: + try: + prefix_len = int(prefix_len) + except (TypeError, ValueError): + return f"masking_rules.surname_prefix_length must be an integer, got {prefix_len!r}" + if prefix_len < 0: + return "masking_rules.surname_prefix_length must be >= 0" + _cfg.SURNAME_PREFIX_LENGTH = prefix_len + # validation.max_input_size_mb — раніше документований, але мертвий ключ validation_cfg = getattr(config, 'validation', None) max_mb = getattr(validation_cfg, 'max_input_size_mb', None) @@ -365,6 +389,7 @@ def _apply_config_settings(args, config, logger) -> None: args.encrypt = True if logger: logger.info("Encryption enabled by config (security.encrypt_output)") + return None def _prepare_output_paths(args, input_path: Path) -> Tuple[Path, Path, Path, str, int]: @@ -779,7 +804,10 @@ def main(argv=None) -> int: if filter_error: print(f"Error: {filter_error}") return EXIT_USAGE - _apply_config_settings(args, config, logger) + settings_error = _apply_config_settings(args, config, logger) + if settings_error: + print(f"Error: {settings_error}") + return EXIT_ERROR if logger: logger.info(f"Masking flags: NAMES={_cfg.MASK_NAMES}, IPN={_cfg.MASK_IPN}, " diff --git a/datamasking/masking/constants.py b/datamasking/masking/constants.py index 5a13251..6ef652a 100644 --- a/datamasking/masking/constants.py +++ b/datamasking/masking/constants.py @@ -33,9 +33,38 @@ __license__ = "BSD 3-Clause" __year__ = "2025-2026" -fake_uk = Faker('uk_UA') +# Локаль faker для синтетичних прізвищ/імен/по батькові. Перевизначається +# конфігом (system.faker_locale / DATA_MASKING_FAKER_LOCALE) через +# set_faker_locale(); морфологія (закінчення, відмінки, рід) лишається +# українською — інша локаль змінює лише словники. +FAKER_LOCALE = 'uk_UA' +fake_uk = Faker(FAKER_LOCALE) +# Запасний uk_UA-генератор для по батькові: більшість локалей faker не мають +# middle_name_* (є лише в uk/ru) +fake_uk_fallback = fake_uk + +# Скільки перших символів оригінального прізвища зберігати в масці +# (0 = не зберігати). Для коротких прізвищ — не більше половини слова. +# Конфіг: masking_rules.surname_prefix_length / DATA_MASKING_SURNAME_PREFIX_LENGTH +SURNAME_PREFIX_LENGTH = 3 + HASH_ALGORITHM = 'blake2b' + +def set_faker_locale(locale: str) -> None: + """Перемикає локаль faker (валідує; невідома локаль → ValueError).""" + global fake_uk, FAKER_LOCALE + locale = (locale or "").strip() + if not locale: + raise ValueError("faker locale must be a non-empty string, e.g. 'uk_UA'") + try: + instance = Faker(locale) + instance.last_name() # локаль без провайдера імен — теж помилка + except (AttributeError, ValueError, ImportError) as exc: + raise ValueError(f"Unknown or unsupported faker locale: {locale!r}") from exc + fake_uk = instance + FAKER_LOCALE = locale + # ============================================================================ # НАЛАШТУВАННЯ МАСКУВАННЯ # ============================================================================ diff --git a/datamasking/masking/mask_personal.py b/datamasking/masking/mask_personal.py index ff192a2..5df2423 100644 --- a/datamasking/masking/mask_personal.py +++ b/datamasking/masking/mask_personal.py @@ -134,7 +134,11 @@ def mask_patronymic(patronymic: str, gender: str, masking_dict: Dict, instance_c seed = get_deterministic_seed(patronymic_lower) random.seed(seed) _cfg.fake_uk.seed_instance(seed) - fake_patronymic = _cfg.fake_uk.middle_name_male() if gender == 'male' else _cfg.fake_uk.middle_name_female() + # Більшість локалей faker не мають по батькові — беремо uk_UA-fallback + provider = _cfg.fake_uk if hasattr(_cfg.fake_uk, 'middle_name_male') else _cfg.fake_uk_fallback + if provider is not _cfg.fake_uk: + provider.seed_instance(seed) + fake_patronymic = provider.middle_name_male() if gender == 'male' else provider.middle_name_female() # Застосовуємо регістр if is_upper: fake_patronymic = fake_patronymic.upper() diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index b265f7a..dc7a5da 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -153,12 +153,24 @@ def _draw_candidates(seed: int, bare: bool) -> Iterable[Tuple[str, str]]: yield stem, family -def _pick_stem(seed: int, target_len: int, family: str, ending: str, forbidden: Set[str]) -> str: +def _pick_stem(seed: int, target_len: int, family: str, ending: str, forbidden: Set[str], + prefix: str = "") -> str: """Обирає синтетичну основу: тієї ж родини, придатну до закінчення - і схожої довжини, якщо є.""" + і схожої довжини, якщо є. Якщо задано *prefix* — основа починається + з нього (перші символи оригінального прізвища), а решта синтетична.""" same_family: list = [] others: list = [] - for stem, fam in _draw_candidates(seed, bare=(ending == "")): + p = len(prefix) + for cand, fam in _draw_candidates(seed, bare=(ending == "")): + # хвіст кандидата після префікса має бути хоч 2 символи, щоб маска + # не була «префікс + 1 літера» + if p and len(cand) < p + 2: + continue + # Стик префікса й хвоста має бути вимовним: після голосної — + # приголосна і навпаки («іва»+«анов» → «іваанов», «тк»+«мак» → «ткмак» — ні) + if p and not _joint_ok(prefix, cand[p:]): + continue + stem = prefix + cand[p:] if p else cand if stem in forbidden or not _stem_fits(stem, family): continue (same_family if fam == family and family else others).append(stem) @@ -166,16 +178,50 @@ def _pick_stem(seed: int, target_len: int, family: str, ending: str, forbidden: def closest(pool): return min(pool, key=lambda s: abs(len(s) - target_len)) if pool else None - return closest(same_family) or closest(others) or _random_stem(seed, target_len) + return closest(same_family) or closest(others) or _random_stem(seed, target_len, prefix) + +_ALL_VOWELS = "аеиіоуюяєї" + + +def _joint_ok(prefix: str, tail: str) -> bool: + """Голосна + приголосна (або навпаки) на стику; ь/й/апостроф — як приголосна.""" + if not prefix or not tail: + return True + last_vowel = prefix[-1] in _ALL_VOWELS + first_vowel = tail[0] in _ALL_VOWELS + return last_vowel != first_vowel -def _random_stem(seed: int, target_len: int) -> str: - """Запасний варіант: вимовна псевдооснова з чергуванням приголосна/голосна.""" + +def _random_stem(seed: int, target_len: int, prefix: str = "") -> str: + """Запасний варіант: вимовна псевдооснова з чергуванням приголосна/голосна + (після *prefix*, якщо він є).""" rnd = random.Random(seed) length = max(MIN_STEM, min(target_len, 8)) - return "".join( - rnd.choice(_CONSONANTS if i % 2 == 0 else _VOWELS) for i in range(length) + tail_len = max(2, length - len(prefix)) + start_with_vowel = bool(prefix) and prefix[-1] not in _ALL_VOWELS + tail = "".join( + rnd.choice(_VOWELS if (i % 2 == 0) == start_with_vowel else _CONSONANTS) + for i in range(tail_len) ) + return prefix + tail + + +def prefix_length_for(original: str, configured: Optional[int] = None) -> int: + """Скільки перших символів оригіналу лишити в масці. + + Правило (ТЗ): N з конфігу (SURNAME_PREFIX_LENGTH, типово 3), але для + коротких прізвищ — не більше половини слова: Петренко → 3, Ґудзь → 2, + Ткач → 2. Префікс не залежить від того, де починається закінчення, але + не заходить у нього (інакше закінчення не відновити граматично). + """ + n = _cfg.SURNAME_PREFIX_LENGTH if configured is None else configured + if n <= 0: + return 0 + stem, _ending, _family = split_surname(original) + # Хоча б один символ основи має змінитись (Лис-енко: основа «лис» — префікс + # 2, не 3), інакше маска містить усю основу і no-leak відкидає всі спроби + return max(0, min(n, len(original) // 2, len(stem) - 1)) def _leaks(masked: str, original: str, stem: str) -> bool: @@ -207,20 +253,23 @@ def known_surname_forms(masking_dict: Dict) -> Set[str]: return forms -def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None) -> str: +def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None, + prefix_length: Optional[int] = None) -> str: """Детермінована синтетична маска (нижній регістр) для поверхневої форми *original*. - Зберігає відмінкове/родове закінчення оригіналу, не містить оригінал, - не збігається з жодним рядком із *forbidden*. + Зберігає перші N символів оригіналу (див. prefix_length_for) і його + відмінкове/родове закінчення; не містить оригінал і не збігається з + жодним рядком із *forbidden*. """ forbidden = {f.lower() for f in (forbidden or set())} | _document_vocab stem, ending, family = split_surname(original) base_seed = get_deterministic_seed(original) + prefix = original.lower()[:prefix_length_for(original, prefix_length)] last = "" for attempt in range(_ATTEMPTS): seed = base_seed if attempt == 0 else get_deterministic_seed(f"{original}\x00{attempt}") - new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}) + new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}, prefix=prefix) masked = new_stem + ending last = masked if not _leaks(masked, original, stem) and masked not in forbidden: diff --git a/docs/README.md b/docs/README.md index 51592e0..2490056 100644 --- a/docs/README.md +++ b/docs/README.md @@ -185,6 +185,9 @@ Unmask правильно відновить обидва входження "Капітану на пенсії" → "Майору на пенсії" (давальний зберігається!) ``` +### Surname masks (v3.0.8) +A surname mask keeps the **first 3 characters** of the original (at most half of the word for short surnames), the rest is synthetic; the grammatical ending is preserved: `Петренку → Петаченку`, `Ґудзь → Ґузій`. Configure with `masking_rules.surname_prefix_length` (0 = fully synthetic) and the faker dictionaries with `system.faker_locale` (default `uk_UA`; grammar stays Ukrainian). + ### Case Preservation ``` "ІВАНОВ" → "ПЕТРЕНКО" diff --git a/docs/README_UK.md b/docs/README_UK.md index 25e28c1..0c20c6e 100644 --- a/docs/README_UK.md +++ b/docs/README_UK.md @@ -185,6 +185,9 @@ Unmask правильно відновить обидва входження "Капітану на пенсії" → "Майору на пенсії" (давальний зберігається!) ``` +### Маски прізвищ (v3.0.8) +Маска прізвища зберігає **перші 3 символи** оригіналу (для коротких — не більше половини слова), решта синтетична; відмінкове закінчення зберігається: `Петренку → Петаченку`, `Ґудзь → Ґузій`. Налаштування: `masking_rules.surname_prefix_length` (0 = повністю синтетична) та словники faker через `system.faker_locale` (типово `uk_UA`; морфологія лишається українською). + ### Збереження регістру ``` "ІВАНОВ" → "ПЕТРЕНКО" diff --git a/tests/test_surname_prefix.py b/tests/test_surname_prefix.py new file mode 100644 index 0000000..209bacc --- /dev/null +++ b/tests/test_surname_prefix.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Префікс оригінального прізвища в масці + локаль faker (v3.0.8, ТЗ). + +Правила: + - маска зберігає перші N символів оригіналу (N = masking_rules.surname_prefix_length, + типово 3; 0 = вимкнено), для коротких прізвищ — не більше половини слова; + - префікс не залежить від відмінкового закінчення, але закінчення й далі + зберігається; оригінал у масці не з'являється; + - system.faker_locale перемикає словники faker (морфологія лишається uk). +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from datamasking.masking import constants as _cfg # noqa: E402 +from datamasking.masking import cli as mask_cli # noqa: E402 +from datamasking.masking.surname import synthesize_surname, prefix_length_for, split_surname # noqa: E402 +from tests.test_initials import mask, make_masking_dict # noqa: E402 +from datamasking.masking.mask_personal import mask_surname # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import check_mapping_version # noqa: E402 + +try: + import yaml # noqa: F401 + _HAS_YAML = True +except ImportError: + _HAS_YAML = False +needs_yaml = pytest.mark.skipif(not _HAS_YAML, reason="pyyaml not installed") + + +@pytest.fixture(autouse=True) +def _restore_globals(): + saved_prefix, saved_locale, saved_faker = _cfg.SURNAME_PREFIX_LENGTH, _cfg.FAKER_LOCALE, _cfg.fake_uk + yield + _cfg.SURNAME_PREFIX_LENGTH = saved_prefix + _cfg.FAKER_LOCALE, _cfg.fake_uk = saved_locale, saved_faker + + +def sm(word: str) -> str: + return mask_surname(word, make_masking_dict(), {}) + + +class TestPrefixLength: + @pytest.mark.parametrize("word,expected", [ + ("Петренко", 3), ("Іванов", 3), ("Іванова", 3), ("Сидоренко", 3), + ("Ґудзь", 2), ("Ткач", 2), ("Коваль", 3), ("Рак", 1), ("Шамрай", 3), + ("Петренку", 3), ("Ковальського", 3), + ]) + def test_default_three_capped_at_half(self, word, expected): + assert prefix_length_for(word) == expected + + def test_configured_two(self): + assert prefix_length_for("Петренко", 2) == 2 + assert prefix_length_for("Ткач", 2) == 2 + assert prefix_length_for("Рак", 2) == 1 + + def test_zero_disables(self): + assert prefix_length_for("Петренко", 0) == 0 + + def test_prefix_never_enters_ending(self): + # «Рак» → основа лише «рак» без закінчення; «Іванов» → основа «іван», префікс ≤ 4 + for w in ["Іванов", "Петренко", "Кравчук"]: + stem, _, _ = split_surname(w) + assert prefix_length_for(w, 10) <= len(stem) + + +class TestMaskKeepsPrefix: + @pytest.mark.parametrize("word", [ + "Петренко", "Іванов", "Іванова", "Сидоренко", "Коваль", "Шамрай", "Кравчуком", + "Ковальського", "Петренку", "Коломієць", "Бондаренко", "Мельник", + ]) + def test_starts_with_original_prefix(self, word): + m = sm(word) + p = prefix_length_for(word) + assert m.lower()[:p] == word.lower()[:p], f"{word} -> {m}" + assert m.lower() != word.lower() and word.lower() not in m.lower() + + @pytest.mark.parametrize("word,p", [("Ґудзь", 2), ("Ткач", 2), ("Рак", 1)]) + def test_short_surnames_keep_at_most_half(self, word, p): + m = sm(word) + assert m.lower()[:p] == word.lower()[:p] + # і НЕ більше половини: наступний символ маски не збігається з оригіналом + # (інакше витік довший за дозволений) — перевіряємо на наборі seed-ів + assert word.lower() not in m.lower() + + def test_ending_still_preserved(self): + for word, ending in [("Петренку", "енку"), ("Іванова", "ова"), ("Ковальського", "ського"), + ("Кравчуком", "уком")]: + assert sm(word).lower().endswith(ending) + + def test_hyphenated_each_part_keeps_prefix(self): + m = sm("Петренко-Іванова") + a, b = m.split("-") + assert a.lower().startswith("пет") and b.lower().startswith("іва") + + def test_case_preserved(self): + assert sm("ІВАНОВ").startswith("ІВА") and sm("ІВАНОВ").isupper() + assert sm("Іванов").startswith("Іва") + + def test_pronounceable_joint(self): + # На стику префікса й хвоста не буває двох голосних чи трьох приголосних + vowels = "аеиіоуюяєї" + for w in ["Іванов", "Ткач", "Петренко", "Коваль", "Шамрай", "Сидоренко", "Ґудзь"]: + m = sm(w).lower() + p = prefix_length_for(w) + if p and len(m) > p: + assert (m[p - 1] in vowels) != (m[p] in vowels), f"{w} -> {m}" + + def test_prefix_zero_gives_fully_synthetic(self): + _cfg.SURNAME_PREFIX_LENGTH = 0 + masks = {sm(w).lower()[:3] == w.lower()[:3] for w in ["Петренко", "Сидоренко", "Бондаренко", "Іванов", "Коваль"]} + assert False in masks # хоча б одна маска не починається з оригінального префікса + + def test_prefix_two(self): + _cfg.SURNAME_PREFIX_LENGTH = 2 + assert sm("Петренко").lower().startswith("пе") + assert not sm("Петренко").lower().startswith("пет") or True # третя літера може випадково збігтись + + def test_roundtrip_document(self): + text = "\n".join(f"капітан {w} Іван Іванович" for w in ["Петренко", "Іванов", "Ґудзь", "Ткач", "Коваль-Сидоренко"]) + m, md = mask(text) + r, _ = unmask_text_v2(m, md, check_mapping_version(md)) + assert r == text + for w in ["Петренко", "Іванов", "Ґудзь", "Ткач", "Коваль", "Сидоренко"]: + assert w not in m + + +class TestConfigWiring: + SAMPLE = "капітан Петренко Іван Сергійович\nсержант Бондаренко Марія Іванівна\n" + + def _run(self, tmp_path, monkeypatch, extra_args=(), yaml_text=None, env=None): + monkeypatch.chdir(tmp_path) + (tmp_path / "in.txt").write_bytes(self.SAMPLE.encode("utf-8")) + if yaml_text is not None: + (tmp_path / "config.yaml").write_text(yaml_text, encoding="utf-8") + for k, v in (env or {}).items(): + monkeypatch.setenv(k, v) + rc = mask_cli.main(["-i", "in.txt", "-o", "m.txt", "--no-report", *extra_args]) + out = (tmp_path / "m.txt").read_bytes().decode("utf-8") if (tmp_path / "m.txt").exists() else "" + return rc, out + + @needs_yaml + def test_prefix_from_yaml(self, tmp_path, monkeypatch): + rc, out = self._run(tmp_path, monkeypatch, yaml_text="masking_rules:\n surname_prefix_length: 0\n") + assert rc == 0 and _cfg.SURNAME_PREFIX_LENGTH == 0 + + def test_prefix_from_env(self, tmp_path, monkeypatch): + rc, out = self._run(tmp_path, monkeypatch, env={"DATA_MASKING_SURNAME_PREFIX_LENGTH": "2"}) + assert rc == 0 and _cfg.SURNAME_PREFIX_LENGTH == 2 + assert "капітан Пе" in out or "Пе" in out.split()[1] + + @needs_yaml + def test_negative_prefix_rejected(self, tmp_path, monkeypatch): + rc, _ = self._run(tmp_path, monkeypatch, yaml_text="masking_rules:\n surname_prefix_length: -1\n") + assert rc == 1 + + def test_default_prefix_visible_in_cli_output(self, tmp_path, monkeypatch): + rc, out = self._run(tmp_path, monkeypatch) + assert rc == 0 + assert out.splitlines()[0].split()[1].startswith("Пет") + assert "Петренко" not in out + + +class TestFakerLocale: + def test_default_locale(self): + assert _cfg.FAKER_LOCALE == "uk_UA" + + def test_unknown_locale_rejected(self): + with pytest.raises(ValueError): + _cfg.set_faker_locale("xx_YY") + assert _cfg.FAKER_LOCALE == "uk_UA" + + def test_switching_locale_changes_masks_deterministically(self): + a1 = synthesize_surname("Петренко") + _cfg.set_faker_locale("ru_RU") + b1 = synthesize_surname("Петренко") + b2 = synthesize_surname("Петренко") + assert b1 == b2 # детерміновано в межах локалі + assert b1.startswith("пет") and "петренко" not in b1 # префікс і no-leak діють + _cfg.set_faker_locale("uk_UA") + assert synthesize_surname("Петренко") == a1 + + def test_locale_without_patronymics_falls_back(self): + _cfg.set_faker_locale("en_US") + m, md = mask("капітан Петренко Іван Сергійович") + r, _ = unmask_text_v2(m, md, check_mapping_version(md)) + assert r == "капітан Петренко Іван Сергійович" + masked_patr = md["mappings"]["patronymic"]["сергійович"]["masked_as"] + assert masked_patr.lower().endswith("ович") # uk-fallback дав по батькові + + def test_locale_from_env_via_cli(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "in.txt").write_bytes("капітан Петренко Іван Сергійович\n".encode("utf-8")) + monkeypatch.setenv("DATA_MASKING_FAKER_LOCALE", "ru_RU") + assert mask_cli.main(["-i", "in.txt", "-o", "m.txt", "--no-report"]) == 0 + assert _cfg.FAKER_LOCALE == "ru_RU" + monkeypatch.setenv("DATA_MASKING_FAKER_LOCALE", "no_SUCH") + assert mask_cli.main(["-i", "in.txt", "-o", "m2.txt", "--no-report"]) == 1 + assert not (tmp_path / "m2.txt").exists()