Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion data_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand Down
2 changes: 1 addition & 1 deletion datamasking/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
(і не тягнучи faker під час збірки).
"""

__version__ = "3.0.7"
__version__ = "3.0.8"
17 changes: 17 additions & 0 deletions datamasking/extras/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
# --------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand Down
36 changes: 32 additions & 4 deletions datamasking/masking/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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]:
Expand Down Expand Up @@ -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}, "
Expand Down
31 changes: 30 additions & 1 deletion datamasking/masking/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ============================================================================
# НАЛАШТУВАННЯ МАСКУВАННЯ
# ============================================================================
Expand Down
6 changes: 5 additions & 1 deletion datamasking/masking/mask_personal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
73 changes: 61 additions & 12 deletions datamasking/masking/surname.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,29 +153,75 @@ 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)

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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
"ІВАНОВ" → "ПЕТРЕНКО"
Expand Down
3 changes: 3 additions & 0 deletions docs/README_UK.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ Unmask правильно відновить обидва входження
"Капітану на пенсії" → "Майору на пенсії" (давальний зберігається!)
```

### Маски прізвищ (v3.0.8)
Маска прізвища зберігає **перші 3 символи** оригіналу (для коротких — не більше половини слова), решта синтетична; відмінкове закінчення зберігається: `Петренку → Петаченку`, `Ґудзь → Ґузій`. Налаштування: `masking_rules.surname_prefix_length` (0 = повністю синтетична) та словники faker через `system.faker_locale` (типово `uk_UA`; морфологія лишається українською).

### Збереження регістру
```
"ІВАНОВ" → "ПЕТРЕНКО"
Expand Down
Loading
Loading