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 +178 -0
- shevchenko/_classifier.py +87 -0
- shevchenko/_data.py +20 -0
- shevchenko/_gender.py +42 -0
- shevchenko/_inflector.py +81 -0
- shevchenko/_language.py +53 -0
- shevchenko/_military.py +64 -0
- shevchenko/_names.py +58 -0
- shevchenko/data/classifier-weights.bin +0 -0
- shevchenko/data/declension-rules.json +1 -0
- shevchenko/data/family-name-classes.json +1 -0
- shevchenko/data/given-name-rules.json +1 -0
- shevchenko/data/military-hyphenation.json +1 -0
- shevchenko/data/military-word-classes.json +1 -0
- shevchenko/data/patronymic-name-rules.json +1 -0
- shevchenko_py-0.1.0.dist-info/METADATA +266 -0
- shevchenko_py-0.1.0.dist-info/RECORD +20 -0
- shevchenko_py-0.1.0.dist-info/WHEEL +5 -0
- shevchenko_py-0.1.0.dist-info/licenses/LICENSE +22 -0
- shevchenko_py-0.1.0.dist-info/top_level.txt +1 -0
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
|
shevchenko/_inflector.py
ADDED
|
@@ -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
|
shevchenko/_language.py
ADDED
|
@@ -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)
|
shevchenko/_military.py
ADDED
|
@@ -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
|