lexeme-type 1.0.0__tar.gz
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.
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lexeme-type
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: TODO
|
|
5
|
+
Author: Gwyn Uttmark
|
|
6
|
+
Author-email: biosafetylevel5@gmail.com
|
|
7
|
+
Requires-Python: >=3.11,<3.14
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Provides-Extra: doc
|
|
18
|
+
Provides-Extra: lint
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: commitizen ; extra == "doc"
|
|
21
|
+
Requires-Dist: doc8 ; extra == "doc" or extra == "lint"
|
|
22
|
+
Requires-Dist: mypy ; extra == "lint"
|
|
23
|
+
Requires-Dist: myst-parser ; extra == "doc"
|
|
24
|
+
Requires-Dist: pinkrst ; extra == "doc" or extra == "lint"
|
|
25
|
+
Requires-Dist: prettier ; extra == "lint"
|
|
26
|
+
Requires-Dist: pydantic ; extra == "test"
|
|
27
|
+
Requires-Dist: pytest ; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov ; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-xdist ; extra == "test"
|
|
30
|
+
Requires-Dist: ruff ; extra == "lint"
|
|
31
|
+
Requires-Dist: sphinx ; extra == "doc"
|
|
32
|
+
Requires-Dist: sphinx-argparse ; extra == "doc"
|
|
33
|
+
Requires-Dist: sphinx-autodoc-typehints ; extra == "doc"
|
|
34
|
+
Requires-Dist: sphinx-copybutton ; extra == "doc"
|
|
35
|
+
Requires-Dist: sphinx-rtd-theme ; extra == "doc"
|
|
36
|
+
Requires-Dist: sphinxcontrib-mermaid ; extra == "doc"
|
|
37
|
+
Project-URL: Documentation, https://biosafetylvl5.github.io/lexeme-type/
|
|
38
|
+
Project-URL: Homepage, https://github.com/biosafetylvl5/lexeme-type
|
|
39
|
+
Project-URL: Issues, https://github.com/biosafetylvl5/lexeme-type/issues
|
|
40
|
+
Project-URL: Repository, https://github.com/biosafetylvl5/lexeme-type
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# lexeme-type
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+

|
|
47
|
+

|
|
48
|
+

|
|
49
|
+

|
|
50
|
+

