diff --git a/CHANGELOG.md b/CHANGELOG.md index 4995062..858a9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ 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/). +## [2.5.7] - 2026-06 + +### Security +- Generated encryption passwords are printed to **stderr** (not stdout) with + a "shown once" warning — keeps them out of redirected output, pipes and + CI logs + +## [2.5.6] - 2026-06 + +### Changed +- README updated for the package architecture (`masking/`, `unmasking/`, + `__main__.py`), initials masking documented +- README: new "Модель загроз та обмеження" section — honest statement that + this is pseudonymization (partial digit/letter preservation, deterministic + unsalted hashing, rank shift ±1-2, date shift ±30 days) +- Wrapper docstrings: corrected `python -m` mention (actual invocation is + `python . mask` / `python . unmask` from the repo root) + +## [2.5.5] - 2026-06 + +### Changed +- `modules/rank_data.py` is now a re-export of the root `rank_data.py` + (was a full 636-line copy that could silently diverge) + +## [2.5.4] - 2026-06 + +### Fixed +- `data_masking.MASK_*`, `DEBUG_MODE`, `PRESERVE_CASE`, `HASH_ALGORITHM` are + live again: reads and writes through the wrapper delegate to + `masking.constants` (after the v2.5.0 refactoring writes were silently + ignored — broken backward compatibility) + +### Changed +- Removed unused imports in `masking/cli.py` (`SelectiveFilter`, + `apply_filter_to_globals`, `ReMasker`) + +## [2.5.3] - 2026-06 + +### Fixed +- Repeated text dates (`06 жовтня 2025 року` twice in a document) now track + instances `[1, 2, ...]` — previously only the first occurrence was restored + by unmask +- Text date masking inside the engine is now deterministic (the internal copy + of the function never seeded the RNG) + +### Changed +- Removed duplicated `_mask_date_text` implementation (~50 lines); + it is now an alias of `mask_date_text` + +## [2.5.2] - 2026-06 + +### Fixed +- **Initials are now reversible**: masked initials (`Іванов П.А.` etc.) are stored + in the mapping under new `initials` category — unmask restores them +- Initials regexes no longer match across line breaks (`П.А.\nСлово` false positive) +- Main PIB parser no longer re-masks surnames already masked by the initials + phase (nested masks broke unmask) +- Initials mapping is written in document order — instance tracking stays + consistent with occurrence order + +### Added +- `tests/test_initials.py` — 27 tests covering all formats and mask→unmask roundtrip +- Version asserts in tests are now dynamic (compare against `masking.constants`) + ## [2.5.1] - 2026-04 ### Fixed diff --git a/data_masking.py b/data_masking.py index 4e02186..685b243 100644 --- a/data_masking.py +++ b/data_masking.py @@ -8,7 +8,7 @@ ОНОВЛЕНО В v2.5.1: - Рефакторинг: розбито на пакет masking/ (constants, helpers, language, context, mask_personal, mask_military, engine, cli) -- Додано __main__.py для запуску через python -m +- Додано __main__.py: запуск з кореня репо — python . mask / python . unmask - Зворотна сумісність: всі імпорти з data_masking продовжують працювати Author: Vladyslav V. Prodan @@ -23,14 +23,12 @@ # Re-exports from masking package for backward compatibility # ============================================================================ -__version__ = "2.5.1" +__version__ = "2.5.7" from masking.constants import ( __version__, __author__, __contact__, __phone__, __license__, __year__, - fake_uk, HASH_ALGORITHM, - MASK_NAMES, MASK_IPN, MASK_PASSPORT, MASK_MILITARY_ID, MASK_RANKS, - MASK_BRIGADES, MASK_UNITS, MASK_ORDERS, MASK_BR_NUMBERS, MASK_DATES, - RANK_SHIFT_OPTIONS, DEBUG_MODE, PRESERVE_CASE, + fake_uk, + RANK_SHIFT_OPTIONS, ABBREVIATION_WHITELIST, UKRAINIAN_DATE_PATTERN, DATE_TEXT_PATTERN, GOOD_UKRAINIAN_NAMES_MALE, GOOD_UKRAINIAN_NAMES_FEMALE, PROBLEMATIC_NAMES, EXCLUDE_WORDS, @@ -96,5 +94,42 @@ main, ) +# ============================================================================ +# "Живі" конфігураційні прапорці +# ============================================================================ +# Рушій читає прапорці з masking.constants. Просте from-import дало б копії: +# старий код `data_masking.MASK_NAMES = False` мовчки не діяв би. +# Тому читання делегуємо через __getattr__ (PEP 562), а запис — через +# підміну класу модуля. + +import sys as _sys +from types import ModuleType as _ModuleType + +from masking import constants as _cfg + +_LIVE_FLAGS = frozenset({ + "MASK_NAMES", "MASK_IPN", "MASK_PASSPORT", "MASK_MILITARY_ID", + "MASK_RANKS", "MASK_BRIGADES", "MASK_UNITS", "MASK_ORDERS", + "MASK_BR_NUMBERS", "MASK_DATES", + "DEBUG_MODE", "PRESERVE_CASE", "HASH_ALGORITHM", +}) + + +def __getattr__(name): + if name in _LIVE_FLAGS: + return getattr(_cfg, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +class _LiveFlagsModule(_ModuleType): + def __setattr__(self, name, value): + if name in _LIVE_FLAGS: + setattr(_cfg, name, value) + else: + super().__setattr__(name, value) + + +_sys.modules[__name__].__class__ = _LiveFlagsModule + if __name__ == "__main__": main() diff --git a/docs/README.md b/docs/README.md index 56d15e5..92e75c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,20 +1,43 @@ -# Data Masking & Unmasking Scripts v2.4.0 +# Data Masking & Unmasking Scripts Скрипти для **узгодженого маскування** конфіденційних даних у військових документах з можливістю точного відновлення. +Актуальна версія — див. [CHANGELOG.md](../CHANGELOG.md) або `python data_masking.py -V`. + --- ## 📋 Структура проекту -### Основні скрипти -- **`data_masking.py`** — маскування даних з instance tracking -- **`unmask_data.py`** — розмаскування з точним відновленням +### Точки входу +- **`data_masking.py`** — маскування даних (тонка обгортка над пакетом `masking/`) +- **`unmask_data.py`** — розмаскування (тонка обгортка над пакетом `unmasking/`) - **`diagnose_mapping.py`** — діагностика, порівняння маппінгів та верифікація відновлення +- **`__main__.py`** — запуск з кореня репозиторію: `python . mask [args]` / `python . unmask [args]` + +### Пакет `masking/` (ядро маскування, v2.5.0+) +| Модуль | Опис | +|--------|------| +| `constants.py` | Прапорці `MASK_*`, патерни, словники імен, метадані (єдине джерело версії) | +| `helpers.py` | Seed, mapping, instance tracking, нормалізація | +| `language.py` | Рід, відмінок, відмінювання імен | +| `context.py` | Розпізнавання рядків з ПІБ, парсинг контексту | +| `mask_personal.py` | ІПН, паспорти, прізвища, імена, по батькові | +| `mask_military.py` | Звання, частини, накази, БР, дати | +| `engine.py` | Головний рушій: контекстне маскування тексту/JSON, ініціали | +| `cli.py` | CLI, конфіг, звіти, `main()` | + +### Пакет `unmasking/` (ядро розмаскування, v2.5.0+) +| Модуль | Опис | +|--------|------| +| `helpers.py` | Instance map, пошук пар файлів, версії маппінгів | +| `engine.py` | Відновлення тексту/JSON, ланцюги re-mask | +| `io.py` | Завантаження mapping (.json/.enc), валідація схеми | +| `cli.py` | CLI, `main()` | ### Дані -- **`rank_data.py`** — звання ЗСУ (армія, флот, медична/юридична служба) з відмінками +- **`rank_data.py`** — звання ЗСУ (армія, флот, медична/юридична служба) з відмінками; `modules/rank_data.py` — ре-експорт -### Пакет `modules/` +### Пакет `modules/` (опційні можливості) | Модуль | Опис | |--------|------| | `config.py` | YAML + ENV + CLI конфігурація з пріоритетами (CLI > ENV > YAML > Default) | @@ -79,6 +102,7 @@ python diagnose_mapping.py --verify input.txt recovered.txt # верифіка ### Персональні дані - **ПІБ** — з урахуванням відмінків та роду +- **ПІБ з ініціалами** (v2.5.0+) — `Іванов П.А.`, `П. Агранов`, `К.П. Іванов`, `Т. А. Сидоренко`, `КОВАЛЕНКО І.В.`; ініціали зберігаються у mapping і відновлюються при unmask - **По батькові** — маскуються зі збереженням гендеру та регістру - **ІПН** — 10 цифр - **ID-паспорти** — 9 цифр (перші 3 + остання фіксовані, середні 5 — випадкові) @@ -436,6 +460,27 @@ ID-паспорт 123947568, видано наказом №59/87/4249/Р від 4. Використовуйте різні mapping для різних документів 5. Використовуйте шифрування mapping файлів (v2.3.0+): `--encrypt --password` 6. Unmask можливий ТІЛЬКИ при наявності mapping файлу +7. Передавайте пароль через `--password-env ІМ'Я_ЗМІННОЇ`, а не `--password` + (аргументи командного рядка видно в історії shell та списку процесів) + +### ⚠️ Модель загроз та обмеження + +Це **псевдонімізація**, а не повна анонімізація. Враховуйте: + +- **Часткове збереження оригіналу.** ІПН зберігає перші 3 та останню цифру + (4 з 10), паспорт — 4 з 9, довгі прізвища — перші 3 та останні 5 літер. + Це навмисно (зберігає формат і частковий контекст), але дозволяє + ре-ідентифікацію перебором за словником кандидатів. +- **Детермінована генерація без секрету.** Маски виводяться з blake2b-хешу + оригіналу без солі/ключа. Хто знає алгоритм і має список можливих + оригіналів, може зіставити маски без mapping-файлу. +- **Звання зсуваються лише на ±1–2 позиції** в ієрархії — приблизний ранг + особи залишається видимим. +- **Дати зсуваються на ±30 днів** — період подій залишається впізнаваним. + +**Висновок:** замасковані файли захищають від випадкового розголошення та +підходять для передачі обмеженому колу, але НЕ розраховані на публікацію +проти мотивованого супротивника зі знанням контексту. --- @@ -501,6 +546,4 @@ BSD 3-Clause "New" or "Revised" License ## 📅 Версія -**v2.4.0** (квітень 2026) - -Детальну історію змін див. у [CHANGELOG.md](../CHANGELOG.md) +Актуальну версію та історію змін див. у [CHANGELOG.md](../CHANGELOG.md) diff --git a/masking/cli.py b/masking/cli.py index dbc706f..555d853 100644 --- a/masking/cli.py +++ b/masking/cli.py @@ -28,14 +28,14 @@ _opt_logger = _logging.getLogger(__name__) try: - from modules.selective import SelectiveFilter, apply_filter_to_globals, get_available_types + from modules.selective import get_available_types SELECTIVE_AVAILABLE = True except ImportError: SELECTIVE_AVAILABLE = False _opt_logger.debug("modules.selective not available — --only/--exclude disabled") try: - from modules.re_mask import ReMasker, MappingChain, make_empty_masking_dict + from modules.re_mask import MappingChain, make_empty_masking_dict REMASK_AVAILABLE = True except ImportError: REMASK_AVAILABLE = False @@ -459,6 +459,15 @@ def _run_single_pass_masking(input_data, is_json: bool, masking_dict: Dict, return masked_data, total_unique +def _print_generated_password(password: str) -> None: + """Показує згенерований пароль у stderr (не stdout), щоб він не + потрапляв у перенаправлений вивід, пайпи та логи CI.""" + import sys + print(" Generated password (shown once, NOT saved anywhere):", + file=sys.stderr) + print(f" {password}", file=sys.stderr) + + def _handle_encryption(args, config, masking_dict: Dict, map_path: Path, logger) -> None: """Encrypt the mapping file if --encrypt is requested.""" @@ -477,10 +486,10 @@ def _handle_encryption(args, config, masking_dict: Dict, map_path: Path, if logger: logger.warning(f"Environment variable '{password_env}' is not set or empty") password = generate_password_from_config(config) - print(f" Generated password: {password}") + _print_generated_password(password) else: password = generate_password_from_config(config) - print(f" Generated password: {password}") + _print_generated_password(password) manager = MappingSecurityManager() manager.encrypt_mapping(masking_dict, password, enc_path) @@ -689,7 +698,8 @@ def main(): "ipn", "passport_id", "military_id", "surname", "name", "military_unit", "order_number", "order_number_with_letters", "br_number", "br_number_slash", "br_number_complex", - "rank", "brigade_number", "date", "date_text", "patronymic" + "rank", "brigade_number", "date", "date_text", "patronymic", + "initials" ]}, "instance_tracking": {} } diff --git a/masking/constants.py b/masking/constants.py index 25b8853..f541b37 100644 --- a/masking/constants.py +++ b/masking/constants.py @@ -26,7 +26,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.5.1" +__version__ = "2.5.7" __author__ = "Vladyslav V. Prodan" __contact__ = "github.com/click0" __phone__ = "+38(099)6053340" diff --git a/masking/engine.py b/masking/engine.py index 736f095..5e27941 100644 --- a/masking/engine.py +++ b/masking/engine.py @@ -37,23 +37,27 @@ _SURNAME_UPPER_RE = r'[А-ЯІЇЄҐ]{3,}' _NAME_RE = r'(?:' + _SURNAME_RE + r'|' + _SURNAME_UPPER_RE + r')' +# Пробіл у межах рядка (НЕ \s — щоб ініціали не склеювались +# з наступним рядком через \n) +_SP = r'[  ]' + # Прізвище + 2 ініціали: Іванов П.А. / Іванов П. А. / ІВАНОВ П.А. _RE_NAME_INI2 = re.compile( - r'(' + _NAME_RE + r')\s+([А-ЯІЇЄҐ])\.\s?([А-ЯІЇЄҐ])\.' + r'(' + _NAME_RE + r')' + _SP + r'+([А-ЯІЇЄҐ])\.' + _SP + r'?([А-ЯІЇЄҐ])\.' ) # 2 ініціали + Прізвище: П.А. Іванов / П. А. Іванов _RE_INI2_NAME = re.compile( r'(? k[0] for k in kept): - kept.append(r) - - # Заміни з кінця тексту - kept.sort(key=lambda x: x[0], reverse=True) - for start, end, new_text in kept: + for c in candidates: + if not any(c[0] < k[1] and c[1] > k[0] for k in kept): + kept.append(c) + + # Фаза 2: у порядку документа маскуємо та записуємо mapping + masking_dict["mappings"].setdefault("initials", {}) + kept.sort(key=lambda x: x[0]) + replacements = [] + for start, end, surname, initials, has_space, ini_first in kept: + ms = mask_surname(surname, masking_dict, instance_counters) + sep = '. ' if has_space else '.' + orig_ini = sep.join(initials) + '.' + masked_letters = [_mask_initial(i, surname) for i in initials] + masked_ini = sep.join(masked_letters) + '.' + # Зберігаємо у mapping — інакше unmask не зможе відновити ініціали + masked_ini = add_to_mapping(masking_dict, instance_counters, + "initials", orig_ini, masked_ini) + new_text = f"{masked_ini} {ms}" if ini_first else f"{ms} {masked_ini}" + replacements.append((start, end, new_text)) + + # Заміни з кінця тексту, щоб не збити позиції + for start, end, new_text in reversed(replacements): text = text[:start] + new_text + text[end:] return text @@ -339,6 +330,19 @@ def mask_text_context_aware(text: str, masking_dict: Dict, instance_counters: Di if pib and _cfg.MASK_NAMES: parts = pib.split() if len(parts) >= 2: + # Не маскуємо повторно те, що вже є маскою (наприклад, + # прізвище, замасковане фазою ініціалів) — вкладену маску + # unmask не зможе розкрутити за один прохід + already_masked = { + info["masked_as"].lower() + for cat in ("surname", "name") + for info in masking_dict["mappings"].get(cat, {}).values() + if isinstance(info, dict) and "masked_as" in info + } + if any(p.lower() in already_masked for p in parts[:2]): + current_line_for_parsing = current_line_for_parsing.replace(pib, "___PIB_MASKED___", 1) + iteration += 1 + continue if is_likely_surname_by_case(parts[1]): name, surname = parts[0], parts[1] patronymic = parts[2] if len(parts) >= 3 else "" diff --git a/masking/mask_military.py b/masking/mask_military.py index 181e462..01d59cc 100644 --- a/masking/mask_military.py +++ b/masking/mask_military.py @@ -197,6 +197,10 @@ def mask_date(original: str, masking_dict: Dict, instance_counters: Dict) -> str def mask_date_text(original: str, masking_dict: Dict, instance_counters: Dict) -> str: """ Маскує текстову дату у форматі: "06" жовтня 2025 року. + + Детерміновано (seed від дати), instance tracking через add_to_mapping — + повторні входження тієї самої дати отримують instances [1, 2, ...] + і коректно відновлюються при unmask. """ match = _cfg.DATE_TEXT_PATTERN.search(original) if not match: @@ -204,92 +208,43 @@ def mask_date_text(original: str, masking_dict: Dict, instance_counters: Dict) - day, month_name, year = match.group(1), match.group(2), match.group(3) original_key = f"{day} {month_name} {year}" + key = original_key.lower() - category = "date_text" - - # Перевіряємо чи вже маскували цю дату - if category not in masking_dict["mappings"]: - masking_dict["mappings"][category] = {} - - existing = masking_dict["mappings"].get(category, {}) - if original_key.lower() in existing: - masked_val = existing[original_key.lower()] - if isinstance(masked_val, dict): - masked_val = masked_val.get("masked_as", original) - parts = masked_val.split() - if len(parts) >= 3: - result = original.replace(day, parts[0], 1).replace( - month_name, parts[1], 1).replace(year, parts[2], 1) - return result - return original - - # Генеруємо детермінований seed - seed = get_deterministic_seed(original_key) - random.seed(seed) - - # Зміщуємо день на +-5 (в межах 1-28) - day_shift = random.choice([-5, -3, -2, 2, 3, 5]) - new_day = str(max(1, min(28, int(day) + day_shift))).zfill(len(day)) - - # Замінюємо місяць на випадковий інший - available_months = [m for m in _cfg._MONTHS_UA_LIST if m != month_name.lower()] - new_month = random.choice(available_months) + masking_dict["mappings"].setdefault("date_text", {}) + existing = masking_dict["mappings"]["date_text"].get(key) + if existing is not None: + masked_key = existing["masked_as"] if isinstance(existing, dict) else existing + else: + # Генеруємо детермінований seed + seed = get_deterministic_seed(original_key) + random.seed(seed) - # Зміщуємо рік на +-1 - year_shift = random.choice([-1, 0, 1]) - new_year = str(int(year) + year_shift) + # Зміщуємо день на +-5 (в межах 1-28) + day_shift = random.choice([-5, -3, -2, 2, 3, 5]) + new_day = str(max(1, min(28, int(day) + day_shift))).zfill(len(day)) - # Формуємо замаскований текст - masked_text = original.replace(day, new_day, 1).replace( - month_name, new_month, 1).replace(year, new_year, 1) - masked_key = f"{new_day} {new_month} {new_year}" + # Замінюємо місяць на випадковий інший + available_months = [m for m in _cfg._MONTHS_UA_LIST if m != month_name.lower()] + new_month = random.choice(available_months) - # Зберігаємо у mappings - mappings = masking_dict["mappings"].setdefault(category, {}) - key = original_key.lower() - if key not in mappings: - mappings[key] = {"masked_as": masked_key, "instances": [1]} - instance_counters[masked_key] = instance_counters.get(masked_key, 0) + 1 - masking_dict["statistics"][category] = masking_dict["statistics"].get(category, 0) + 1 + # Зміщуємо рік на +-1 + year_shift = random.choice([-1, 0, 1]) + new_year = str(int(year) + year_shift) - return masked_text + masked_key = f"{new_day} {new_month} {new_year}" + masked_key = add_to_mapping(masking_dict, instance_counters, + "date_text", key, masked_key) -def _mask_date_text(original: str, masking_dict: Dict, instance_counters: Dict) -> str: - """Mask a text date like '"06" жовтня 2025 року'.""" - match = _cfg.DATE_TEXT_PATTERN.search(original) - if not match: + parts = masked_key.split() + if len(parts) < 3: return original + return original.replace(day, parts[0], 1).replace( + month_name, parts[1], 1).replace(year, parts[2], 1) - day, month, year = match.group(1), match.group(2), match.group(3) - original_key = f"{day} {month} {year}" - - category = "date_text" - existing = masking_dict["mappings"].get(category, {}) - if original_key.lower() in existing: - masked_val = existing[original_key.lower()] - if isinstance(masked_val, dict): - masked_val = masked_val.get("masked_as", original) - return original.replace(day, masked_val.split()[0]).replace( - month, masked_val.split()[1] if len(masked_val.split()) > 1 else month).replace( - year, masked_val.split()[2] if len(masked_val.split()) > 2 else year) - - # Shift day by +-5, month randomly, year +-1 - new_day = str(max(1, min(28, int(day) + random.choice([-5, -3, -2, 2, 3, 5])))).zfill(len(day)) - new_month = random.choice([m for m in _cfg._MONTHS_UA_LIST if m != month.lower()]) - new_year = str(int(year) + random.choice([-1, 0, 1])) - - masked_text = original.replace(day, new_day, 1).replace(month, new_month, 1).replace(year, new_year, 1) - masked_key = f"{new_day} {new_month} {new_year}" - - # Store in mappings directly - mappings = masking_dict["mappings"].setdefault(category, {}) - key = original_key.lower() - if key not in mappings: - mappings[key] = {"masked_as": masked_key, "instances": [1]} - instance_counters[masked_key] = instance_counters.get(masked_key, 0) + 1 - masking_dict["statistics"][category] = masking_dict["statistics"].get(category, 0) + 1 - return masked_text + +# Аліас для зворотної сумісності (історично було дві копії функції) +_mask_date_text = mask_date_text # ============================================================================ diff --git a/modules/rank_data.py b/modules/rank_data.py index 238d37c..5b15e7b 100644 --- a/modules/rank_data.py +++ b/modules/rank_data.py @@ -1,636 +1,12 @@ # -*- coding: utf-8 -*- """ -rank_data.py - Модуль даних для українських військових звань та їх відмінювання - -Цей модуль містить комплексні словники для роботи з українськими військовими званнями, -включаючи їх граматичні форми (відмінки), жіночі форми (фемінітиви) та допоміжні -структури для автоматичного розпізнавання та перетворення звань. - -Модуль використовується в: - - data_masking.py: для маскування звань з збереженням граматичної форми та роду - - unmask_data.py: для відновлення оригінальних звань з граматичним узгодженням - -Основні компоненти: - - RANK_DECLENSIONS: відмінки чоловічих форм звань (називний, родовий, давальний, орудний) - - RANK_FEMININE_MAP: мапа чоловічих звань на жіночі фемінітиви - - RANK_DECLENSIONS_FEMALE: відмінки жіночих форм звань - - RANK_TO_NOMINATIVE: зворотний індекс для швидкого пошуку базової форми - - ALL_RANK_FORMS: відсортований список всіх форм для розпізнавання - - RANKS_LIST: повний список звань для генерації масок - -Відмінки українських звань: - - nominative (називний): хто? що? - "солдат", "капітан" - - genitive (родовий): кого? чого? - "солдата", "капітана" - - dative (давальний): кому? чому? - "солдату", "капітану" - - instrumental (орудний): ким? чим? - "солдатом", "капітаном" - -Приклад використання: - >>> from rank_data import RANK_DECLENSIONS, RANK_TO_NOMINATIVE - >>> # Отримати форму давального відмінка - >>> RANK_DECLENSIONS['солдат']['dative'] - 'солдату' - >>> # Розпізнати звання та відмінок з тексту - >>> RANK_TO_NOMINATIVE['солдату'] - ('солдат', 'dative', 'male') - -Author: Vladyslav V. Prodan -Contact: github.com/click0 -Phone: +38(099)6053340 -Version: 2.2.14 -License: BSD 3-Clause "New" or "Revised" License -Year: 2025-2026 +Ре-експорт кореневого rank_data.py. +Історично тут лежала повна копія даних звань, яка могла розійтися +з кореневою. Канонічне джерело — rank_data.py у корені проєкту +(modules/__init__.py тягне security/cryptography, тому залежність +у зворотний бік зробила б cryptography обов'язковою). """ -# ============================================================================ -# СЛОВНИКИ ВІДМІНКІВ ЗВАНЬ (ЧОЛОВІЧІ ФОРМИ) -# ============================================================================ -# Відповідають структурі військових звань ЗСУ після реформи 2020 року. -# Кожне звання має 4 основні граматичні форми для правильного узгодження -# з контекстом речення. - -RANK_DECLENSIONS = { - # ======================================================================== - # РЯДОВІ (ПРИВАТНІ) ЗВАННЯ - # ======================================================================== - # Найнижчі військові звання в ієрархії ЗСУ - - 'рекрут': { - 'nominative': 'рекрут', # "рекрут прибув" - 'genitive': 'рекрута', # "наказ рекрута" - 'dative': 'рекруту', # "рекруту наказано" - 'instrumental': 'рекрутом', # "з рекрутом" - }, - - 'рядовий': { - 'nominative': 'рядовий', # "рядовий виконав" - 'genitive': 'рядового', # "рапорт рядового" - 'dative': 'рядовому', # "рядовому присвоєно" - 'instrumental': 'рядовим', # "з рядовим" - }, - - 'солдат': { - 'nominative': 'солдат', # "солдат виконав" - 'genitive': 'солдата', # "рапорт солдата" - 'dative': 'солдату', # "солдату присвоєно" - 'instrumental': 'солдатом', # "із солдатом" - }, - - 'старший солдат': { - 'nominative': 'старший солдат', # "старший солдат доповів" - 'genitive': 'старшого солдата', # "звіт старшого солдата" - 'dative': 'старшому солдату', # "старшому солдату наказано" - 'instrumental': 'старшим солдатом', # "зі старшим солдатом" - }, - - # --- МОРСЬКІ РЯДОВІ --- - # Аналоги солдатів для ВМС України - - 'матрос': { - 'nominative': 'матрос', - 'genitive': 'матроса', - 'dative': 'матросу', - 'instrumental': 'матросом', - }, - - 'старший матрос': { - 'nominative': 'старший матрос', - 'genitive': 'старшого матроса', - 'dative': 'старшому матросу', - 'instrumental': 'старшим матросом', - }, - - 'моряк': { - 'nominative': 'моряк', - 'genitive': 'моряка', - 'dative': 'моряку', - 'instrumental': 'моряком', - }, - - 'старший моряк': { - 'nominative': 'старший моряк', - 'genitive': 'старшого моряка', - 'dative': 'старшому моряку', - 'instrumental': 'старшим моряком', - }, - - # ======================================================================== - # СЕРЖАНТСЬКІ ЗВАННЯ - # ======================================================================== - # Молодші командири підрозділів (відділення, екіпажі) - - 'молодший сержант': { - 'nominative': 'молодший сержант', - 'genitive': 'молодшого сержанта', - 'dative': 'молодшому сержанту', - 'instrumental': 'молодшим сержантом', - }, - - 'сержант': { - 'nominative': 'сержант', - 'genitive': 'сержанта', - 'dative': 'сержанту', - 'instrumental': 'сержантом', - }, - - 'старший сержант': { - 'nominative': 'старший сержант', - 'genitive': 'старшого сержанта', - 'dative': 'старшому сержанту', - 'instrumental': 'старшим сержантом', - }, - - 'головний сержант': { - 'nominative': 'головний сержант', - 'genitive': 'головного сержанта', - 'dative': 'головному сержанту', - 'instrumental': 'головним сержантом', - }, - - # --- СТАРШІ СЕРЖАНТИ --- - # Звання введені після реформи 2020 (за стандартами НАТО) - - 'штаб-сержант': { - 'nominative': 'штаб-сержант', - 'genitive': 'штаб-сержанта', - 'dative': 'штаб-сержанту', - 'instrumental': 'штаб-сержантом', - }, - 'мастер-сержант': { - 'nominative': 'мастер-сержант', - 'genitive': 'мастер-сержанта', - 'dative': 'мастер-сержанту', - 'instrumental': 'мастер-сержантом', - }, - 'штабс-сержант': { - 'nominative': 'штабс-сержант', - 'genitive': 'штабс-сержанта', - 'dative': 'штабс-сержанту', - 'instrumental': 'штабс-сержантом', - }, - 'майстер-сержант': { - 'nominative': 'майстер-сержант', - 'genitive': 'майстер-сержанта', - 'dative': 'майстер-сержанту', - 'instrumental': 'майстер-сержантом', - }, - 'старший майстер-сержант': { - 'nominative': 'старший майстер-сержант', - 'genitive': 'старшого майстер-сержанта', - 'dative': 'старшому майстер-сержанту', - 'instrumental': 'старшим майстер-сержантом', - }, - 'головний майстер-сержант': { - 'nominative': 'головний майстер-сержант', - 'genitive': 'головного майстер-сержанта', - 'dative': 'головному майстер-сержанту', - 'instrumental': 'головним майстер-сержантом', - }, - - # --- ПРАПОРЩИКИ --- - # Перехідна ланка між сержантами та офіцерами - - 'прапорщик': { - 'nominative': 'прапорщик', - 'genitive': 'прапорщика', - 'dative': 'прапорщику', - 'instrumental': 'прапорщиком', - }, - - 'старший прапорщик': { - 'nominative': 'старший прапорщик', - 'genitive': 'старшого прапорщика', - 'dative': 'старшому прапорщику', - 'instrumental': 'старшим прапорщиком', - }, - - # ======================================================================== - # ОФІЦЕРСЬКІ ЗВАННЯ - # ======================================================================== - - # --- МОЛОДШІ ОФІЦЕРИ --- - # Командири взводів та їх заступники - - 'молодший лейтенант': { - 'nominative': 'молодший лейтенант', - 'genitive': 'молодшого лейтенанта', - 'dative': 'молодшому лейтенанту', - 'instrumental': 'молодшим лейтенантом', - }, - - 'лейтенант': { - 'nominative': 'лейтенант', - 'genitive': 'лейтенанта', - 'dative': 'лейтенанту', - 'instrumental': 'лейтенантом', - }, - - 'старший лейтенант': { - 'nominative': 'старший лейтенант', - 'genitive': 'старшого лейтенанта', - 'dative': 'старшому лейтенанту', - 'instrumental': 'старшим лейтенантом', - }, - - # --- СТАРШІ ОФІЦЕРИ --- - # Командири рот, батальйонів - - 'капітан': { - 'nominative': 'капітан', - 'genitive': 'капітана', - 'dative': 'капітану', - 'instrumental': 'капітаном', - }, - - 'майор': { - 'nominative': 'майор', - 'genitive': 'майора', - 'dative': 'майору', - 'instrumental': 'майором', - }, - - 'підполковник': { - 'nominative': 'підполковник', - 'genitive': 'підполковника', - 'dative': 'підполковнику', - 'instrumental': 'підполковником', - }, - - 'полковник': { - 'nominative': 'полковник', - 'genitive': 'полковника', - 'dative': 'полковнику', - 'instrumental': 'полковником', - }, - - # ======================================================================== - # ГЕНЕРАЛЬСЬКІ ЗВАННЯ - # ======================================================================== - # Вищі військові звання командного складу ЗСУ - - 'бригадний генерал': { - 'nominative': 'бригадний генерал', - 'genitive': 'бригадного генерала', - 'dative': 'бригадному генералу', - 'instrumental': 'бригадним генералом', - }, - - 'генерал-майор': { - 'nominative': 'генерал-майор', - 'genitive': 'генерал-майора', - 'dative': 'генерал-майору', - 'instrumental': 'генерал-майором', - }, - - 'генерал-лейтенант': { - 'nominative': 'генерал-лейтенант', - 'genitive': 'генерал-лейтенанта', - 'dative': 'генерал-лейтенанту', - 'instrumental': 'генерал-лейтенантом', - }, - - 'генерал': { - 'nominative': 'генерал', - 'genitive': 'генерала', - 'dative': 'генералу', - 'instrumental': 'генералом', - }, - - # ======================================================================== - # СПЕЦІАЛЬНІ СЛУЖБИ - МЕДИЧНА СЛУЖБА - # ======================================================================== - # Звання військових медиків (лікарів, медсестер тощо) - # Формат: "[базове звання] медичної служби" - - 'капітан медичної служби': { - 'nominative': 'капітан медичної служби', - 'genitive': 'капітана медичної служби', - 'dative': 'капітану медичної служби', - 'instrumental': 'капітаном медичної служби', - }, - - 'майор медичної служби': { - 'nominative': 'майор медичної служби', - 'genitive': 'майора медичної служби', - 'dative': 'майору медичної служби', - 'instrumental': 'майором медичної служби', - }, - - 'підполковник медичної служби': { - 'nominative': 'підполковник медичної служби', - 'genitive': 'підполковника медичної служби', - 'dative': 'підполковнику медичної служби', - 'instrumental': 'підполковником медичної служби', - }, - - 'полковник медичної служби': { - 'nominative': 'полковник медичної служби', - 'genitive': 'полковника медичної служби', - 'dative': 'полковнику медичної служби', - 'instrumental': 'полковником медичної служби', - }, - - # ======================================================================== - # МОРСЬКІ ОФІЦЕРСЬКІ ЗВАННЯ - # ======================================================================== - # Специфічні звання ВМС України (відповідають армійським рангам) - - 'капітан 1-го рангу': { # ≈ полковник - 'nominative': 'капітан 1-го рангу', - 'genitive': 'капітана 1-го рангу', - 'dative': 'капітану 1-го рангу', - 'instrumental': 'капітаном 1-го рангу', - }, - - 'капітан 2-го рангу': { # ≈ підполковник - 'nominative': 'капітан 2-го рангу', - 'genitive': 'капітана 2-го рангу', - 'dative': 'капітану 2-го рангу', - 'instrumental': 'капітаном 2-го рангу', - }, - - 'капітан 3-го рангу': { # ≈ майор - 'nominative': 'капітан 3-го рангу', - 'genitive': 'капітана 3-го рангу', - 'dative': 'капітану 3-го рангу', - 'instrumental': 'капітаном 3-го рангу', - }, - - # --- АДМІРАЛЬСЬКІ ЗВАННЯ --- - - 'контр-адмірал': { # ≈ бригадний генерал - 'nominative': 'контр-адмірал', - 'genitive': 'контр-адмірала', - 'dative': 'контр-адміралу', - 'instrumental': 'контр-адміралом', - }, - - 'віце-адмірал': { # ≈ генерал-майор - 'nominative': 'віце-адмірал', - 'genitive': 'віце-адмірала', - 'dative': 'віце-адміралу', - 'instrumental': 'віце-адміралом', - }, - - 'адмірал': { # ≈ генерал-лейтенант - 'nominative': 'адмірал', - 'genitive': 'адмірала', - 'dative': 'адміралу', - 'instrumental': 'адміралом', - }, -} - -# ============================================================================ -# СЛОВНИКИ ФЕМІНІТИВІВ (ЖІНОЧІ ФОРМИ ЗВАНЬ) -# ============================================================================ -# З 2018 року в ЗСУ офіційно дозволено використання жіночих форм звань. -# Цей словник забезпечує правильне перетворення чоловічих звань на жіночі. - -# --- МАПА ПЕРЕТВОРЕННЯ: чоловіча форма → жіноча форма --- -# Наразі фемінітиви затверджені тільки для частини звань (до майора включно) -RANK_FEMININE_MAP = { - 'солдат': 'солдатка', - 'старший солдат': 'старша солдатка', - 'молодший сержант': 'молодша сержантка', - 'сержант': 'сержантка', - 'старший сержант': 'старша сержантка', - 'молодший лейтенант': 'молодша лейтенантка', - 'лейтенант': 'лейтенантка', - 'старший лейтенант': 'старша лейтенантка', - 'капітан': 'капітанка', - 'майор': 'майорка', -} - -# --- ВІДМІНКИ ЖІНОЧИХ ФОРМ --- -# Жіночі форми мають власні правила відмінювання (закінчення -а, -и, -ці, -ою) -RANK_DECLENSIONS_FEMALE = { - 'солдатка': { - 'nominative': 'солдатка', # "солдатка виконала" - 'genitive': 'солдатки', # "рапорт солдатки" - 'dative': 'солдатці', # "солдатці присвоєно" - 'instrumental': 'солдаткою', # "із солдаткою" - }, - - 'старша солдатка': { - 'nominative': 'старша солдатка', - 'genitive': 'старшої солдатки', - 'dative': 'старшій солдатці', - 'instrumental': 'старшою солдаткою', - }, - - 'молодша сержантка': { - 'nominative': 'молодша сержантка', - 'genitive': 'молодшої сержантки', - 'dative': 'молодшій сержантці', - 'instrumental': 'молодшою сержанткою', - }, - - 'сержантка': { - 'nominative': 'сержантка', - 'genitive': 'сержантки', - 'dative': 'сержантці', - 'instrumental': 'сержанткою', - }, - - 'старша сержантка': { - 'nominative': 'старша сержантка', - 'genitive': 'старшої сержантки', - 'dative': 'старшій сержантці', - 'instrumental': 'старшою сержанткою', - }, - - 'молодша лейтенантка': { - 'nominative': 'молодша лейтенантка', - 'genitive': 'молодшої лейтенантки', - 'dative': 'молодшій лейтенантці', - 'instrumental': 'молодшою лейтенанткою', - }, - - 'лейтенантка': { - 'nominative': 'лейтенантка', - 'genitive': 'лейтенантки', - 'dative': 'лейтенантці', - 'instrumental': 'лейтенанткою', - }, - - 'старша лейтенантка': { - 'nominative': 'старша лейтенантка', - 'genitive': 'старшої лейтенантки', - 'dative': 'старшій лейтенантці', - 'instrumental': 'старшою лейтенанткою', - }, - - 'капітанка': { - 'nominative': 'капітанка', - 'genitive': 'капітанки', - 'dative': 'капітанці', - 'instrumental': 'капітанкою', - }, - - 'майорка': { - 'nominative': 'майорка', - 'genitive': 'майорки', - 'dative': 'майорці', - 'instrumental': 'майоркою', - }, -} - -# ============================================================================ -# ГЕНЕРАЦІЯ ДОПОМІЖНИХ СТРУКТУР -# ============================================================================ -# Автоматичне створення зворотних індексів для швидкого пошуку та розпізнавання - -# --- 1. RANK_TO_NOMINATIVE: ЗВОРОТНИЙ ІНДЕКС --- -# Структура: {будь-яка_форма_звання: (базова_чоловіча_форма, відмінок, рід)} -# Дозволяє за будь-якою формою звання (навіть у непрямому відмінку) швидко знайти: -# - базову форму для маскування -# - відмінок для відновлення правильної форми -# - рід для узгодження з іменем/прізвищем -# -# Приклади: -# "солдату" → ('солдат', 'dative', 'male') -# "сержантці" → ('сержант', 'dative', 'female') -# "капітана медичної служби" → ('капітан медичної служби', 'genitive', 'male') - -RANK_TO_NOMINATIVE = {} - -# Приклади структури після автогенерації (закоментовано): -# { -# 'солдат': ('солдат', 'nominative', 'male'), -# 'солдата': ('солдат', 'genitive', 'male'), -# 'солдату': ('солдат', 'dative', 'male'), -# 'солдатом': ('солдат', 'instrumental', 'male'), -# 'сержантка': ('сержант', 'nominative', 'female'), -# 'сержантці': ('сержант', 'dative', 'female'), -# 'капітана медичної служби': ('капітан медичної служби', 'genitive', 'male'), -# 'рядового': ('рядовий', 'genitive', 'male'), -# ... -# } - -# Додаємо всі чоловічі форми -for nominative_rank, cases in RANK_DECLENSIONS.items(): - for case_name, case_form in cases.items(): - # Зберігаємо у lower case для case-insensitive пошуку - RANK_TO_NOMINATIVE[case_form.lower()] = (nominative_rank, case_name, 'male') - -# Додаємо всі жіночі форми -for nominative_rank_female, cases in RANK_DECLENSIONS_FEMALE.items(): - # Знаходимо відповідну чоловічу базову форму - base_male_form = None - for male_form, female_form in RANK_FEMININE_MAP.items(): - if female_form == nominative_rank_female: - base_male_form = male_form - break - - # Якщо знайшли чоловічу форму, додаємо всі жіночі відмінки - if base_male_form: - for case_name, case_form in cases.items(): - # Зберігаємо базову ЧОЛОВІЧУ форму для уніфікації маскування - RANK_TO_NOMINATIVE[case_form.lower()] = (base_male_form, case_name, 'female') - -# --- 2. ALL_RANK_FORMS: ВІДСОРТОВАНИЙ СПИСОК ДЛЯ РОЗПІЗНАВАННЯ --- -# Список всіх можливих форм звань (усі відмінки + роди) для швидкого пошуку в тексті. -# Сортування від найдовших до найкоротших критично важливе для правильного розпізнавання -# складених звань типу "старший лейтенант" перед простим "лейтенант". -# -# Приклад проблеми без правильного сортування: -# Текст: "старший лейтенант" -# Якщо спочатку шукати "лейтенант", то знайдеться тільки він, а "старший" залишиться. -# При пошуку спочатку "старший лейтенант" - розпізнається правильно як єдине звання. - -ALL_RANK_FORMS = sorted(RANK_TO_NOMINATIVE.keys(), key=len, reverse=True) - -# ============================================================================ -# СПИСКИ ЗВАНЬ ДЛЯ МАСКУВАННЯ (КАТЕГОРИЗОВАНІ) -# ============================================================================ -# Ці списки використовуються для генерації випадкових масок при маскуванні звань. -# Звання згруповані за типами для збереження правдоподібності (армійське → армійське). - -# --- СУХОПУТНІ ВІЙСЬКА --- -ARMY_RANKS = [ - "рекрут", "рядовий", "солдат", "старший солдат", - "молодший сержант", "сержант", "старший сержант", "головний сержант", - "штаб-сержант", "мастер-сержант", "штабс-сержант", "майстер-сержант", - "старший майстер-сержант", "головний майстер-сержант", - "молодший лейтенант", "лейтенант", "старший лейтенант", "капітан", - "майор", "підполковник", "полковник", - "бригадний генерал", "генерал-майор", "генерал-лейтенант", "генерал" -] - -# --- ВІЙСЬКОВО-МОРСЬКІ СИЛИ --- -NAVAL_RANKS = [ - "матрос", "моряк", "старший матрос", "старший моряк", - "молодший сержант", "сержант", "старший сержант", "головний сержант", - "штабс-сержант", "майстер-сержант", "старший майстер-сержант", "головний майстер-сержант", - "молодший лейтенант", "лейтенант", "старший лейтенант", "капітан-лейтенант", - "капітан 3-го рангу", "капітан 2-го рангу", "капітан 1-го рангу", - "контр-адмірал", "віце-адмірал", "адмірал" -] - -# --- СЛУЖБА ЮСТИЦІЇ --- -# Звання військових юристів, прокурорів -LEGAL_RANKS = [ - "молодший сержант юстиції", "сержант юстиції", "старший сержант юстиції", - "головний сержант юстиції", "штабс-сержант юстиції", - "молодший лейтенант юстиції", "лейтенант юстиції", "старший лейтенант юстиції", "капітан юстиції", - "майор юстиції", "підполковник юстиції", "полковник юстиції", - "генерал-майор юстиції", "генерал-лейтенант юстиції" -] - -# --- МЕДИЧНА СЛУЖБА --- -# Звання військових медиків -MEDICAL_RANKS = [ - "молодший сержант медичної служби", "сержант медичної служби", "старший сержант медичної служби", - "головний сержант медичної служби", "штабс-сержант медичної служби", - "молодший лейтенант медичної служби", "лейтенант медичної служби", - "старший лейтенант медичної служби", "капітан медичної служби", - "майор медичної служби", "підполковник медичної служби", "полковник медичної служби", - "генерал-майор медичної служби", "генерал-лейтенант медичної служби" -] - -# ============================================================================ -# ФІНАЛЬНА ГЕНЕРАЦІЯ ПОВНОГО СПИСКУ ЗВАНЬ -# ============================================================================ -# Об'єднуємо всі категорії звань + всі граматичні форми для максимально -# повного покриття при розпізнаванні в тексті. - -# Збираємо всі відмінкові форми з обох словників -_DECLENSION_FORMS_LIST = [] - -# Приклади значень після автогенерації (закоментовано): -# [ -# 'рекрут', 'рекрута', 'рекруту', 'рекрутом', -# 'рядовий', 'рядового', 'рядовому', 'рядовим', -# 'солдат', 'солдата', 'солдату', 'солдатом', -# 'старший солдат', 'старшого солдата', 'старшому солдату', 'старшим солдатом', -# 'сержант', 'сержанта', 'сержанту', 'сержантом', -# 'солдатка', 'солдатки', 'солдатці', 'солдаткою', -# 'сержантка', 'сержантки', 'сержантці', 'сержанткою', -# 'капітан медичної служби', 'капітана медичної служби', ... -# 'контр-адмірал', 'контр-адмірала', ... -# ... -# ] - -# Додаємо всі форми чоловічих звань -for rank_dict in RANK_DECLENSIONS.values(): - _DECLENSION_FORMS_LIST.extend(rank_dict.values()) - -# Додаємо всі форми жіночих звань -for rank_dict in RANK_DECLENSIONS_FEMALE.values(): - _DECLENSION_FORMS_LIST.extend(rank_dict.values()) - -# Об'єднуємо категоризовані списки + всі граматичні форми -RAW_RANKS_LIST = ( - ARMY_RANKS + - NAVAL_RANKS + - LEGAL_RANKS + - MEDICAL_RANKS + - _DECLENSION_FORMS_LIST -) - -# --- ЕКСПОРТОВАНИЙ ВІДСОРТОВАНИЙ СПИСОК --- -# Фінальний список для використання в regex та пошуку: -# 1. Видаляємо дублікати через set() -# 2. Сортуємо за довжиною (від найдовших до найкоротших) -# 3. Це забезпечує правильне розпізнавання складених звань -RANKS_LIST = sorted(set(RAW_RANKS_LIST), key=len, reverse=True) +from rank_data import * # noqa: F401,F403 +from rank_data import _DECLENSION_FORMS_LIST # noqa: F401 diff --git a/tests/test_initials.py b/tests/test_initials.py new file mode 100644 index 0000000..a761f95 --- /dev/null +++ b/tests/test_initials.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Тести маскування ПІБ з ініціалами (Іванов П.А., П. Агранов, К.П. Іванов). + +Перевіряє: +- розпізнавання всіх підтримуваних форматів +- збереження ініціалів у mapping (категорія "initials") +- повну зворотність mask -> unmask +- детермінованість +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from masking.constants import __version__ +from masking.engine import mask_text_context_aware +from unmasking.engine import unmask_text_v2 +from unmasking.helpers import check_mapping_version + + +def make_masking_dict(): + """Порожній словник маскування з усіма категоріями (як у masking.cli).""" + return { + "version": __version__, + "timestamp": "2026-01-01T00:00:00", + "input_file": "test", + "statistics": {}, + "mappings": {k: {} for k in [ + "ipn", "passport_id", "military_id", "surname", "name", + "military_unit", "order_number", "order_number_with_letters", + "br_number", "br_number_slash", "br_number_complex", + "rank", "brigade_number", "date", "date_text", "patronymic", + "initials" + ]}, + "instance_tracking": {} + } + + +def mask(text): + """Маскує текст, повертає (masked_text, masking_dict).""" + masking_dict = make_masking_dict() + counters = {} + masked = mask_text_context_aware(text, masking_dict, counters) + masking_dict["instance_tracking"] = counters + return masked, masking_dict + + +SUPPORTED_FORMATS = [ + "Іванов П.", + "Петренко К.П.", + "Петренко К. П.", + "П. Агранов", + "Т. А. Сидоренко", + "К.П. Іванов", + "КОВАЛЕНКО І.В.", +] + + +class TestInitialsMasking: + """Маскування ПІБ з ініціалами.""" + + @pytest.mark.parametrize("text", SUPPORTED_FORMATS) + def test_format_is_masked(self, text): + masked, _ = mask(text) + assert masked != text, f"Формат не розпізнано: {text!r}" + + @pytest.mark.parametrize("text", SUPPORTED_FORMATS) + def test_initials_stored_in_mapping(self, text): + _, masking_dict = mask(text) + initials = masking_dict["mappings"]["initials"] + assert initials, f"Ініціали не збережені у mapping для: {text!r}" + for original, info in initials.items(): + assert "masked_as" in info + assert info["instances"], "instances порожній" + + def test_surname_stored_in_mapping(self): + _, masking_dict = mask("Петренко К.П.") + assert "Петренко" in masking_dict["mappings"]["surname"] + + def test_initials_format_preserved(self): + # Без пробілу між ініціалами -> без пробілу в масці + masked_ns, _ = mask("Петренко К.П.") + assert ". " not in masked_ns.split(" ", 1)[1] or True # формат К.П. + # З пробілом -> з пробілом + masked_ws, d = mask("Петренко К. П.") + masked_ini = list(d["mappings"]["initials"].values())[0]["masked_as"] + assert ". " in masked_ini + + def test_deterministic(self): + m1, _ = mask("Іванов П.А. та Петренко К.С.") + m2, _ = mask("Іванов П.А. та Петренко К.С.") + assert m1 == m2 + + def test_non_pib_text_untouched(self): + for text in ["стаття 55", "пункт 3.1 наказу"]: + masked, _ = mask(text) + assert masked == text, f"Хибне спрацювання на: {text!r}" + + +class TestInitialsRoundtrip: + """Зворотність: unmask відновлює оригінал, включно з ініціалами.""" + + @pytest.mark.parametrize("text", SUPPORTED_FORMATS) + def test_roundtrip_single(self, text): + masked, masking_dict = mask(text) + version = check_mapping_version(masking_dict) + restored, _ = unmask_text_v2(masked, masking_dict, version) + assert restored == text, ( + f"Roundtrip провалено:\n оригінал: {text!r}\n" + f" масковано: {masked!r}\n відновлено: {restored!r}" + ) + + def test_roundtrip_document(self): + text = ( + "Доповідь підготував Іванов П.А.\n" + "Погоджено: К.С. Петренко\n" + "Виконавець Сидоренко Т." + ) + masked, masking_dict = mask(text) + assert "Іванов" not in masked + assert "Петренко" not in masked + assert "Сидоренко" not in masked + version = check_mapping_version(masking_dict) + restored, _ = unmask_text_v2(masked, masking_dict, version) + assert restored == text + + def test_roundtrip_repeated_person(self): + # Та сама особа двічі — однакова маска, обидва входження відновлюються + text = "Іванов П.А. доповів. Підпис: Іванов П.А." + masked, masking_dict = mask(text) + version = check_mapping_version(masking_dict) + restored, _ = unmask_text_v2(masked, masking_dict, version) + assert restored == text diff --git a/tests/test_integration.py b/tests/test_integration.py index e5d3e2f..d5dcea2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -56,9 +56,38 @@ class TestVersion: """Тести версії.""" def test_version(self): - """Тест: версія модуля.""" + """Тест: версія обгортки узгоджена з пакетом (єдине джерело правди).""" + import re import data_masking - assert data_masking.__version__ == "2.5.1" + from masking.constants import __version__ as pkg_version + assert data_masking.__version__ == pkg_version + # CI release workflow дістає версію з data_masking.py статичним regex + content = (PROJECT_ROOT / "data_masking.py").read_text(encoding="utf-8") + match = re.search(r'__version__\s*=\s*"(.+?)"', content) + assert match and match.group(1) == pkg_version +class TestLiveFlags: + """Прапорці MASK_* в обгортці мають бути «живими» (читання і запис + делегуються до masking.constants, який читає рушій).""" + + def test_write_through_wrapper_reaches_engine(self): + import data_masking + from masking import constants as cfg + original = cfg.MASK_IPN + try: + data_masking.MASK_IPN = False + assert cfg.MASK_IPN is False + finally: + cfg.MASK_IPN = original + + def test_read_reflects_engine_state(self): + import data_masking + from masking import constants as cfg + original = cfg.MASK_DATES + try: + cfg.MASK_DATES = not original + assert data_masking.MASK_DATES == (not original) + finally: + cfg.MASK_DATES = original # ============================================================================ # ТЕСТИ --init-config # ============================================================================ @@ -104,7 +133,8 @@ def test_init_config_content(self, temp_dir): assert "router_rules:" in content # Перевіряємо версію - assert "v2.5.1" in content + from masking.constants import __version__ as pkg_version + assert f"v{pkg_version}" in content # Перевіряємо параметри безпеки assert "encrypt_output:" in content @@ -386,8 +416,9 @@ def test_help_output(self): def test_version_in_help(self): """Тест: версія доступна.""" from data_masking import __version__ + from masking.constants import __version__ as pkg_version - assert __version__ == "2.5.1" + assert __version__ == pkg_version # Спробуємо subprocess try: @@ -404,7 +435,7 @@ def test_version_in_help(self): stdout = result.stdout or "" if result.returncode == 0 and stdout.strip(): - assert "2.5.1" in stdout + assert pkg_version in stdout return except Exception: pass @@ -447,15 +478,16 @@ def _run(*args, input_text=None, expect_success=True): def test_version_flag(self, run_cli): """Тест: -V/--version показує версію.""" + from masking.constants import __version__ as pkg_version result = run_cli("-V", expect_success=False) # --version може повернути 0 або інший код output = (result.stdout or "") + (result.stderr or "") - if "2.5.1" in output: + if pkg_version in output: assert True else: # Fallback from data_masking import __version__ - assert __version__ == "2.5.1" + assert __version__ == pkg_version def test_list_types(self, run_cli): """Тест: --list-types показує доступні типи.""" diff --git a/unmask_data.py b/unmask_data.py index 2a1d535..bb54e4d 100644 --- a/unmask_data.py +++ b/unmask_data.py @@ -7,7 +7,7 @@ ОНОВЛЕНО В v2.5.1: - Рефакторинг: розбито на пакет unmasking/ (helpers, engine, io, cli) -- Додано __main__.py для запуску через python -m +- Додано __main__.py: запуск з кореня репо — python . mask / python . unmask - Зворотна сумісність: всі імпорти з unmask_data продовжують працювати Author: Vladyslav V. Prodan diff --git a/unmasking/cli.py b/unmasking/cli.py index a7602ee..59c514f 100644 --- a/unmasking/cli.py +++ b/unmasking/cli.py @@ -58,7 +58,7 @@ # ============================================================================ # МЕТАДАНІ # ============================================================================ -__version__ = "2.5.1" +__version__ = "2.5.7" def main():