shevchenko-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shevchenko/__init__.py ADDED
@@ -0,0 +1,178 @@
1
+ """shevchenko — Ukrainian declension of names, military ranks, and appointments.
2
+
3
+ A minimal, dependency-free Python port of shevchenko-js and its military
4
+ extension (https://github.com/tooleks/shevchenko-js, MIT).
5
+
6
+ >>> import shevchenko
7
+ >>> shevchenko.in_genitive(
8
+ ... gender="masculine",
9
+ ... given_name="Тарас",
10
+ ... patronymic_name="Григорович",
11
+ ... family_name="Шевченко",
12
+ ... military_rank="старший солдат",
13
+ ... )
14
+ {'given_name': 'Тараса', 'patronymic_name': 'Григоровича',
15
+ 'family_name': 'Шевченка', 'military_rank': 'старшого солдата'}
16
+
17
+ When ``gender`` is omitted it is auto-detected from the patronymic or given
18
+ name; an undetectable gender raises :class:`InputValidationError`.
19
+ """
20
+
21
+ import unicodedata
22
+
23
+ from . import _gender, _military, _names
24
+ from ._language import ( # noqa: F401 (re-exported constants)
25
+ ABLATIVE,
26
+ ACCUSATIVE,
27
+ CASES,
28
+ DATIVE,
29
+ FEMININE,
30
+ GENDERS,
31
+ GENITIVE,
32
+ LOCATIVE,
33
+ MASCULINE,
34
+ NOMINATIVE,
35
+ VOCATIVE,
36
+ )
37
+
38
+ __all__ = [
39
+ "InputValidationError",
40
+ "detect_gender",
41
+ "in_case",
42
+ "in_nominative",
43
+ "in_genitive",
44
+ "in_dative",
45
+ "in_accusative",
46
+ "in_ablative",
47
+ "in_locative",
48
+ "in_vocative",
49
+ "CASES",
50
+ "GENDERS",
51
+ "MASCULINE",
52
+ "FEMININE",
53
+ "NOMINATIVE",
54
+ "GENITIVE",
55
+ "DATIVE",
56
+ "ACCUSATIVE",
57
+ "ABLATIVE",
58
+ "LOCATIVE",
59
+ "VOCATIVE",
60
+ ]
61
+
62
+ __version__ = "0.1.0"
63
+
64
+
65
+ class InputValidationError(ValueError):
66
+ pass
67
+
68
+
69
+ _NAME_FIELDS = ("given_name", "patronymic_name", "family_name")
70
+ _FIELDS = _NAME_FIELDS + ("military_rank", "military_appointment")
71
+
72
+ _INFLECTORS = {
73
+ "given_name": _names.inflect_given_name,
74
+ "patronymic_name": _names.inflect_patronymic_name,
75
+ "family_name": _names.inflect_family_name,
76
+ }
77
+
78
+
79
+ def _normalize_input(source, kwargs, allowed_fields):
80
+ merged = {**(source or {}), **kwargs}
81
+ unknown = set(merged) - set(allowed_fields) - {"gender"}
82
+ if unknown:
83
+ raise InputValidationError(f"Unknown parameters: {sorted(unknown)}.")
84
+ normalized = {}
85
+ for field in allowed_fields:
86
+ value = merged.get(field)
87
+ if value is None:
88
+ continue
89
+ if not isinstance(value, str):
90
+ raise InputValidationError(f'The "{field}" parameter must be a string.')
91
+ normalized[field] = unicodedata.normalize("NFC", value)
92
+ if not normalized:
93
+ raise InputValidationError(
94
+ f"At least one of the following parameters must present: {allowed_fields}."
95
+ )
96
+ return merged.get("gender"), normalized
97
+
98
+
99
+ def _resolve_gender(gender, fields):
100
+ if gender in GENDERS:
101
+ return gender
102
+ if gender is not None:
103
+ raise InputValidationError(
104
+ f'The "gender" parameter must be one of {GENDERS}, got {gender!r}.'
105
+ )
106
+ detected = _gender.detect_gender(
107
+ given_name=fields.get("given_name"),
108
+ patronymic_name=fields.get("patronymic_name"),
109
+ )
110
+ if detected is None:
111
+ raise InputValidationError(
112
+ 'The grammatical gender could not be detected; pass gender="masculine"'
113
+ ' or gender="feminine" explicitly.'
114
+ )
115
+ return detected
116
+
117
+
118
+ def in_case(case, input=None, **kwargs):
119
+ """Inflect the given fields in ``case`` and return them as a dict.
120
+
121
+ Fields: given_name, patronymic_name, family_name, military_rank,
122
+ military_appointment. ``gender`` applies to the name fields and is
123
+ auto-detected when omitted.
124
+ """
125
+ if case not in CASES:
126
+ raise InputValidationError(f'The "case" parameter must be one of {CASES}.')
127
+ gender, fields = _normalize_input(input, kwargs, _FIELDS)
128
+ if any(field in fields for field in _NAME_FIELDS):
129
+ gender = _resolve_gender(gender, fields)
130
+
131
+ result = {}
132
+ for field, value in fields.items():
133
+ if field in _INFLECTORS:
134
+ result[field] = _INFLECTORS[field](value, gender, case)
135
+ elif field == "military_rank":
136
+ result[field] = _military.inflect_military(value, case, _military.RANK)
137
+ else:
138
+ result[field] = _military.inflect_military(
139
+ value, case, _military.APPOINTMENT
140
+ )
141
+ return result
142
+
143
+
144
+ def in_nominative(input=None, **kwargs):
145
+ return in_case(NOMINATIVE, input, **kwargs)
146
+
147
+
148
+ def in_genitive(input=None, **kwargs):
149
+ return in_case(GENITIVE, input, **kwargs)
150
+
151
+
152
+ def in_dative(input=None, **kwargs):
153
+ return in_case(DATIVE, input, **kwargs)
154
+
155
+
156
+ def in_accusative(input=None, **kwargs):
157
+ return in_case(ACCUSATIVE, input, **kwargs)
158
+
159
+
160
+ def in_ablative(input=None, **kwargs):
161
+ return in_case(ABLATIVE, input, **kwargs)
162
+
163
+
164
+ def in_locative(input=None, **kwargs):
165
+ return in_case(LOCATIVE, input, **kwargs)
166
+
167
+
168
+ def in_vocative(input=None, **kwargs):
169
+ return in_case(VOCATIVE, input, **kwargs)
170
+
171
+
172
+ def detect_gender(input=None, **kwargs):
173
+ """Return "masculine"/"feminine" from the patronymic or given name, else None."""
174
+ _, fields = _normalize_input(input, kwargs, _NAME_FIELDS)
175
+ return _gender.detect_gender(
176
+ given_name=fields.get("given_name"),
177
+ patronymic_name=fields.get("patronymic_name"),
178
+ )
@@ -0,0 +1,87 @@
1
+ """Family-name word-class classifier.
2
+
3
+ Pure-Python inference for the tiny upstream tfjs model
4
+ (Embedding 34x16 -> SimpleRNN 16, relu -> Dense 1, sigmoid), preceded by the
5
+ upstream cache of known-hard family names.
6
+ """
7
+
8
+ import json
9
+ import math
10
+ import struct
11
+ from functools import lru_cache
12
+
13
+ from ._data import read_bytes, read_text
14
+ from ._language import ADJECTIVE, ALPHABET_ENCODING, NOUN
15
+
16
+ _INPUT_SIZE = 20
17
+ _EMBED_DIM = 16
18
+ _UNITS = 16
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def _load_model():
23
+ raw = read_bytes("classifier-weights.bin")
24
+ values = struct.unpack(f"<{len(raw) // 4}f", raw)
25
+
26
+ def take(count):
27
+ nonlocal values
28
+ chunk, values = values[:count], values[count:]
29
+ return chunk
30
+
31
+ def matrix(rows, cols):
32
+ flat = take(rows * cols)
33
+ return [flat[row * cols : (row + 1) * cols] for row in range(rows)]
34
+
35
+ return {
36
+ "embeddings": matrix(34, _EMBED_DIM),
37
+ "kernel": matrix(_EMBED_DIM, _UNITS),
38
+ "recurrent": matrix(_UNITS, _UNITS),
39
+ "bias": take(_UNITS),
40
+ "dense_kernel": matrix(_UNITS, 1),
41
+ "dense_bias": take(1),
42
+ }
43
+
44
+
45
+ @lru_cache(maxsize=1)
46
+ def _load_known_classes():
47
+ return json.loads(read_text("family-name-classes.json"))
48
+
49
+
50
+ def _encode(word, size=_INPUT_SIZE):
51
+ letters = word[-size:].lower().rjust(size, "-")
52
+ return [ALPHABET_ENCODING.get(letter, 0) for letter in letters]
53
+
54
+
55
+ def _decode(probability):
56
+ return NOUN if probability >= 0.5 else ADJECTIVE
57
+
58
+
59
+ @lru_cache(maxsize=4096)
60
+ def classify_family_name(family_name):
61
+ """Return the word class ("noun" or "adjective") of a family name."""
62
+ known = _load_known_classes().get(family_name.lower())
63
+ if known is not None:
64
+ return known
65
+ return _predict(family_name)
66
+
67
+
68
+ def _predict(family_name):
69
+ model = _load_model()
70
+ state = [0.0] * _UNITS
71
+ for index in _encode(family_name):
72
+ embedded = model["embeddings"][index]
73
+ state = [
74
+ max(
75
+ 0.0,
76
+ sum(embedded[i] * model["kernel"][i][unit] for i in range(_EMBED_DIM))
77
+ + sum(state[i] * model["recurrent"][i][unit] for i in range(_UNITS))
78
+ + model["bias"][unit],
79
+ )
80
+ for unit in range(_UNITS)
81
+ ]
82
+ logit = (
83
+ sum(state[i] * model["dense_kernel"][i][0] for i in range(_UNITS))
84
+ + model["dense_bias"][0]
85
+ )
86
+ probability = 1.0 / (1.0 + math.exp(-logit))
87
+ return _decode(probability)
shevchenko/_data.py ADDED
@@ -0,0 +1,20 @@
1
+ """Package data access via plain file paths.
2
+
3
+ Deliberately avoids importlib.resources: its import chain (inspect, shutil,
4
+ tempfile, ...) costs ~25 ms, dwarfing this whole library. The trade-off is no
5
+ zipimport support, which this package does not need.
6
+ """
7
+
8
+ import os
9
+
10
+ _DIR = os.path.join(os.path.dirname(__file__), "data")
11
+
12
+
13
+ def read_text(name):
14
+ with open(os.path.join(_DIR, name), encoding="utf-8") as file:
15
+ return file.read()
16
+
17
+
18
+ def read_bytes(name):
19
+ with open(os.path.join(_DIR, name), "rb") as file:
20
+ return file.read()
shevchenko/_gender.py ADDED
@@ -0,0 +1,42 @@
1
+ """Grammatical gender detection from given-name / patronymic endings."""
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+
7
+ from ._data import read_text
8
+ from ._language import FEMININE, MASCULINE
9
+
10
+
11
+ @lru_cache(maxsize=None)
12
+ def _load_detector(file_name):
13
+ rules = json.loads(read_text(file_name))
14
+ return (
15
+ re.compile(rules["masculine"], re.IGNORECASE),
16
+ re.compile(rules["feminine"], re.IGNORECASE),
17
+ )
18
+
19
+
20
+ def _detect(word, file_name):
21
+ masculine_pattern, feminine_pattern = _load_detector(file_name)
22
+ masculine_match = masculine_pattern.search(word)
23
+ feminine_match = feminine_pattern.search(word)
24
+ if masculine_match and not feminine_match:
25
+ return MASCULINE
26
+ if feminine_match and not masculine_match:
27
+ return FEMININE
28
+ if masculine_match and feminine_match:
29
+ # Both endings match: the longer match wins, ties go to feminine.
30
+ if len(masculine_match.group(0)) > len(feminine_match.group(0)):
31
+ return MASCULINE
32
+ return FEMININE
33
+ return None
34
+
35
+
36
+ def detect_gender(given_name=None, patronymic_name=None):
37
+ """Return "masculine"/"feminine", or None when undetectable."""
38
+ if patronymic_name:
39
+ return _detect(patronymic_name.lower(), "patronymic-name-rules.json")
40
+ if given_name:
41
+ return _detect(given_name.lower(), "given-name-rules.json")
42
+ return None
@@ -0,0 +1,81 @@
1
+ """Regex-driven word declension engine (port of shevchenko-js word-declension)."""
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+
7
+ from ._data import read_text
8
+ from ._language import copy_letter_case
9
+
10
+ # Python's stdlib `re` rejects variable-width lookbehinds, which two upstream
11
+ # rules use. Both are only ever evaluated as boolean searches, so they can be
12
+ # rewritten into equivalent lookbehind-free / fixed-width forms.
13
+ _PATTERN_REWRITES = {
14
+ "((?<=[аеєиіїоуюя].*[аеєиіїоуюя].*л)|(?<=[аеєиіїоуюя].*[^л]))ок$": (
15
+ "([аеєиіїоуюя].*[аеєиіїоуюя].*л|[аеєиіїоуюя].*[^л])ок$"
16
+ ),
17
+ "(?<!ус|в|н)ин$": "(?<!ус)(?<!в)(?<!н)ин$",
18
+ }
19
+
20
+
21
+ @lru_cache(maxsize=None)
22
+ def _compile(pattern):
23
+ return re.compile(_PATTERN_REWRITES.get(pattern, pattern), re.IGNORECASE)
24
+
25
+
26
+ def _load_rules():
27
+ rules = json.loads(read_text("declension-rules.json"))
28
+ rules.sort(key=lambda rule: rule["priority"], reverse=True)
29
+ return rules
30
+
31
+
32
+ _RULES = _load_rules()
33
+
34
+
35
+ def _apply_rule(rule, word, case):
36
+ # Cases with no commands are omitted from the data entirely (word unchanged).
37
+ commands = rule["grammaticalCases"].get(case)
38
+ if not commands:
39
+ return word
40
+ commands_by_group = commands[0]
41
+ modify = _compile(rule["pattern"]["modify"])
42
+
43
+ def replace(match):
44
+ parts = []
45
+ for group_index in range(modify.groups):
46
+ value = match.group(group_index + 1) or ""
47
+ command = commands_by_group.get(str(group_index))
48
+ if command is not None:
49
+ if command["action"] == "replace":
50
+ value = command["value"]
51
+ else: # append
52
+ value += command["value"]
53
+ parts.append(value)
54
+ return "".join(parts)
55
+
56
+ return copy_letter_case(word, modify.sub(replace, word))
57
+
58
+
59
+ def inflect_word(word, case, gender, word_class=None, application_type=None, strict=False):
60
+ """Inflect a single word, applying the highest-priority matching rule.
61
+
62
+ ``application_type`` limits rules to a usage ("givenName", "patronymicName",
63
+ "familyName"); non-strict matching also admits rules with no usage listed.
64
+ Returns the word unchanged when no rule matches.
65
+ """
66
+ for rule in _RULES:
67
+ if gender not in rule["gender"]:
68
+ continue
69
+ if application_type is not None:
70
+ applications = rule["applicationType"]
71
+ if strict:
72
+ if application_type not in applications:
73
+ continue
74
+ elif applications and application_type not in applications:
75
+ continue
76
+ if word_class is not None and rule["wordClass"] != word_class:
77
+ continue
78
+ if not _compile(rule["pattern"]["find"]).search(word):
79
+ continue
80
+ return _apply_rule(rule, word, case)
81
+ return word
@@ -0,0 +1,53 @@
1
+ """Ukrainian language primitives shared by the declension modules."""
2
+
3
+ MASCULINE = "masculine"
4
+ FEMININE = "feminine"
5
+ GENDERS = (MASCULINE, FEMININE)
6
+
7
+ NOMINATIVE = "nominative"
8
+ GENITIVE = "genitive"
9
+ DATIVE = "dative"
10
+ ACCUSATIVE = "accusative"
11
+ ABLATIVE = "ablative"
12
+ LOCATIVE = "locative"
13
+ VOCATIVE = "vocative"
14
+ CASES = (NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE, ABLATIVE, LOCATIVE, VOCATIVE)
15
+
16
+ NOUN = "noun"
17
+ ADJECTIVE = "adjective"
18
+
19
+ # Letter -> 1-based position in the Ukrainian alphabet; 0 is reserved for
20
+ # padding/unknown characters in the classifier input encoding.
21
+ ALPHABET_ENCODING = {
22
+ letter: index
23
+ for index, letter in enumerate("абвгґдеєжзиіїйклмнопрстуфхцчшщьюя", start=1)
24
+ }
25
+
26
+ _VOWELS = set("аоуеиіяюєїАОУЕИІЯЮЄЇ")
27
+
28
+
29
+ def is_monosyllable(word):
30
+ return sum(letter in _VOWELS for letter in word) == 1
31
+
32
+
33
+ def copy_letter_case(template_word, target_word):
34
+ """Reapply the letter-case pattern of ``template_word`` onto ``target_word``.
35
+
36
+ When the target is longer than the template, the case of the template's
37
+ last letter is extended over the remainder.
38
+ """
39
+ result = []
40
+ for index, target_letter in enumerate(target_word):
41
+ if index < len(template_word):
42
+ template_letter = template_word[index]
43
+ elif template_word:
44
+ template_letter = template_word[-1]
45
+ else:
46
+ template_letter = ""
47
+ if template_letter == template_letter.lower():
48
+ result.append(target_letter.lower())
49
+ elif template_letter == template_letter.upper():
50
+ result.append(target_letter.upper())
51
+ else:
52
+ result.append(target_letter)
53
+ return "".join(result)
@@ -0,0 +1,64 @@
1
+ """Inflection of military ranks and appointments (port of shevchenko-ext-military)."""
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+
7
+ from ._data import read_text
8
+ from ._inflector import inflect_word
9
+
10
+ RANK = "militaryRank"
11
+ APPOINTMENT = "militaryAppointment"
12
+
13
+ _DELIMITERS_START = re.compile(r'^["\'()]+')
14
+ _DELIMITERS_END = re.compile(r'["\'()]+$')
15
+
16
+
17
+ @lru_cache(maxsize=None)
18
+ def _load_json(file_name):
19
+ return json.loads(read_text(file_name))
20
+
21
+
22
+ def _matches_all(patterns, word):
23
+ return all(re.search(pattern, word, re.IGNORECASE) for pattern in patterns)
24
+
25
+
26
+ def _inflect_single_word(word, case):
27
+ core = word
28
+ start_match = _DELIMITERS_START.search(core)
29
+ start = start_match.group(0) if start_match else ""
30
+ core = core[len(start):]
31
+ end_match = _DELIMITERS_END.search(core)
32
+ end = end_match.group(0) if end_match else ""
33
+ core = core[: len(core) - len(end)] if end else core
34
+
35
+ for rule in _load_json("military-word-classes.json"):
36
+ if _matches_all(rule["include"], core):
37
+ inflected = inflect_word(
38
+ core, case, rule["gender"], word_class=rule["wordClass"]
39
+ )
40
+ return start + inflected + end
41
+ return word
42
+
43
+
44
+ def inflect_military(text, case, use_case):
45
+ """Inflect a military rank or appointment phrase word by word.
46
+
47
+ Words not recognized by the classifier rules (proper names, abbreviations,
48
+ already-genitive qualifiers) are left unchanged.
49
+ """
50
+ hyphenation_rules = _load_json("military-hyphenation.json")
51
+ result = []
52
+ for word in text.split(" "):
53
+ hyphenated = any(
54
+ (rule.get("useCase") is None or rule["useCase"] == use_case)
55
+ and _matches_all(rule["include"], word)
56
+ for rule in hyphenation_rules
57
+ )
58
+ if hyphenated:
59
+ result.append(
60
+ "-".join(_inflect_single_word(part, case) for part in word.split("-"))
61
+ )
62
+ else:
63
+ result.append(_inflect_single_word(word, case))
64
+ return " ".join(result)
shevchenko/_names.py ADDED
@@ -0,0 +1,58 @@
1
+ """Inflection of given names, patronymics, and family names."""
2
+
3
+ import re
4
+
5
+ from ._classifier import classify_family_name
6
+ from ._inflector import inflect_word
7
+ from ._language import FEMININE, MASCULINE, is_monosyllable
8
+
9
+ # Endings for which a family name may be either a noun or an adjective, so the
10
+ # classifier must disambiguate before rules are applied.
11
+ _UNCERTAIN_FEMININE = re.compile("[ая]$", re.IGNORECASE)
12
+ _UNCERTAIN_MASCULINE = re.compile("(ой|ий|ій|их)$", re.IGNORECASE)
13
+
14
+
15
+ def _inflect_parts(name, inflect_part):
16
+ parts = name.split("-")
17
+ last_index = len(parts) - 1
18
+ return "-".join(
19
+ inflect_part(part, index == last_index) for index, part in enumerate(parts)
20
+ )
21
+
22
+
23
+ def inflect_given_name(name, gender, case):
24
+ return _inflect_parts(
25
+ name,
26
+ lambda part, _: inflect_word(part, case, gender, application_type="givenName"),
27
+ )
28
+
29
+
30
+ def inflect_patronymic_name(name, gender, case):
31
+ return _inflect_parts(
32
+ name,
33
+ lambda part, _: inflect_word(
34
+ part, case, gender, application_type="patronymicName", strict=True
35
+ ),
36
+ )
37
+
38
+
39
+ def _is_uncertain_family_name(part, gender):
40
+ if gender == FEMININE:
41
+ return bool(_UNCERTAIN_FEMININE.search(part))
42
+ if gender == MASCULINE:
43
+ return bool(_UNCERTAIN_MASCULINE.search(part))
44
+ return False
45
+
46
+
47
+ def inflect_family_name(name, gender, case):
48
+ def inflect_part(part, is_last):
49
+ if not is_last and is_monosyllable(part):
50
+ return part
51
+ word_class = None
52
+ if _is_uncertain_family_name(part, gender):
53
+ word_class = classify_family_name(part)
54
+ return inflect_word(
55
+ part, case, gender, word_class=word_class, application_type="familyName"
56
+ )
57
+
58
+ return _inflect_parts(name, inflect_part)
Binary file