|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
[](https://github.com/biosafetylvl5/lexeme-type/stargazers)
|
|
54
|
+
|
|
55
|
+
# Lexeme type
|
|
56
|
+
|
|
57
|
+
Lightweight no-dependency helper for treating singular and plural spellings as equivalent.
|
|
58
|
+
|
|
59
|
+
A drop‑in str subclass that normalizes English nouns so that their
|
|
60
|
+
singular and plural forms compare as equal, hash to the same key, and work
|
|
61
|
+
inside Pydantic v2 models. Ignores capitalization.
|
|
62
|
+
|
|
63
|
+
Examples
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
>>> from partial_lexeme.lexeme import Lexeme
|
|
67
|
+
>>> Lexeme("reader") == "readers" == Lexeme("readers")
|
|
68
|
+
True
|
|
69
|
+
>>> {Lexeme("analyses"): 1} == {"analysis": 1}
|
|
70
|
+
True
|
|
71
|
+
|
|
72
|
+
>>> from pydantic import BaseModel
|
|
73
|
+
>>> class _Plugin(BaseModel):
|
|
74
|
+
>>> kind: Lexeme
|
|
75
|
+
>>> interface: Lexeme
|
|
76
|
+
>>> model = _Plugin(kind="reader", interface="readers")
|
|
77
|
+
>>> assert model.kind == model.interface == "reader"
|
|
78
|
+
```
|
|
79
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# lexeme-type
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
[](https://github.com/biosafetylvl5/lexeme-type/stargazers)
|
|
12
|
+
|
|
13
|
+
# Lexeme type
|
|
14
|
+
|
|
15
|
+
Lightweight no-dependency helper for treating singular and plural spellings as equivalent.
|
|
16
|
+
|
|
17
|
+
A drop‑in str subclass that normalizes English nouns so that their
|
|
18
|
+
singular and plural forms compare as equal, hash to the same key, and work
|
|
19
|
+
inside Pydantic v2 models. Ignores capitalization.
|
|
20
|
+
|
|
21
|
+
Examples
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
>>> from partial_lexeme.lexeme import Lexeme
|
|
25
|
+
>>> Lexeme("reader") == "readers" == Lexeme("readers")
|
|
26
|
+
True
|
|
27
|
+
>>> {Lexeme("analyses"): 1} == {"analysis": 1}
|
|
28
|
+
True
|
|
29
|
+
|
|
30
|
+
>>> from pydantic import BaseModel
|
|
31
|
+
>>> class _Plugin(BaseModel):
|
|
32
|
+
>>> kind: Lexeme
|
|
33
|
+
>>> interface: Lexeme
|
|
34
|
+
>>> model = _Plugin(kind="reader", interface="readers")
|
|
35
|
+
>>> assert model.kind == model.interface == "reader"
|
|
36
|
+
```
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "lexeme-type"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "TODO"
|
|
5
|
+
authors = ["Gwyn Uttmark <biosafetylevel5@gmail.com>"]
|
|
6
|
+
license = ""
|
|
7
|
+
repository = "https://github.com/biosafetylvl5/lexeme-type"
|
|
8
|
+
|
|
9
|
+
packages = [{include="lexeme_type", from="src"}]
|
|
10
|
+
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = ">=3.11,<3.14"
|
|
26
|
+
|
|
27
|
+
# Optional dependencies for extras
|
|
28
|
+
sphinx = { version = "*", optional = true }
|
|
29
|
+
sphinx-rtd-theme = { version = "*", optional = true }
|
|
30
|
+
sphinx-argparse = { version = "*", optional = true }
|
|
31
|
+
sphinx-copybutton = { version = "*", optional = true }
|
|
32
|
+
sphinx-autodoc-typehints = { version = "*", optional = true }
|
|
33
|
+
sphinxcontrib-mermaid = { version = "*", optional = true }
|
|
34
|
+
myst-parser = { version = "*", optional = true }
|
|
35
|
+
pinkrst = { version = "*", optional = true }
|
|
36
|
+
doc8 = { version = "*", optional = true }
|
|
37
|
+
prettier = { version = "*", optional = true }
|
|
38
|
+
ruff = { version = "*", optional = true }
|
|
39
|
+
mypy = { version = "*", optional = true }
|
|
40
|
+
pytest = { version = "*", optional = true }
|
|
41
|
+
pytest-cov = { version = "*", optional = true }
|
|
42
|
+
pytest-xdist = { version = "*", optional = true }
|
|
43
|
+
commitizen = { version = "*", optional = true }
|
|
44
|
+
pydantic = { version = "*", optional = true }
|
|
45
|
+
|
|
46
|
+
[tool.poetry.group.dev.dependencies]
|
|
47
|
+
# Development dependencies that are always installed for development
|
|
48
|
+
black = "*"
|
|
49
|
+
pre-commit = "*"
|
|
50
|
+
brassy = "*"
|
|
51
|
+
commitizen = "*"
|
|
52
|
+
|
|
53
|
+
[tool.poetry.extras]
|
|
54
|
+
doc = [
|
|
55
|
+
"sphinx", # for docs building
|
|
56
|
+
"myst-parser",
|
|
57
|
+
"sphinx-rtd-theme", # for docs building
|
|
58
|
+
"sphinx-copybutton", # for docs building
|
|
59
|
+
"sphinx-autodoc-typehints", # for docs building
|
|
60
|
+
"sphinxcontrib-mermaid", # for docs building
|
|
61
|
+
"sphinx-argparse", # for docs building
|
|
62
|
+
"pinkrst", # for rst formatting
|
|
63
|
+
"doc8", # for rst linting
|
|
64
|
+
"commitizen", # for better commits
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
lint = [
|
|
68
|
+
"pinkrst", # for rst auto-formatting
|
|
69
|
+
"doc8", # for rst linting
|
|
70
|
+
"prettier", # YAML, JSON linting
|
|
71
|
+
"ruff", # Python linting
|
|
72
|
+
"mypy", # Type checking
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
test = [
|
|
76
|
+
"pytest", # for running tests
|
|
77
|
+
"pytest-cov", # for test coverage
|
|
78
|
+
"pytest-xdist", # for parallel testing
|
|
79
|
+
"pydantic", # for pydantic compat. testing
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.poetry.scripts]
|
|
83
|
+
# project_cli = "project.cli:main"
|
|
84
|
+
|
|
85
|
+
[tool.poetry.urls]
|
|
86
|
+
Homepage = "https://github.com/biosafetylvl5/lexeme-type"
|
|
87
|
+
Issues = "https://github.com/biosafetylvl5/lexeme-type/issues"
|
|
88
|
+
Documentation = "https://biosafetylvl5.github.io/lexeme-type/"
|
|
89
|
+
|
|
90
|
+
[tool.mypy]
|
|
91
|
+
python_version = "3.12"
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
disallow_untyped_defs = true
|
|
95
|
+
disallow_incomplete_defs = true
|
|
96
|
+
check_untyped_defs = true
|
|
97
|
+
disallow_untyped_decorators = true
|
|
98
|
+
no_implicit_optional = true
|
|
99
|
+
strict_optional = true
|
|
100
|
+
warn_redundant_casts = true
|
|
101
|
+
warn_unused_ignores = true
|
|
102
|
+
warn_no_return = true
|
|
103
|
+
warn_unreachable = true
|
|
104
|
+
|
|
105
|
+
[[tool.mypy.overrides]]
|
|
106
|
+
module = "tests.*"
|
|
107
|
+
disallow_untyped_defs = false
|
|
108
|
+
follow_untyped_imports = true
|
|
109
|
+
|
|
110
|
+
[tool.pytest.ini_options]
|
|
111
|
+
pythonpath = ["."]
|
|
112
|
+
testpaths = ["tests"]
|
|
113
|
+
python_files = "test_*.py"
|
|
114
|
+
norecursedirs = ["xarray_utils"]
|
|
115
|
+
addopts = "-v -rf --ff --cov=src --cov-report=term-missing --cov-report=xml -m \"not limited_test_dataset_availability\""
|
|
116
|
+
markers = [
|
|
117
|
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
118
|
+
"integration: CONCURRENT - marks tests as integration (select with '-m \"integration\"')",
|
|
119
|
+
"spans_multiple_packages: CONCURRENT - marks integration tests that test functionality that could fail due to errors in multiple repositories. e.g. listing or validating all plugins, etc. Allows calling tests to ensure the same error will not cause tests in multiple repos to fail.",
|
|
120
|
+
"limited_test_dataset_availability: CONCURRENT - marks integration tests that may have limited test dataset availability so may not be available to all users.",
|
|
121
|
+
"base: OPTIONAL INDEPENDENT - integration tests that complete very quickly and require only a minimal installation for testing basic functionality within the current repository",
|
|
122
|
+
"full: REQUIRED INDEPENDENT - integration tests that require a 'full' installation for testing all functionality within the current repository. 'full' marker includes all tests for the current repo except for tests included in the optional 'base' marker.",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[tool.coverage.run]
|
|
126
|
+
source = ["src"]
|
|
127
|
+
omit = ["tests/*"]
|
|
128
|
+
|
|
129
|
+
[tool.coverage.report]
|
|
130
|
+
exclude_lines = [
|
|
131
|
+
"pragma: no cover",
|
|
132
|
+
"def __repr__",
|
|
133
|
+
"raise NotImplementedError",
|
|
134
|
+
"if __name__ == .__main__.:",
|
|
135
|
+
"pass",
|
|
136
|
+
"raise ImportError",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
[tool.bandit]
|
|
140
|
+
exclude_dirs = ["tests", "docs"]
|
|
141
|
+
skips = ["B101"] # Skip assert warnings
|
|
142
|
+
|
|
143
|
+
[tool.ruff]
|
|
144
|
+
# Target Python version
|
|
145
|
+
target-version = "py312"
|
|
146
|
+
line-length = 88
|
|
147
|
+
exclude = [
|
|
148
|
+
"tests",
|
|
149
|
+
"src/legacy",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
[tool.ruff.lint]
|
|
153
|
+
# Enable rules
|
|
154
|
+
select = [
|
|
155
|
+
"E", "F", "W", # pycodestyle and Pyflakes
|
|
156
|
+
"I", # isort
|
|
157
|
+
"N", # PEP8 naming
|
|
158
|
+
"D", # pydocstyle
|
|
159
|
+
"UP", # pyupgrade
|
|
160
|
+
"B", # flake8-bugbear
|
|
161
|
+
"C4", # flake8-comprehensions
|
|
162
|
+
"SIM", # flake8-simplify
|
|
163
|
+
"ARG", # flake8-unused-arguments
|
|
164
|
+
"ERA", # eradicate
|
|
165
|
+
"PL", # Pylint
|
|
166
|
+
"RUF", # Ruff-specific rules
|
|
167
|
+
"TCH", # flake8-type-checking
|
|
168
|
+
"S", # flake8-bandit (security)
|
|
169
|
+
"A", # flake8-builtins
|
|
170
|
+
"COM", # flake8-commas
|
|
171
|
+
"PT", # flake8-pytest-style
|
|
172
|
+
"PTH", # flake8-use-pathlib
|
|
173
|
+
"TRY", # tryceratops (exception handling)
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
ignore = [
|
|
177
|
+
"D203", # one-blank-line-before-class (conflicts with D211)
|
|
178
|
+
"D212", # multi-line-summary-first-line (conflicts with D213)
|
|
179
|
+
"E731", # allow variable assigned lambdas (PLEASE!! I need my lambdas!)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
183
|
+
fixable = ["ALL"]
|
|
184
|
+
unfixable = []
|
|
185
|
+
|
|
186
|
+
# Allow unused variables when underscore-prefixed.
|
|
187
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
188
|
+
|
|
189
|
+
[tool.ruff.lint.pydocstyle]
|
|
190
|
+
convention = "numpy"
|
|
191
|
+
|
|
192
|
+
[tool.ruff.lint.mccabe]
|
|
193
|
+
max-complexity = 5
|
|
194
|
+
|
|
195
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
196
|
+
ban-relative-imports = "all"
|
|
197
|
+
|
|
198
|
+
[tool.ruff.lint.per-file-ignores]
|
|
199
|
+
# Allow assert statements in tests
|
|
200
|
+
"tests/**/*" = ["S101"]
|
|
201
|
+
# Ignore import violations in __init__.py files
|
|
202
|
+
"__init__.py" = ["F401"]
|
|
203
|
+
|
|
204
|
+
[tool.ruff.format]
|
|
205
|
+
# Like Black, use double quotes for strings.
|
|
206
|
+
quote-style = "double"
|
|
207
|
+
# Like Black, indent with spaces, rather than tabs.
|
|
208
|
+
indent-style = "space"
|
|
209
|
+
# Like Black, respect magic trailing commas.
|
|
210
|
+
skip-magic-trailing-comma = false
|
|
211
|
+
# Unlike Black, force unix style line endings ("\n")
|
|
212
|
+
line-ending = "lf"
|
|
213
|
+
|
|
214
|
+
[build-system]
|
|
215
|
+
requires = ["poetry-core>=1.0.0"]
|
|
216
|
+
build-backend = "poetry.core.masonry.api"
|
|
217
|
+
|
|
218
|
+
[tool.commitizen]
|
|
219
|
+
name = "cz_conventional_commits"
|
|
220
|
+
tag_format = "v$version"
|
|
221
|
+
version_provider = "poetry"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
|
|
6
|
+
__version__ = importlib.metadata.version(__package__ or __name__)
|
|
7
|
+
except ModuleNotFoundError:
|
|
8
|
+
try:
|
|
9
|
+
import importlib_metadata
|
|
10
|
+
|
|
11
|
+
__version__ = importlib_metadata.version(__package__ or __name__)
|
|
12
|
+
except ModuleNotFoundError:
|
|
13
|
+
logging.debug(
|
|
14
|
+
"Could not set __version__ because importlib.metadata is not available."
|
|
15
|
+
+ "If running python 3.7, installing importlib-metadata will fix this issue"
|
|
16
|
+
)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# # # This source code is subject to the license referenced at
|
|
2
|
+
# # # https://github.com/NRLMMD-GEOIPS.
|
|
3
|
+
|
|
4
|
+
"""Lightweight helper for treating singular and plural spellings as equivalent.
|
|
5
|
+
|
|
6
|
+
A drop‑in str subclass that normalizes English nouns so that their
|
|
7
|
+
singular and plural forms compare as equal, hash to the same key, and work
|
|
8
|
+
inside Pydantic v2 models. Ignores capitalization.
|
|
9
|
+
|
|
10
|
+
Examples
|
|
11
|
+
--------
|
|
12
|
+
>>> from partial_lexeme import Lexeme
|
|
13
|
+
>>> Lexeme("reader") == "readers" == Lexeme("readers")
|
|
14
|
+
True
|
|
15
|
+
>>> {Lexeme("analyses"): 1} == {"analysis": 1}
|
|
16
|
+
True
|
|
17
|
+
|
|
18
|
+
>>> from pydantic import BaseModel
|
|
19
|
+
>>> class _Plugin(BaseModel):
|
|
20
|
+
>>> kind: Lexeme
|
|
21
|
+
>>> interface: Lexeme
|
|
22
|
+
>>> model = _Plugin(kind="reader", interface="readers")
|
|
23
|
+
>>> assert model.kind == model.interface == "reader"
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
from typing import Any, Dict
|
|
28
|
+
|
|
29
|
+
__all__ = ["Lexeme"]
|
|
30
|
+
|
|
31
|
+
# Irregular nouns – plural ↔ singular
|
|
32
|
+
# - Each plural maps to its singular
|
|
33
|
+
# - Singulars map to themselves so that a single lookup works
|
|
34
|
+
_IRREGULAR: Dict[str, str] = {
|
|
35
|
+
"analyses": "analysis",
|
|
36
|
+
"analysis": "analysis",
|
|
37
|
+
"indices": "index",
|
|
38
|
+
"index": "index",
|
|
39
|
+
"children": "child",
|
|
40
|
+
"data": "datum",
|
|
41
|
+
"criteria": "criterion",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Reverse map – singular → plural
|
|
45
|
+
_S_TO_P: Dict[str, str] = {
|
|
46
|
+
singular: plural for plural, singular in _IRREGULAR.items() if plural != singular
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize(word: str) -> str:
|
|
51
|
+
"""Convert nouns to their singular form.
|
|
52
|
+
|
|
53
|
+
When provided with a noun, whether singular or plural, returns its singular form.
|
|
54
|
+
This is done using:
|
|
55
|
+
|
|
56
|
+
1. An irregular lookup table, `_IRREGULAR` for known exceptions.
|
|
57
|
+
2. Heuristic suffix rules for regular pluralization:
|
|
58
|
+
|
|
59
|
+
- Words ending in "ies" - replace "ies" with "y" if preceded by a consonant
|
|
60
|
+
- Words in double "zzes" - removes "zes"
|
|
61
|
+
- Words ending in "sses", "xes", or "zes" - remove "es"
|
|
62
|
+
- Words ending in "ses" - remove "s"
|
|
63
|
+
- Words ending in "es" and containing "sh" or "ch" in their singular form -
|
|
64
|
+
remove "es"
|
|
65
|
+
- Words ending in singular "s" and more than three characters long - remove "s"
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
word : str
|
|
70
|
+
The noun in its singular or plural form.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
str
|
|
75
|
+
A plausible singular form of the given word.
|
|
76
|
+
|
|
77
|
+
Notes
|
|
78
|
+
-----
|
|
79
|
+
- It does not handle all English irregulars such as mice -> mouse unless present
|
|
80
|
+
in `_IRREGULAR`.
|
|
81
|
+
- Words that do not match any rule are returned unchanged in lowercase.
|
|
82
|
+
"""
|
|
83
|
+
w = word.strip().lower()
|
|
84
|
+
if not w:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
if w in _IRREGULAR:
|
|
88
|
+
return _IRREGULAR[w]
|
|
89
|
+
|
|
90
|
+
# heuristics
|
|
91
|
+
|
|
92
|
+
# parties -> party (replace "ies" with "y" if the preceding letter is a consonant)
|
|
93
|
+
if w.endswith("ies") and len(w) > 3 and w[-4] not in "aeiou":
|
|
94
|
+
return w[:-3] + "y"
|
|
95
|
+
|
|
96
|
+
# quizzes -> quiz (remove "zes")
|
|
97
|
+
if w.endswith("zzes"):
|
|
98
|
+
return w[:-3]
|
|
99
|
+
|
|
100
|
+
# processes -> process (remove "es")
|
|
101
|
+
# boxes -> box, buzzes -> buzz (remove "es")
|
|
102
|
+
if w.endswith(("sses", "xes", "zes")):
|
|
103
|
+
return w[:-2]
|
|
104
|
+
|
|
105
|
+
# processes -> process (remove "s")
|
|
106
|
+
if w.endswith("ses"):
|
|
107
|
+
return w[:-1]
|
|
108
|
+
|
|
109
|
+
# dishes -> dish, churches -> church (remove "es")
|
|
110
|
+
if w.endswith("es") and w[-4:-2] in {"sh", "ch"}:
|
|
111
|
+
return w[:-2]
|
|
112
|
+
|
|
113
|
+
# readers -> reader (remove "s")
|
|
114
|
+
if w.endswith("s") and not w.endswith("ss") and len(w) > 3:
|
|
115
|
+
return w[:-1]
|
|
116
|
+
|
|
117
|
+
return w
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _to_plural(word: str) -> str:
|
|
121
|
+
r"""Return a plausible plural spelling for *word*.
|
|
122
|
+
|
|
123
|
+
In this function’s documentation, consider * a regular Linux wildcard character.
|
|
124
|
+
This function attempts tp pluralize a singular English noun using:
|
|
125
|
+
|
|
126
|
+
1. An irregular lookup table, `_S_TO_P` for known exceptions.
|
|
127
|
+
2. Heuristic spelling rules for regular pluralization:
|
|
128
|
+
|
|
129
|
+
- \\*consonant + y --> \\*+ies such as party -> parties
|
|
130
|
+
- \\*(x, ch, sh, ss, zz) --> \\*+es such as box -> boxes, buzz -> buzzes
|
|
131
|
+
- \\*z -> \\*+zes --> such as quiz -> quizzes
|
|
132
|
+
- near-default with min length of two characters -> default+s
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
word : str
|
|
137
|
+
The singular form of noun.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
str
|
|
142
|
+
A plausible plural form of the given word.
|
|
143
|
+
|
|
144
|
+
Raises
|
|
145
|
+
------
|
|
146
|
+
ValueError
|
|
147
|
+
If the `word` is empty or no pluralization rule can be applied.
|
|
148
|
+
|
|
149
|
+
Notes
|
|
150
|
+
-----
|
|
151
|
+
- This is best‑effort but the lexeme type does not depend on it because equality
|
|
152
|
+
and hashing always normalizes back to singular.
|
|
153
|
+
- It does not handle all English irregulars such as mouse -> mice unless present
|
|
154
|
+
in `_S_TO_P`.
|
|
155
|
+
- Consider ``*`` a regular Linux wildcard character for reference.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
w = word.strip().lower()
|
|
159
|
+
|
|
160
|
+
# check for irregular words first
|
|
161
|
+
if w in _S_TO_P:
|
|
162
|
+
return _S_TO_P[w]
|
|
163
|
+
|
|
164
|
+
# heuristics
|
|
165
|
+
|
|
166
|
+
# party -> parties (replace "y" with "ies" if preceding by a consonant)
|
|
167
|
+
if w.endswith("y") and w[-2] not in "aeiou":
|
|
168
|
+
return w[:-1] + "ies"
|
|
169
|
+
|
|
170
|
+
# box -> boxes (ends with "x" -> append "es")
|
|
171
|
+
# church -> churches (ends with "ch" -> append "es")
|
|
172
|
+
# dish -> dishes (ends with "sh" -> append "es")
|
|
173
|
+
# process -> processes (ends with "ss" -> append "es")
|
|
174
|
+
# buzz -> buzzes (ends with double "z" -> append "es")
|
|
175
|
+
if w.endswith(("x", "ch", "sh", "ss", "zz")):
|
|
176
|
+
return w + "es"
|
|
177
|
+
|
|
178
|
+
# quiz -> quizzes (ends with single "z" -> append zes)
|
|
179
|
+
if w.endswith("z"):
|
|
180
|
+
return w + "zes"
|
|
181
|
+
|
|
182
|
+
# reader -> readers (near default scenario, append "s")
|
|
183
|
+
if len(w) >= 2:
|
|
184
|
+
return w + "s"
|
|
185
|
+
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"No pluralization rule for word: {w}. Check if it's a valid noun!!"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Lexeme(str):
|
|
192
|
+
"""A string that treats singular and plural spellings as equal.
|
|
193
|
+
|
|
194
|
+
It behaves exactly like a built‑in ``str`` but overrides equality and
|
|
195
|
+
hashing to use the canonical singular form. This makes singular and
|
|
196
|
+
plural spellings interchangeable as dict keys, set members, CLI options,
|
|
197
|
+
etc.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __new__(cls, value: Any) -> "Lexeme":
|
|
201
|
+
"""Create new Lexeme."""
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
return super().__new__(cls, value)
|
|
204
|
+
raise TypeError(
|
|
205
|
+
f"{cls.__name__} must be built from str, got {type(value).__name__}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def singular(self) -> "Lexeme":
|
|
210
|
+
"""Return the singular spelling."""
|
|
211
|
+
return _normalize(self)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def plural(self) -> "Lexeme":
|
|
215
|
+
"""Return a plural spelling."""
|
|
216
|
+
return _to_plural(self.singular)
|
|
217
|
+
|
|
218
|
+
def _key(self) -> str:
|
|
219
|
+
"""Normalize key for comparisons and hashing."""
|
|
220
|
+
return _normalize(self)
|
|
221
|
+
|
|
222
|
+
def __eq__(self, other: object) -> bool:
|
|
223
|
+
"""Plural or singular equality check."""
|
|
224
|
+
if isinstance(other, str):
|
|
225
|
+
return self._key() == _normalize(other)
|
|
226
|
+
if isinstance(other, Lexeme):
|
|
227
|
+
return self._key() == other._key()
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
def __hash__(self) -> int:
|
|
231
|
+
"""Hash lexeme."""
|
|
232
|
+
return hash(self._key())
|
|
233
|
+
|
|
234
|
+
def __repr__(self) -> str:
|
|
235
|
+
"""Print out un-serialized self."""
|
|
236
|
+
return f"{self.__class__.__name__}({super().__str__()!r})"
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def __get_pydantic_core_schema__(cls, _source_type, _handler):
|
|
240
|
+
"""Return a core_schema that allows ``Lexeme`` for use as a field in pydantic.
|
|
241
|
+
|
|
242
|
+
Accepts ``str`` or ``Lexeme``; coerces to ``Lexeme``.
|
|
243
|
+
Always serializes output as plain string.
|
|
244
|
+
"""
|
|
245
|
+
# lazy import so only imported if using pydantic
|
|
246
|
+
from pydantic_core import core_schema
|
|
247
|
+
|
|
248
|
+
def _to_lexeme(value: object, _info=None):
|
|
249
|
+
if isinstance(value, cls):
|
|
250
|
+
return value
|
|
251
|
+
if isinstance(value, str):
|
|
252
|
+
return cls(value)
|
|
253
|
+
raise TypeError("String or Lexeme required")
|
|
254
|
+
|
|
255
|
+
return core_schema.no_info_after_validator_function(
|
|
256
|
+
_to_lexeme,
|
|
257
|
+
core_schema.str_schema(),
|
|
258
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
259
|
+
lambda v: str(v)
|
|
260
|
+
),
|
|
261
|
+
)
|