pluralio 1.0.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.
pluralio/__init__.py ADDED
@@ -0,0 +1,360 @@
1
+ """pluralio — Pluralization and singularization for Python.
2
+
3
+ Public API:
4
+ - :func:`pluralize`: Convert a word to plural form.
5
+ - :func:`singularize`: Convert a word to singular form.
6
+ - :func:`supported_languages`: List registered language codes.
7
+ - :func:`add_irregular`: Add an irregular word pair (both directions).
8
+ - :func:`add_plural`: Add only the singular→plural direction.
9
+ - :func:`add_singular`: Add only the plural→singular direction.
10
+ - :func:`add_uncountable`: Mark a word as invariable.
11
+ - :func:`add_plural_rule`: Insert a pluralization regex rule.
12
+ - :func:`add_singular_rule`: Insert a singularization regex rule.
13
+ - :func:`register_language`: Register a new language with its rules.
14
+ - :func:`is_plural`: Check if a word is in plural form.
15
+ - :func:`is_singular`: Check if a word is in singular form.
16
+ - :class:`LanguageRules`: Dataclass for custom language rules.
17
+ - :func:`register`: Low-level registry registration.
18
+ - :func:`get_rules`: Low-level registry lookup.
19
+
20
+ Supported languages (built-in):
21
+ - English (``en``)
22
+ - Spanish (``es``)
23
+
24
+ Example:
25
+ >>> import pluralio
26
+ >>> pluralio.pluralize("cat")
27
+ 'cats'
28
+ >>> pluralio.pluralize("gato", lang="es")
29
+ 'gatos'
30
+ >>> pluralio.singularize("cities")
31
+ 'city'
32
+ """
33
+
34
+ import re
35
+ from importlib.metadata import PackageNotFoundError, version
36
+
37
+ from pluralio import rules_en, rules_es # noqa: F401 — triggers registration
38
+ from pluralio.core import pluralize, singularize
39
+ from pluralio.registry import (
40
+ LanguageRules,
41
+ get_rules,
42
+ register,
43
+ supported_languages,
44
+ )
45
+
46
+ try:
47
+ __version__ = version("pluralio")
48
+ except PackageNotFoundError:
49
+ __version__ = "0.0.0"
50
+ """Package version string (read from installed package metadata)."""
51
+
52
+ __all__ = [
53
+ "pluralize",
54
+ "singularize",
55
+ "supported_languages",
56
+ "LanguageRules",
57
+ "register",
58
+ "get_rules",
59
+ "add_irregular",
60
+ "add_plural",
61
+ "add_singular",
62
+ "add_uncountable",
63
+ "add_plural_rule",
64
+ "add_singular_rule",
65
+ "register_language",
66
+ "is_plural",
67
+ "is_singular",
68
+ "__version__",
69
+ ]
70
+
71
+
72
+ def add_irregular(singular: str, plural: str, lang: str = "en") -> None:
73
+ """Add an irregular word pair for both pluralize and singularize.
74
+
75
+ This registers the word in both ``irregular_plurals``
76
+ (singular → plural) and ``irregular_singles`` (plural → singular),
77
+ so that both :func:`pluralize` and :func:`singularize` use the
78
+ mapping. The entries are stored in lowercase for case-insensitive
79
+ matching.
80
+
81
+ Args:
82
+ singular: The singular form of the word.
83
+ plural: The plural form of the word.
84
+ lang: Language code to register the pair in. Defaults to ``"en"``.
85
+
86
+ Example:
87
+ >>> add_irregular("person", "people")
88
+ >>> pluralize("person")
89
+ 'people'
90
+ >>> singularize("people")
91
+ 'person'
92
+ """
93
+ rules = get_rules(lang)
94
+ rules.irregular_plurals[singular.lower()] = plural.lower()
95
+ rules.irregular_singles[plural.lower()] = singular.lower()
96
+
97
+
98
+ def add_plural(singular: str, plural: str, lang: str = "en") -> None:
99
+ """Add only the singular→plural direction for an irregular word.
100
+
101
+ Unlike :func:`add_irregular`, this only affects :func:`pluralize`.
102
+ :func:`singularize` will not know about the inverse mapping.
103
+
104
+ This is useful when the plural→singular direction follows a
105
+ different rule or needs a separate entry (e.g. Spanish accent
106
+ restoration).
107
+
108
+ Args:
109
+ singular: The singular form of the word.
110
+ plural: The plural form of the word.
111
+ lang: Language code. Defaults to ``"en"``.
112
+
113
+ Example:
114
+ >>> add_plural("joven", "jóvenes", lang="es")
115
+ >>> pluralize("joven", lang="es")
116
+ 'jóvenes'
117
+ """
118
+ rules = get_rules(lang)
119
+ rules.irregular_plurals[singular.lower()] = plural.lower()
120
+
121
+
122
+ def add_singular(plural: str, singular: str, lang: str = "en") -> None:
123
+ """Add only the plural→singular direction for an irregular word.
124
+
125
+ Unlike :func:`add_irregular`, this only affects :func:`singularize`.
126
+ :func:`pluralize` will not know about the forward mapping.
127
+
128
+ This is useful for Spanish accent restoration where the plural
129
+ form has a predictable ending but the singular requires an accent
130
+ that the regex rules cannot infer.
131
+
132
+ Args:
133
+ plural: The plural form of the word.
134
+ singular: The singular form of the word.
135
+ lang: Language code. Defaults to ``"en"``.
136
+
137
+ Example:
138
+ >>> add_singular("alemanes", "alemán", lang="es")
139
+ >>> singularize("alemanes", lang="es")
140
+ 'alemán'
141
+ """
142
+ rules = get_rules(lang)
143
+ rules.irregular_singles[plural.lower()] = singular.lower()
144
+
145
+
146
+ def add_uncountable(word: str, lang: str = "en") -> None:
147
+ """Mark a word as uncountable (invariable).
148
+
149
+ Uncountable words are returned unchanged by both :func:`pluralize`
150
+ and :func:`singularize`. They are checked **before** irregulars
151
+ and regex rules, so they take highest priority.
152
+
153
+ Args:
154
+ word: The word to mark as uncountable.
155
+ lang: Language code. Defaults to ``"en"``.
156
+
157
+ Example:
158
+ >>> add_uncountable("data")
159
+ >>> pluralize("data")
160
+ 'data'
161
+ >>> singularize("data")
162
+ 'data'
163
+ """
164
+ rules = get_rules(lang)
165
+ rules.uncountable.add(word.lower())
166
+
167
+
168
+ def add_plural_rule(pattern: str, replacement: str, lang: str = "en") -> None:
169
+ """Insert a pluralization regex rule at the top (highest priority).
170
+
171
+ Rules are checked in order, and the first match wins. By inserting
172
+ at position 0, the new rule takes priority over all existing rules.
173
+
174
+ Args:
175
+ pattern: Regular expression pattern to match (string, will be
176
+ compiled internally).
177
+ replacement: Replacement string for ``re.sub``.
178
+ lang: Language code. Defaults to ``"en"``.
179
+
180
+ Example:
181
+ >>> add_plural_rule(r"us$", "i")
182
+ >>> pluralize("cactus")
183
+ 'cacti'
184
+ """
185
+ rules = get_rules(lang)
186
+ rules.plural_rules.insert(0, (re.compile(pattern), replacement))
187
+
188
+
189
+ def add_singular_rule(pattern: str, replacement: str, lang: str = "en") -> None:
190
+ """Insert a singularization regex rule at the top (highest priority).
191
+
192
+ Rules are checked in order, and the first match wins. By inserting
193
+ at position 0, the new rule takes priority over all existing rules.
194
+
195
+ Args:
196
+ pattern: Regular expression pattern to match (string, will be
197
+ compiled internally).
198
+ replacement: Replacement string for ``re.sub``.
199
+ lang: Language code. Defaults to ``"en"``.
200
+
201
+ Example:
202
+ >>> add_singular_rule(r"i$", "us")
203
+ >>> singularize("cacti")
204
+ 'cactus'
205
+ """
206
+ rules = get_rules(lang)
207
+ rules.singular_rules.insert(0, (re.compile(pattern), replacement))
208
+
209
+
210
+ def register_language(
211
+ lang: str,
212
+ *,
213
+ plural_rules: list[tuple[str, str]] | None = None,
214
+ singular_rules: list[tuple[str, str]] | None = None,
215
+ irregular_plurals: dict[str, str] | None = None,
216
+ irregular_singles: dict[str, str] | None = None,
217
+ uncountable: set[str] | None = None,
218
+ ) -> None:
219
+ """Register a new language with its pluralization rules.
220
+
221
+ This is the high-level API for adding a completely new language.
222
+ It compiles the regex patterns, normalizes keys to lowercase,
223
+ auto-generates inverse irregular mappings, and registers the
224
+ resulting :class:`LanguageRules` in the global registry.
225
+
226
+ If ``plural_rules`` is omitted, a default rule of ``(r"$", "s")``
227
+ is used (append ``s``).
228
+ If ``singular_rules`` is omitted, a default rule of ``(r"s$", "")``
229
+ is used (strip trailing ``s``).
230
+
231
+ For every entry in ``irregular_plurals``, the inverse is
232
+ automatically added to ``irregular_singles`` unless an explicit
233
+ entry already exists.
234
+
235
+ Args:
236
+ lang: ISO 639-1 language code for the new language.
237
+ plural_rules: List of ``(pattern, replacement)`` string tuples
238
+ for pluralization. Checked in order; first match wins.
239
+ singular_rules: List of ``(pattern, replacement)`` string tuples
240
+ for singularization. Checked in order; first match wins.
241
+ irregular_plurals: Mapping of singular → plural for irregular
242
+ words. Keys and values are normalized to lowercase.
243
+ irregular_singles: Mapping of plural → singular for irregular
244
+ words. Keys and values are normalized to lowercase.
245
+ Auto-populated from ``irregular_plurals`` where possible.
246
+ uncountable: Set of invariable words. Normalized to lowercase.
247
+
248
+ Example:
249
+ >>> register_language(
250
+ ... "fr",
251
+ ... plural_rules=[(r"$", "s")],
252
+ ... singular_rules=[(r"s$", "")],
253
+ ... irregular_plurals={"cheval": "chevaux"},
254
+ ... uncountable={"information"},
255
+ ... )
256
+ >>> pluralize("chat", lang="fr")
257
+ 'chats'
258
+ >>> pluralize("cheval", lang="fr")
259
+ 'chevaux'
260
+ >>> singularize("chevaux", lang="fr")
261
+ 'cheval'
262
+ >>> pluralize("information", lang="fr")
263
+ 'information'
264
+ """
265
+ plurals = {k.lower(): v.lower() for k, v in (irregular_plurals or {}).items()}
266
+ singles = {k.lower(): v.lower() for k, v in (irregular_singles or {}).items()}
267
+ for singular, plural in plurals.items():
268
+ singles.setdefault(plural, singular)
269
+
270
+ compiled_plural = [(re.compile(p), r) for p, r in (plural_rules or [(r"$", "s")])]
271
+ compiled_singular = [(re.compile(p), r) for p, r in (singular_rules or [(r"s$", "")])]
272
+
273
+ register(LanguageRules(
274
+ code=lang,
275
+ irregular_plurals=plurals,
276
+ irregular_singles=singles,
277
+ plural_rules=compiled_plural,
278
+ singular_rules=compiled_singular,
279
+ uncountable={w.lower() for w in (uncountable or set())},
280
+ ))
281
+
282
+
283
+ def is_plural(word: str, lang: str = "en") -> bool:
284
+ """Check if a word is in its plural form.
285
+
286
+ A word is considered plural if singularizing it produces a
287
+ different word. Uncountable words are neither plural nor
288
+ singular — they return ``False`` for both checks.
289
+
290
+ Args:
291
+ word: The word to check.
292
+ lang: ISO 639-1 language code. Defaults to ``"en"``.
293
+
294
+ Returns:
295
+ ``True`` if the word is in plural form, ``False`` otherwise.
296
+
297
+ Raises:
298
+ TypeError: If ``word`` is not a string.
299
+ ValueError: If ``lang`` is not a registered language.
300
+
301
+ Example:
302
+ >>> is_plural("cats")
303
+ True
304
+ >>> is_plural("cat")
305
+ False
306
+ >>> is_plural("sheep")
307
+ False
308
+ >>> is_plural("gatos", lang="es")
309
+ True
310
+ """
311
+ if not isinstance(word, str):
312
+ raise TypeError(f"word must be str, got {type(word).__name__}")
313
+ stripped = word.strip()
314
+ if not stripped:
315
+ return False
316
+ rules = get_rules(lang)
317
+ lower = stripped.lower()
318
+ if lower in rules.uncountable:
319
+ return False
320
+ return singularize(stripped, lang=lang).lower() != lower
321
+
322
+
323
+ def is_singular(word: str, lang: str = "en") -> bool:
324
+ """Check if a word is in its singular form.
325
+
326
+ A word is considered singular if it is not plural and not
327
+ uncountable. Uncountable words are neither plural nor
328
+ singular — they return ``False`` for both checks.
329
+
330
+ Args:
331
+ word: The word to check.
332
+ lang: ISO 639-1 language code. Defaults to ``"en"``.
333
+
334
+ Returns:
335
+ ``True`` if the word is in singular form, ``False`` otherwise.
336
+
337
+ Raises:
338
+ TypeError: If ``word`` is not a string.
339
+ ValueError: If ``lang`` is not a registered language.
340
+
341
+ Example:
342
+ >>> is_singular("cat")
343
+ True
344
+ >>> is_singular("cats")
345
+ False
346
+ >>> is_singular("sheep")
347
+ False
348
+ >>> is_singular("gato", lang="es")
349
+ True
350
+ """
351
+ if not isinstance(word, str):
352
+ raise TypeError(f"word must be str, got {type(word).__name__}")
353
+ stripped = word.strip()
354
+ if not stripped:
355
+ return False
356
+ rules = get_rules(lang)
357
+ lower = stripped.lower()
358
+ if lower in rules.uncountable:
359
+ return False
360
+ return singularize(stripped, lang=lang).lower() == lower
pluralio/core.py ADDED
@@ -0,0 +1,223 @@
1
+ """Core pluralization and singularization engine.
2
+
3
+ This module implements the main algorithm that transforms a word
4
+ between its singular and plural forms. The algorithm follows a
5
+ three-step priority chain for each word:
6
+
7
+ 1. **Uncountable check** — if the word is in the language's
8
+ ``uncountable`` set, it is returned unchanged.
9
+ 2. **Irregular lookup** — if the word appears in the irregular
10
+ mapping (``irregular_plurals`` or ``irregular_singles``),
11
+ the mapped form is returned.
12
+ 3. **Regex rules** — the first matching regex rule in the
13
+ ordered list is applied. If no rule matches, the word is
14
+ returned unchanged.
15
+
16
+ Additional features handled here:
17
+
18
+ - **Case preservation**: the output mirrors the casing of the
19
+ input (title case, all caps, lowercase).
20
+ - **Hyphenated words**: only the first segment is transformed
21
+ (e.g. ``"mother-in-law"`` → ``"mothers-in-law"``).
22
+ - **Count-aware pluralization**: when ``count == 1`` the word
23
+ is returned in singular form regardless of other rules.
24
+ - **Input validation**: non-string inputs raise ``TypeError``;
25
+ empty or whitespace-only strings are returned as-is.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+
32
+ from pluralio.registry import LanguageRules, get_rules
33
+
34
+
35
+ def _match_case(source: str, target: str) -> str:
36
+ """Apply the casing pattern of ``source`` to ``target``.
37
+
38
+ Three modes are supported:
39
+ - **All caps**: if ``source`` is entirely uppercase, ``target``
40
+ is returned in uppercase.
41
+ - **Title case**: if only the first character of ``source`` is
42
+ uppercase, ``target`` is capitalized on its first character.
43
+ - **Lowercase**: otherwise ``target`` is returned unchanged.
44
+
45
+ Args:
46
+ source: The original word whose casing pattern to replicate.
47
+ target: The transformed word to re-case.
48
+
49
+ Returns:
50
+ ``target`` with the casing of ``source`` applied.
51
+
52
+ Example:
53
+ >>> _match_case("Library", "libraries")
54
+ 'Libraries'
55
+ >>> _match_case("LIBRARY", "libraries")
56
+ 'LIBRARIES'
57
+ >>> _match_case("library", "libraries")
58
+ 'libraries'
59
+ """
60
+ if source.isupper():
61
+ return target.upper()
62
+ if source[0].isupper():
63
+ return target[0].upper() + target[1:]
64
+ return target
65
+
66
+
67
+ def _apply_rules(
68
+ word: str,
69
+ rules: LanguageRules,
70
+ irregulars: dict[str, str],
71
+ rule_list: list[tuple[re.Pattern[str], str]],
72
+ ) -> str:
73
+ """Apply the three-step transformation chain to a single word.
74
+
75
+ This is the shared inner logic used by both :func:`pluralize` and
76
+ :func:`singularize`. It checks uncountables first, then irregulars,
77
+ then regex rules, preserving the input casing in the result.
78
+
79
+ Args:
80
+ word: The word to transform (already stripped, no hyphens).
81
+ rules: The :class:`LanguageRules` for the target language.
82
+ irregulars: Either ``irregular_plurals`` or ``irregular_singles``
83
+ from the language rules, depending on direction.
84
+ rule_list: Either ``plural_rules`` or ``singular_rules`` from
85
+ the language rules, depending on direction.
86
+
87
+ Returns:
88
+ The transformed word with original casing preserved.
89
+ """
90
+ lower = word.lower()
91
+ if lower in rules.uncountable:
92
+ return word
93
+ if lower in irregulars:
94
+ return _match_case(word, irregulars[lower])
95
+ for pattern, replacement in rule_list:
96
+ if pattern.search(lower):
97
+ return _match_case(word, pattern.sub(replacement, lower, count=1))
98
+ return word
99
+
100
+
101
+ def _pluralize_hyphenated(word: str, lang: str, count: int | None) -> str:
102
+ """Pluralize only the first segment of a hyphenated word.
103
+
104
+ Args:
105
+ word: The hyphenated word (e.g. ``"mother-in-law"``).
106
+ lang: Language code to pass through.
107
+ count: Count value to pass through for count-aware behavior.
108
+
109
+ Returns:
110
+ The word with its first segment pluralized
111
+ (e.g. ``"mothers-in-law"``).
112
+ """
113
+ parts = word.split("-")
114
+ parts[0] = pluralize(parts[0], lang=lang, count=count)
115
+ return "-".join(parts)
116
+
117
+
118
+ def _singularize_hyphenated(word: str, lang: str) -> str:
119
+ """Singularize only the first segment of a hyphenated word.
120
+
121
+ Args:
122
+ word: The hyphenated word (e.g. ``"mothers-in-law"``).
123
+ lang: Language code to pass through.
124
+
125
+ Returns:
126
+ The word with its first segment singularized
127
+ (e.g. ``"mother-in-law"``).
128
+ """
129
+ parts = word.split("-")
130
+ parts[0] = singularize(parts[0], lang=lang)
131
+ return "-".join(parts)
132
+
133
+
134
+ def pluralize(word: str, lang: str = "en", count: int | None = None) -> str:
135
+ """Convert a word to its plural form.
136
+
137
+ The function follows the three-step priority chain
138
+ (uncountable → irregular → regex) and preserves the input
139
+ casing in the output.
140
+
141
+ Args:
142
+ word: The singular word to pluralize.
143
+ lang: ISO 639-1 language code. Defaults to ``"en"``.
144
+ count: Optional integer count. When ``count == 1`` the word
145
+ is returned unchanged (singular form). Any other value
146
+ (including ``0``, negative numbers, and ``None``) produces
147
+ the plural form.
148
+
149
+ Returns:
150
+ The plural form of ``word``, with original casing preserved.
151
+
152
+ Raises:
153
+ TypeError: If ``word`` is not a string.
154
+ ValueError: If ``lang`` is not a registered language.
155
+
156
+ Example:
157
+ >>> pluralize("cat")
158
+ 'cats'
159
+ >>> pluralize("box")
160
+ 'boxes'
161
+ >>> pluralize("child")
162
+ 'children'
163
+ >>> pluralize("gato", lang="es")
164
+ 'gatos'
165
+ >>> pluralize("item", count=1)
166
+ 'item'
167
+ >>> pluralize("Library")
168
+ 'Libraries'
169
+ >>> pluralize("mother-in-law")
170
+ 'mothers-in-law'
171
+ """
172
+ if not isinstance(word, str):
173
+ raise TypeError(f"word must be str, got {type(word).__name__}")
174
+ stripped = word.strip()
175
+ if not stripped:
176
+ return word
177
+ if count is not None and count == 1:
178
+ return word
179
+ rules = get_rules(lang)
180
+ if "-" in stripped:
181
+ return _pluralize_hyphenated(stripped, lang, count)
182
+ return _apply_rules(stripped, rules, rules.irregular_plurals, rules.plural_rules)
183
+
184
+
185
+ def singularize(word: str, lang: str = "en") -> str:
186
+ """Convert a word to its singular form.
187
+
188
+ The function follows the three-step priority chain
189
+ (uncountable → irregular → regex) and preserves the input
190
+ casing in the output.
191
+
192
+ Args:
193
+ word: The plural word to singularize.
194
+ lang: ISO 639-1 language code. Defaults to ``"en"``.
195
+
196
+ Returns:
197
+ The singular form of ``word``, with original casing preserved.
198
+
199
+ Raises:
200
+ TypeError: If ``word`` is not a string.
201
+ ValueError: If ``lang`` is not a registered language.
202
+
203
+ Example:
204
+ >>> singularize("cities")
205
+ 'city'
206
+ >>> singularize("mice")
207
+ 'mouse'
208
+ >>> singularize("lápices", lang="es")
209
+ 'lápiz'
210
+ >>> singularize("Libraries")
211
+ 'Library'
212
+ >>> singularize("mothers-in-law")
213
+ 'mother-in-law'
214
+ """
215
+ if not isinstance(word, str):
216
+ raise TypeError(f"word must be str, got {type(word).__name__}")
217
+ stripped = word.strip()
218
+ if not stripped:
219
+ return word
220
+ rules = get_rules(lang)
221
+ if "-" in stripped:
222
+ return _singularize_hyphenated(stripped, lang)
223
+ return _apply_rules(stripped, rules, rules.irregular_singles, rules.singular_rules)
pluralio/py.typed ADDED
File without changes