reglem 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.
- reglem/__init__.py +40 -0
- reglem/anki.py +23 -0
- reglem/build.py +103 -0
- reglem/cli.py +146 -0
- reglem/errors.py +23 -0
- reglem/languages/__init__.py +29 -0
- reglem/languages/_base.py +34 -0
- reglem/languages/greek.py +144 -0
- reglem/normalize.py +48 -0
- reglem/options.py +71 -0
- reglem/py.typed +0 -0
- reglem/variants.py +32 -0
- reglem-0.1.0.dist-info/METADATA +154 -0
- reglem-0.1.0.dist-info/RECORD +17 -0
- reglem-0.1.0.dist-info/WHEEL +4 -0
- reglem-0.1.0.dist-info/entry_points.txt +2 -0
- reglem-0.1.0.dist-info/licenses/LICENSE +21 -0
reglem/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""reglem: build anchored regex alternations over word lemmas.
|
|
2
|
+
|
|
3
|
+
Turns a list of lemmas into one anchored regex alternation
|
|
4
|
+
(`^(lemma1|lemma2|...)([terminators]|$)`, split into one bracketed group per
|
|
5
|
+
terminator set when a language excludes a terminator for specific lemmas --
|
|
6
|
+
see `build.py`), with optional per-language spelling-variant expansion
|
|
7
|
+
(currently: Greek macron/long-vowel forms), and an Anki `field:re:...`
|
|
8
|
+
search-string wrapper. See `docs/greek.md` and the README for the motivating
|
|
9
|
+
detail.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from reglem.anki import build_anki_search
|
|
17
|
+
from reglem.build import build_pattern
|
|
18
|
+
from reglem.errors import EmptyLemmaSetError, ReglemError, UnknownLanguageError
|
|
19
|
+
from reglem.languages import available_languages, get_language
|
|
20
|
+
from reglem.normalize import normalize_lemma, prepare_lemmas
|
|
21
|
+
from reglem.options import SearchOptions
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("reglem")
|
|
25
|
+
except PackageNotFoundError: # pragma: no cover -- only hit for an uninstalled checkout
|
|
26
|
+
__version__ = "0.0.0+unknown"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EmptyLemmaSetError",
|
|
30
|
+
"ReglemError",
|
|
31
|
+
"SearchOptions",
|
|
32
|
+
"UnknownLanguageError",
|
|
33
|
+
"__version__",
|
|
34
|
+
"available_languages",
|
|
35
|
+
"build_anki_search",
|
|
36
|
+
"build_pattern",
|
|
37
|
+
"get_language",
|
|
38
|
+
"normalize_lemma",
|
|
39
|
+
"prepare_lemmas",
|
|
40
|
+
]
|
reglem/anki.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Wrap a lemma pattern as a quoted Anki `field:re:...` search string.
|
|
2
|
+
|
|
3
|
+
Anki field regexes are unanchored by default (`front:re:[a-c]1` matches
|
|
4
|
+
anywhere in the field, not just at its start), which is why `build_pattern`
|
|
5
|
+
anchors with `^` explicitly rather than relying on Anki to do it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from reglem.build import build_pattern
|
|
11
|
+
from reglem.options import SearchOptions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_anki_search(lemmas: list[str], options: SearchOptions | None = None) -> str:
|
|
15
|
+
"""Build a quoted `"field:re:pattern"` Anki search matching any of `lemmas`.
|
|
16
|
+
|
|
17
|
+
Raises `EmptyLemmaSetError` (from `build_pattern`) if `lemmas` is empty.
|
|
18
|
+
"""
|
|
19
|
+
options = options or SearchOptions()
|
|
20
|
+
pattern = build_pattern(lemmas, options)
|
|
21
|
+
escaped_field = options.field.replace('"', '\\"')
|
|
22
|
+
escaped_pattern = pattern.replace('"', '\\"')
|
|
23
|
+
return f'"{escaped_field}:re:{escaped_pattern}"'
|
reglem/build.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Build an anchored regex alternation matching a set of lemmas.
|
|
2
|
+
|
|
3
|
+
The pattern shape is `^(alt1|alt2|...)([terminator-chars]|$)`: anchored to
|
|
4
|
+
the start of the field/line, so a lemma that's a prefix of another word
|
|
5
|
+
(`ὁ` vs `ὁδός`) doesn't false-match, and terminated by an explicit character
|
|
6
|
+
class or end-of-string rather than a lookahead assertion.
|
|
7
|
+
|
|
8
|
+
That terminator design isn't cosmetic: this is meant to also work as an Anki
|
|
9
|
+
`re:` search, and Anki's `re:` matching uses the Rust `regex` crate (see
|
|
10
|
+
https://docs.rs/regex, referenced from docs.ankiweb.net/searching.html),
|
|
11
|
+
which does NOT support lookaround. A pattern like `^word(?=[ ,])` is valid
|
|
12
|
+
Python/PCRE but invalid there -- the terminator has to be an ordinary
|
|
13
|
+
alternation group instead.
|
|
14
|
+
|
|
15
|
+
One anchored alternation over all lemmas, rather than one OR'd term per
|
|
16
|
+
lemma, keeps the pattern shorter and the match a single regex pass instead
|
|
17
|
+
of N.
|
|
18
|
+
|
|
19
|
+
A language may also declare, per lemma, terminator characters that must be
|
|
20
|
+
*excluded* for that lemma specifically (`Language.excluded_terminators` --
|
|
21
|
+
see `languages/greek.py`'s `ARTICLE_FORMS` for why). When no lemma triggers
|
|
22
|
+
an exclusion, the whole set shares one terminator group exactly as above.
|
|
23
|
+
When one does, the alternatives are split into groups by their effective
|
|
24
|
+
terminator set and each group gets its own trailing group instead:
|
|
25
|
+
`^((?:lemma-a|lemma-b)(?:[terminators]|$)|(?:lemma-c)(?:[fewer-terminators]|$))`.
|
|
26
|
+
A branch whose lemma matches but whose terminator doesn't still falls
|
|
27
|
+
through to try the next branch, under ordinary leftmost-first alternation
|
|
28
|
+
(true for both Python's `re` and the Rust `regex` crate) -- so this still
|
|
29
|
+
needs no lookaround, same as the single-group form.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
from reglem.errors import EmptyLemmaSetError
|
|
37
|
+
from reglem.languages import get_language
|
|
38
|
+
from reglem.normalize import prepare_lemmas
|
|
39
|
+
from reglem.options import SearchOptions
|
|
40
|
+
from reglem.variants import VariantTable, expand_variants
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _expand(text: str, table: VariantTable | None) -> str:
|
|
44
|
+
"""Escape `text` literally, or with per-character variants from `table`."""
|
|
45
|
+
if table is None:
|
|
46
|
+
return re.escape(text)
|
|
47
|
+
return expand_variants(text, table)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _terminator_group(terminators: str) -> str:
|
|
51
|
+
"""Regex fragment matching one allowed terminator, or end-of-string.
|
|
52
|
+
|
|
53
|
+
`terminators` may be empty (every character excluded for this lemma) --
|
|
54
|
+
`[]` is not a valid regex character class, so that case falls back to
|
|
55
|
+
requiring end-of-string outright.
|
|
56
|
+
"""
|
|
57
|
+
if not terminators:
|
|
58
|
+
return "$"
|
|
59
|
+
return f"[{re.escape(terminators)}]|$"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_pattern(lemmas: list[str], options: SearchOptions | None = None) -> str:
|
|
63
|
+
"""Build a bare (unquoted, un-field-prefixed) regex pattern matching `lemmas`.
|
|
64
|
+
|
|
65
|
+
Raises `EmptyLemmaSetError` if `lemmas` is empty or normalizes down to
|
|
66
|
+
nothing (e.g. all blank strings).
|
|
67
|
+
"""
|
|
68
|
+
options = options or SearchOptions()
|
|
69
|
+
alternatives = prepare_lemmas(lemmas, strip_trailing_digits=options.strip_trailing_digits)
|
|
70
|
+
if not alternatives:
|
|
71
|
+
raise EmptyLemmaSetError
|
|
72
|
+
|
|
73
|
+
language = get_language(options.language) # already validated by SearchOptions
|
|
74
|
+
table: VariantTable | None = None
|
|
75
|
+
if options.with_macrons:
|
|
76
|
+
table = language.variant_tables.get("macrons")
|
|
77
|
+
excluded_terminators = language.excluded_terminators
|
|
78
|
+
|
|
79
|
+
# Group alternatives by their effective (post-exclusion) terminator
|
|
80
|
+
# string, preserving prepare_lemmas' longest-first order within a group.
|
|
81
|
+
groups: dict[str, list[str]] = {}
|
|
82
|
+
for alt in alternatives:
|
|
83
|
+
excluded = excluded_terminators.get(alt, "")
|
|
84
|
+
effective = "".join(char for char in options.terminators if char not in excluded)
|
|
85
|
+
groups.setdefault(effective, []).append(alt)
|
|
86
|
+
|
|
87
|
+
if len(groups) == 1:
|
|
88
|
+
(effective,) = groups
|
|
89
|
+
alternation = "|".join(_expand(alt, table) for alt in alternatives)
|
|
90
|
+
pattern = f"^({alternation})({_terminator_group(effective)})"
|
|
91
|
+
else:
|
|
92
|
+
# Unrestricted group (if any) first, then the rest sorted for a
|
|
93
|
+
# deterministic, reviewable pattern.
|
|
94
|
+
ordered = sorted(groups, key=lambda t: (t != options.terminators, t))
|
|
95
|
+
branches = [
|
|
96
|
+
f"(?:{'|'.join(_expand(alt, table) for alt in groups[effective])})"
|
|
97
|
+
f"(?:{_terminator_group(effective)})"
|
|
98
|
+
for effective in ordered
|
|
99
|
+
]
|
|
100
|
+
pattern = f"^({'|'.join(branches)})"
|
|
101
|
+
|
|
102
|
+
re.compile(pattern) # self-check: fail here, not at the point of use
|
|
103
|
+
return pattern
|
reglem/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Command-line entry point for reglem.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
reglem lemmas.txt
|
|
6
|
+
cat lemmas.txt | reglem -
|
|
7
|
+
reglem -w lemma-one -w "lemma two" --macrons
|
|
8
|
+
reglem lemmas.txt --raw --terminators ",;"
|
|
9
|
+
reglem --list-languages
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from reglem import __version__
|
|
19
|
+
from reglem.anki import build_anki_search
|
|
20
|
+
from reglem.build import build_pattern
|
|
21
|
+
from reglem.errors import ReglemError
|
|
22
|
+
from reglem.languages import available_languages
|
|
23
|
+
from reglem.options import DEFAULT_FIELD, DEFAULT_TERMINATORS, SearchOptions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
"""Build the `reglem` argument parser."""
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="reglem",
|
|
30
|
+
description=__doc__,
|
|
31
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"path",
|
|
35
|
+
nargs="?",
|
|
36
|
+
default=None,
|
|
37
|
+
help="file of lemmas, one per line ('#' comments and blank lines skipped); "
|
|
38
|
+
"'-' or omitted reads stdin",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-w",
|
|
42
|
+
"--word",
|
|
43
|
+
action="append",
|
|
44
|
+
dest="words",
|
|
45
|
+
metavar="LEMMA",
|
|
46
|
+
help="a lemma to include; repeatable. Overrides path/stdin when given.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-f",
|
|
50
|
+
"--field",
|
|
51
|
+
default=DEFAULT_FIELD,
|
|
52
|
+
help=f"Anki field to match against (default: {DEFAULT_FIELD!r})",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--language",
|
|
56
|
+
default="greek",
|
|
57
|
+
help="language whose spelling-variant tables to use (default: greek)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--macrons",
|
|
61
|
+
action="store_true",
|
|
62
|
+
help="expand ambiguous-length vowels into macron alternatives (language-dependent)",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--strip-trailing-digits",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="strip a trailing homograph digit (e.g. 'lead2') from each lemma before matching",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--terminators",
|
|
71
|
+
default=DEFAULT_TERMINATORS,
|
|
72
|
+
help=f"characters (besides end-of-field) that may follow a matched lemma "
|
|
73
|
+
f"(default: {DEFAULT_TERMINATORS!r})",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--raw",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="print the bare regex pattern instead of a quoted Anki search string",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--list-languages",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="print known language names and exit",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--version",
|
|
87
|
+
action="version",
|
|
88
|
+
version=f"reglem {__version__}",
|
|
89
|
+
)
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_lemmas(path: str | None, words: list[str] | None) -> list[str]:
|
|
94
|
+
"""Resolve lemmas from `-w` words, a file path, or stdin, in that precedence order."""
|
|
95
|
+
if words:
|
|
96
|
+
return list(words)
|
|
97
|
+
|
|
98
|
+
if path is None or path == "-":
|
|
99
|
+
lines = sys.stdin.readlines()
|
|
100
|
+
else:
|
|
101
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
102
|
+
|
|
103
|
+
lemmas: list[str] = []
|
|
104
|
+
for raw_line in lines:
|
|
105
|
+
line = raw_line.strip()
|
|
106
|
+
if not line or line.startswith("#"):
|
|
107
|
+
continue
|
|
108
|
+
lemmas.append(line)
|
|
109
|
+
return lemmas
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: list[str] | None = None) -> int:
|
|
113
|
+
"""Run the `reglem` CLI. Returns the process exit code."""
|
|
114
|
+
parser = build_parser()
|
|
115
|
+
args = parser.parse_args(argv)
|
|
116
|
+
|
|
117
|
+
if args.list_languages:
|
|
118
|
+
for name in available_languages():
|
|
119
|
+
print(name)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
lemmas = _read_lemmas(args.path, args.words)
|
|
124
|
+
options = SearchOptions(
|
|
125
|
+
language=args.language,
|
|
126
|
+
with_macrons=args.macrons,
|
|
127
|
+
strip_trailing_digits=args.strip_trailing_digits,
|
|
128
|
+
terminators=args.terminators,
|
|
129
|
+
field=args.field,
|
|
130
|
+
)
|
|
131
|
+
result = (
|
|
132
|
+
build_pattern(lemmas, options) if args.raw else build_anki_search(lemmas, options)
|
|
133
|
+
)
|
|
134
|
+
except (ReglemError, ValueError) as exc:
|
|
135
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
print(result)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|
reglem/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Error types raised by reglem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReglemError(Exception):
|
|
7
|
+
"""Base class for every error reglem raises on purpose."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EmptyLemmaSetError(ReglemError):
|
|
11
|
+
"""Raised when a pattern is requested for zero lemmas."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
"""Build the fixed error message for this error."""
|
|
15
|
+
super().__init__("no lemmas given -- need at least one to build a pattern")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownLanguageError(ReglemError):
|
|
19
|
+
"""Raised when a language name isn't in the registry."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str, known: tuple[str, ...]) -> None:
|
|
22
|
+
"""Build an error message naming the bad `name` and the `known` alternatives."""
|
|
23
|
+
super().__init__(f"unknown language {name!r}; known languages: {', '.join(known)}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Registry of known languages.
|
|
2
|
+
|
|
3
|
+
A plain dict, not a plugin system -- with one language, entry-point discovery
|
|
4
|
+
would be pure ceremony. Revisit once a second language actually shows up.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from reglem.errors import UnknownLanguageError
|
|
10
|
+
from reglem.languages._base import Language
|
|
11
|
+
from reglem.languages.greek import GREEK
|
|
12
|
+
|
|
13
|
+
_REGISTRY: dict[str, Language] = {GREEK.name: GREEK}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_language(name: str) -> Language:
|
|
17
|
+
"""Look up a `Language` by name, raising `UnknownLanguageError` if unknown."""
|
|
18
|
+
try:
|
|
19
|
+
return _REGISTRY[name]
|
|
20
|
+
except KeyError:
|
|
21
|
+
raise UnknownLanguageError(name, available_languages()) from None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def available_languages() -> tuple[str, ...]:
|
|
25
|
+
"""Return the names of every registered language, sorted."""
|
|
26
|
+
return tuple(sorted(_REGISTRY))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["Language", "available_languages", "get_language"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The `Language` model shared by every language module and the registry.
|
|
2
|
+
|
|
3
|
+
Split out from `languages/__init__.py` to avoid a circular import: each
|
|
4
|
+
language module (e.g. `greek.py`) builds a `Language` instance at import
|
|
5
|
+
time, and the registry in `__init__.py` imports those modules -- so the
|
|
6
|
+
`Language` type itself can't live in `__init__.py` too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
# Needed at runtime, not just for type-checking: pydantic resolves the
|
|
14
|
+
# `dict[str, VariantTable]` annotation below when building the model, which
|
|
15
|
+
# requires `VariantTable` to actually be in this module's namespace.
|
|
16
|
+
from reglem.variants import VariantTable # noqa: TC001
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Language(BaseModel):
|
|
20
|
+
"""A named set of per-character spelling-variant tables for one language."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
variant_tables: dict[str, VariantTable]
|
|
26
|
+
excluded_terminators: dict[str, str] = {}
|
|
27
|
+
"""Lemma -> terminator characters that may NOT follow that exact lemma.
|
|
28
|
+
|
|
29
|
+
Empty by default, so languages without any such exception need not set
|
|
30
|
+
it. See `greek.py`'s `ARTICLE_FORMS` for the motivating case: a lemma
|
|
31
|
+
whose normal dictionary citation is never followed by a plain space, so
|
|
32
|
+
space needs to be excluded from its terminator set specifically rather
|
|
33
|
+
than for every lemma.
|
|
34
|
+
"""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Greek variant tables: macron (long-vowel) alternatives for α, ι, υ.
|
|
2
|
+
|
|
3
|
+
Many Greek word lists (e.g. lemma dictionaries, vocabulary tools) mark accent
|
|
4
|
+
and breathing but never vowel *length* -- α, ι, and υ are ambiguous between
|
|
5
|
+
short and long in that spelling. Pedagogical texts, by contrast, often mark
|
|
6
|
+
long instances of these three vowels with a macron layered on top of
|
|
7
|
+
whatever accent/breathing the vowel already carries, e.g. a long alpha with
|
|
8
|
+
smooth breathing appears as ᾱ̓ rather than ἀ.
|
|
9
|
+
|
|
10
|
+
Unicode has a precomposed macron-vowel character (ᾱ ῑ ῡ) but NOT a
|
|
11
|
+
precomposed macron+breathing or macron+accent character -- those are the
|
|
12
|
+
macron-vowel codepoint followed by ordinary *combining* breathing/accent
|
|
13
|
+
marks (U+0313 smooth, U+0314 rough, U+0301 acute, U+0300 grave), breathing
|
|
14
|
+
before accent. Verified against Python's `unicodedata` (both directions):
|
|
15
|
+
|
|
16
|
+
>>> import unicodedata
|
|
17
|
+
>>> unicodedata.normalize("NFC", "ᾱ" + "̓" + "́") == "ᾱ" + "̓" + "́"
|
|
18
|
+
True # already canonical -- NFC does not reorder or recompose it further
|
|
19
|
+
|
|
20
|
+
Also verified: NFC always resolves the alpha/iota/upsilon + acute ambiguity
|
|
21
|
+
(there are two Unicode encodings of e.g. "alpha with acute", the polytonic
|
|
22
|
+
U+1F71 and the monotonic U+03AC "tonos" form) down to the single tonos
|
|
23
|
+
codepoint -- most Greek lemma sources use NFC text, so there's no
|
|
24
|
+
dual-encoding to account for on that side.
|
|
25
|
+
|
|
26
|
+
Circumflex forms (ᾶ, ἆ, ἇ, and the ι/υ equivalents) are deliberately absent
|
|
27
|
+
from MACRON_MAP: a circumflex accent can only fall on a long vowel, so a
|
|
28
|
+
circumflexed α/ι/υ is already unambiguously long and has no separate macron
|
|
29
|
+
form to search for.
|
|
30
|
+
|
|
31
|
+
Known simplification: this maps every occurrence of a bare/accented/breathed
|
|
32
|
+
α, ι, or υ, including ones that are actually the first or second vowel of a
|
|
33
|
+
diphthong (αι, αυ, ει, ευ, οι, ου, υι), where a macron would never actually
|
|
34
|
+
be written. This only makes a generated pattern larger (extra alternation
|
|
35
|
+
branches that can never match anything real), not incorrect -- reliably
|
|
36
|
+
detecting diphthongs is much more machinery than the false-positive cost
|
|
37
|
+
justifies here.
|
|
38
|
+
|
|
39
|
+
Separately, `ARTICLE_FORMS` lists the 19 forms of the Greek definite article.
|
|
40
|
+
A lemma pattern normally allows a plain space right after a matched lemma
|
|
41
|
+
(see `DEFAULT_TERMINATORS` in `options.py`), but the article is special: it
|
|
42
|
+
is never the last thing before running Greek text in a dictionary/word-list
|
|
43
|
+
entry the way an ordinary headword is. Instead it's cited on its own, joined
|
|
44
|
+
to its other forms by punctuation -- `ὁ, ἡ, τό` or `ὁ/ἡ/τό` -- while in
|
|
45
|
+
running text it is immediately followed by the word it modifies (`ὁ σοφός`,
|
|
46
|
+
`ἡ ἀρίστη`). Without an exception, an article lemma matches the start of
|
|
47
|
+
every one of those unrelated entries. So `GREEK.excluded_terminators` denies
|
|
48
|
+
a plain space, and a non-breaking space (U+00A0), right after any article
|
|
49
|
+
form -- comma, period, slash, and end-of-field are still allowed.
|
|
50
|
+
|
|
51
|
+
NBSP is excluded for the same reason it's a default terminator at all
|
|
52
|
+
(`options.py`'s `DEFAULT_TERMINATORS` docstring): it renders as an invisible
|
|
53
|
+
space in the Anki editor while still separating words in field HTML, so it
|
|
54
|
+
needs the same treatment as a literal space here.
|
|
55
|
+
|
|
56
|
+
Grave-accented forms (τὸν, τὴν, τοὺς, τὰς -- the article as it actually
|
|
57
|
+
appears in running text, where an acute shifts to a grave before another
|
|
58
|
+
word) are deliberately not in `ARTICLE_FORMS`: a lemma list cites the acute
|
|
59
|
+
citation form, not the running-text accentuation, so there is nothing to
|
|
60
|
+
exclude a terminator from for those spellings. Adding them would be a
|
|
61
|
+
one-line extension if a source ever needs it.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import unicodedata
|
|
67
|
+
from typing import TYPE_CHECKING
|
|
68
|
+
|
|
69
|
+
from reglem.languages._base import Language
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from reglem.variants import VariantTable
|
|
73
|
+
|
|
74
|
+
_SMOOTH = "̓" # combining comma above (psili)
|
|
75
|
+
_ROUGH = "̔" # combining reversed comma above (dasia)
|
|
76
|
+
_ACUTE = "́" # combining acute accent (oxia)
|
|
77
|
+
_GRAVE = "̀" # combining grave accent (varia)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _macron_table(bare: str, macron_base: str, breathing_accent_block: str) -> dict[str, str]:
|
|
81
|
+
"""Build the 9-entry {surface_form: macron_form} table for one vowel.
|
|
82
|
+
|
|
83
|
+
`breathing_accent_block` holds the eight precomposed Greek Extended
|
|
84
|
+
characters for this vowel, in the fixed order: smooth, rough,
|
|
85
|
+
smooth+grave, rough+grave, smooth+acute, rough+acute, grave, acute.
|
|
86
|
+
"""
|
|
87
|
+
smooth, rough, smooth_grave, rough_grave, smooth_acute, rough_acute, grave, acute = (
|
|
88
|
+
breathing_accent_block
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
bare: macron_base,
|
|
92
|
+
acute: macron_base + _ACUTE,
|
|
93
|
+
grave: macron_base + _GRAVE,
|
|
94
|
+
smooth: macron_base + _SMOOTH,
|
|
95
|
+
rough: macron_base + _ROUGH,
|
|
96
|
+
smooth_acute: macron_base + _SMOOTH + _ACUTE,
|
|
97
|
+
rough_acute: macron_base + _ROUGH + _ACUTE,
|
|
98
|
+
smooth_grave: macron_base + _SMOOTH + _GRAVE,
|
|
99
|
+
rough_grave: macron_base + _ROUGH + _GRAVE,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# fmt: off
|
|
104
|
+
MACRON_MAP: VariantTable = {
|
|
105
|
+
**_macron_table("α", "ᾱ", "ἀἁἂἃἄἅὰά"),
|
|
106
|
+
**_macron_table("ι", "ῑ", "ἰἱἲἳἴἵὶί"),
|
|
107
|
+
**_macron_table("υ", "ῡ", "ὐὑὒὓὔὕὺύ"),
|
|
108
|
+
}
|
|
109
|
+
# fmt: on
|
|
110
|
+
|
|
111
|
+
_EXPECTED_MACRON_MAP_SIZE = 27 # 9 each for α/ι/υ
|
|
112
|
+
assert len(MACRON_MAP) == _EXPECTED_MACRON_MAP_SIZE, ( # noqa: S101
|
|
113
|
+
f"expected {_EXPECTED_MACRON_MAP_SIZE} entries, got {len(MACRON_MAP)}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# fmt: off
|
|
117
|
+
ARTICLE_FORMS: tuple[str, ...] = tuple(
|
|
118
|
+
unicodedata.normalize("NFC", form)
|
|
119
|
+
for form in (
|
|
120
|
+
"ὁ", "ἡ", "τό",
|
|
121
|
+
"τοῦ", "τῆς", "τῷ", "τῇ", "τόν", "τήν",
|
|
122
|
+
"τώ", "τοῖν",
|
|
123
|
+
"οἱ", "αἱ", "τά", "τῶν", "τοῖς", "ταῖς", "τούς", "τάς",
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
# fmt: on
|
|
127
|
+
"""The 19 forms of the Greek definite article (masc./fem./neut., all cases,
|
|
128
|
+
all numbers), NFC-normalized to match how `prepare_lemmas` compares lemmas.
|
|
129
|
+
See the module docstring for why these specifically need a terminator
|
|
130
|
+
exception.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_EXPECTED_ARTICLE_FORM_COUNT = 19
|
|
134
|
+
assert len(ARTICLE_FORMS) == _EXPECTED_ARTICLE_FORM_COUNT, ( # noqa: S101
|
|
135
|
+
f"expected {_EXPECTED_ARTICLE_FORM_COUNT} article forms, got {len(ARTICLE_FORMS)}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
_SPACE_TERMINATORS = " \xa0" # plain space, non-breaking space
|
|
139
|
+
|
|
140
|
+
GREEK = Language(
|
|
141
|
+
name="greek",
|
|
142
|
+
variant_tables={"macrons": MACRON_MAP},
|
|
143
|
+
excluded_terminators=dict.fromkeys(ARTICLE_FORMS, _SPACE_TERMINATORS),
|
|
144
|
+
)
|
reglem/normalize.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Normalize raw lemma text before it goes into a pattern.
|
|
2
|
+
|
|
3
|
+
Two independent cleanups live here:
|
|
4
|
+
|
|
5
|
+
- Unicode NFC normalization, so visually-identical lemmas that happen to use
|
|
6
|
+
different combining-mark orders or precomposed-vs-decomposed forms compare
|
|
7
|
+
equal.
|
|
8
|
+
- Optional trailing-digit stripping, for word lists that disambiguate
|
|
9
|
+
homographs with a suffix digit (e.g. ``lead2`` for the metal vs. ``lead``
|
|
10
|
+
the verb). Off by default -- not every source uses this convention, and
|
|
11
|
+
guessing wrong silently merges two different words.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import unicodedata
|
|
18
|
+
|
|
19
|
+
_TRAILING_DIGITS_RE = re.compile(r"\d+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_lemma(text: str, *, strip_trailing_digits: bool = False) -> str:
|
|
23
|
+
"""Return `text` NFC-normalized, optionally with trailing digits removed."""
|
|
24
|
+
if strip_trailing_digits:
|
|
25
|
+
text = _TRAILING_DIGITS_RE.sub("", text)
|
|
26
|
+
return unicodedata.normalize("NFC", text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def prepare_lemmas(
|
|
30
|
+
lemmas: list[str],
|
|
31
|
+
*,
|
|
32
|
+
strip_trailing_digits: bool = False,
|
|
33
|
+
) -> list[str]:
|
|
34
|
+
"""Normalize, drop blanks, dedupe, and order a list of raw lemmas.
|
|
35
|
+
|
|
36
|
+
Order is longest-first (ties broken alphabetically) rather than
|
|
37
|
+
insertion order or plain sort: it's deterministic, and it guarantees a
|
|
38
|
+
longer alternative is tried before a shorter one that happens to be its
|
|
39
|
+
prefix, which matters under leftmost-first regex alternation (the
|
|
40
|
+
engines targeted here, including the Rust `regex` crate Anki embeds,
|
|
41
|
+
pick the first alternative that matches rather than the longest).
|
|
42
|
+
"""
|
|
43
|
+
normalized = (
|
|
44
|
+
normalize_lemma(lemma, strip_trailing_digits=strip_trailing_digits).strip()
|
|
45
|
+
for lemma in lemmas
|
|
46
|
+
)
|
|
47
|
+
deduped = {lemma for lemma in normalized if lemma}
|
|
48
|
+
return sorted(deduped, key=lambda lemma: (-len(lemma), lemma))
|
reglem/options.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Options controlling how a lemma pattern is built."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
6
|
+
|
|
7
|
+
from reglem.errors import UnknownLanguageError
|
|
8
|
+
from reglem.languages import available_languages, get_language
|
|
9
|
+
|
|
10
|
+
DEFAULT_TERMINATORS = " ,.\xa0/"
|
|
11
|
+
"""Space, comma, full stop, non-breaking space (U+00A0), slash.
|
|
12
|
+
|
|
13
|
+
The non-breaking space matters for Anki fields specifically: it shows up in
|
|
14
|
+
field HTML (e.g. entity-derived) but is invisible in the Anki editor, so
|
|
15
|
+
omitting it here would cause silent, hard-to-diagnose match misses. It's
|
|
16
|
+
harmless to keep as a default for other targets too.
|
|
17
|
+
|
|
18
|
+
The slash covers dictionary entries that cite alternative spellings inline,
|
|
19
|
+
e.g. a slash-separated Greek article (`ὁ/ἡ/τό`) or adjective principal parts
|
|
20
|
+
(`ἀγαθός/ή/όν`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
DEFAULT_FIELD = "Greek"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SearchOptions(BaseModel):
|
|
27
|
+
"""Immutable, validated configuration for `build_pattern` / `build_anki_search`."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
30
|
+
|
|
31
|
+
language: str = "greek"
|
|
32
|
+
with_macrons: bool = False
|
|
33
|
+
strip_trailing_digits: bool = False
|
|
34
|
+
terminators: str = DEFAULT_TERMINATORS
|
|
35
|
+
field: str = DEFAULT_FIELD
|
|
36
|
+
|
|
37
|
+
@field_validator("language")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _known_language(cls, value: str) -> str:
|
|
40
|
+
# pydantic only auto-wraps ValueError/TypeError/AssertionError raised
|
|
41
|
+
# from a validator into its own ValidationError, so translate here
|
|
42
|
+
# rather than let UnknownLanguageError escape raw.
|
|
43
|
+
try:
|
|
44
|
+
get_language(value)
|
|
45
|
+
except UnknownLanguageError as exc:
|
|
46
|
+
raise ValueError(str(exc)) from exc
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
@field_validator("terminators")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _terminators_not_empty(cls, value: str) -> str:
|
|
52
|
+
if not value:
|
|
53
|
+
msg = "terminators must not be empty"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
@field_validator("field")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _field_not_blank(cls, value: str) -> str:
|
|
60
|
+
if not value.strip():
|
|
61
|
+
msg = "field must not be blank"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
if "\n" in value or "\r" in value:
|
|
64
|
+
msg = "field must not contain a newline"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def known_languages(self) -> tuple[str, ...]:
|
|
70
|
+
"""Convenience passthrough for callers building error/help text."""
|
|
71
|
+
return available_languages()
|
reglem/py.typed
ADDED
|
File without changes
|
reglem/variants.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Expand a literal string into a regex fragment with per-character spelling variants.
|
|
2
|
+
|
|
3
|
+
A variant table maps one surface character to one alternative spelling of
|
|
4
|
+
that character (e.g. a plain vowel to its long-marked form). `expand_variants`
|
|
5
|
+
walks the input character by character, escaping every character literally
|
|
6
|
+
except the ones with a table entry, which become a non-capturing
|
|
7
|
+
`(?:plain|variant)` group.
|
|
8
|
+
|
|
9
|
+
This never emits lookaround (`(?=...)`, `(?!...)`), on purpose: some target
|
|
10
|
+
engines this feeds into -- notably the Rust `regex` crate Anki embeds -- don't
|
|
11
|
+
support it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
VariantTable = Mapping[str, str]
|
|
20
|
+
"""Surface character -> alternative spelling of that character."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expand_variants(text: str, table: VariantTable) -> str:
|
|
24
|
+
"""Return a regex source fragment matching `text` or its per-character variants."""
|
|
25
|
+
parts: list[str] = []
|
|
26
|
+
for char in text:
|
|
27
|
+
variant = table.get(char)
|
|
28
|
+
if variant is None:
|
|
29
|
+
parts.append(re.escape(char))
|
|
30
|
+
else:
|
|
31
|
+
parts.append(f"(?:{re.escape(char)}|{re.escape(variant)})")
|
|
32
|
+
return "".join(parts)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: reglem
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build anchored regex alternations over word lemmas, with optional per-language spelling variants.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jaycrick/reglem
|
|
6
|
+
Project-URL: Issues, https://github.com/jaycrick/reglem/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/jaycrick/reglem/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: jaycrick <114450568+jaycrick@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anki,greek,lemma,linguistics,regex
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pydantic>=2.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# reglem
|
|
28
|
+
|
|
29
|
+
Turn list of word lemmas into one anchored regex alternation.
|
|
30
|
+
Optional per-language spelling variants (Greek macrons, for now).
|
|
31
|
+
Optional Anki `field:re:...` search-string wrapper.
|
|
32
|
+
|
|
33
|
+
Greek only today.
|
|
34
|
+
Language layer built so more languages drop in later without touching core.
|
|
35
|
+
|
|
36
|
+
## Why
|
|
37
|
+
|
|
38
|
+
Building `re:` search for Anki (or any regex-search tool) by hand from a word list is fiddly:
|
|
39
|
+
prefix leaks (`ox` matching `oxen`),
|
|
40
|
+
Rust `regex` crate has no lookahead so `(?=...)` breaks in Anki,
|
|
41
|
+
spelling variants (accents, long-vowel marks) multiply the work.
|
|
42
|
+
This package does that once, correctly, tested.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv add reglem
|
|
48
|
+
# or
|
|
49
|
+
pip install reglem
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart — library
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from reglem import build_anki_search, build_pattern, SearchOptions
|
|
56
|
+
|
|
57
|
+
# bare regex
|
|
58
|
+
build_pattern(["cat", "dog"])
|
|
59
|
+
# '^(dog|cat)([ ,. ]|$)'
|
|
60
|
+
|
|
61
|
+
# Anki search string
|
|
62
|
+
build_anki_search(["ὁ", "καί", "ἀγαθός"], SearchOptions(field="Greek", with_macrons=True))
|
|
63
|
+
# '"Greek:re:^(...)([ ,. ]|$)"'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Paste that string into Anki's Browse search bar.
|
|
67
|
+
It matches notes whose field *starts* with one of the given lemmas,
|
|
68
|
+
followed by space, comma, period, or end of field.
|
|
69
|
+
|
|
70
|
+
## Quickstart — CLI
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
reglem lemmas.txt # Anki search string, one lemma per line in file
|
|
74
|
+
cat lemmas.txt | reglem - # same, from stdin
|
|
75
|
+
reglem -w ὁ -w καί -w ἀγαθός --macrons
|
|
76
|
+
reglem lemmas.txt --raw # bare regex, no Anki wrapper
|
|
77
|
+
reglem --list-languages
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`lemmas.txt`: one lemma per line, blank lines and `#` comments skipped.
|
|
81
|
+
|
|
82
|
+
## Flags
|
|
83
|
+
|
|
84
|
+
| flag | default | does |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `-w/--word LEMMA` | — | add one lemma; repeatable; overrides file/stdin |
|
|
87
|
+
| `-f/--field NAME` | `Greek` | Anki field name in output search string |
|
|
88
|
+
| `--macrons` | off | expand ambiguous-length vowels into macron alternatives |
|
|
89
|
+
| `--strip-trailing-digits` | off | strip trailing homograph digit (`lead2` → `lead`) before matching |
|
|
90
|
+
| `--terminators CHARS` | `" ,. /"` (space, comma, period, NBSP, slash) | chars allowed right after a matched lemma |
|
|
91
|
+
| `--raw` | off | print bare regex, skip the `"field:re:..."` wrapper |
|
|
92
|
+
| `--language NAME` | `greek` | which variant tables to use for `--macrons` |
|
|
93
|
+
| `--list-languages` | — | print known language names, exit |
|
|
94
|
+
|
|
95
|
+
## Why prefix-anchored, no lookahead
|
|
96
|
+
|
|
97
|
+
Anki's `re:` search uses the Rust `regex` crate (no lookaround support),
|
|
98
|
+
and field regexes are unanchored by default.
|
|
99
|
+
So a naive `word(?=[ ,])` breaks two ways:
|
|
100
|
+
it's invalid syntax in Anki,
|
|
101
|
+
and without `^` it'd match `word` inside `password` too.
|
|
102
|
+
reglem builds `^(alt1|alt2|...)([terminators]|$)` instead —
|
|
103
|
+
one alternation, explicit anchor, ordinary terminator class.
|
|
104
|
+
When a language excludes a terminator for a specific lemma (see Greek article,
|
|
105
|
+
below), that lemma gets its own terminator group in a separate branch instead —
|
|
106
|
+
still no lookahead.
|
|
107
|
+
|
|
108
|
+
## Greek macrons
|
|
109
|
+
|
|
110
|
+
See `docs/greek.md` for the full story —
|
|
111
|
+
why unmarked Greek text is vowel-length-ambiguous,
|
|
112
|
+
and how the macron expansion table is built.
|
|
113
|
+
|
|
114
|
+
## Greek article
|
|
115
|
+
|
|
116
|
+
A plain space isn't allowed right after any of the 19 forms of the Greek
|
|
117
|
+
definite article (`ὁ`, `ἡ`, `τό`, `τῶν`, `τούς`, ...): unlike an ordinary
|
|
118
|
+
headword, the article is never immediately followed by running Greek text in
|
|
119
|
+
its own dictionary entry — it's cited with punctuation instead, `ὁ, ἡ, τό` or
|
|
120
|
+
`ὁ/ἡ/τό`. Without the exception, `ὁ` as a lemma would match the start of
|
|
121
|
+
every entry that *begins* with the article, like `ὁ σοφός, -ή, -όν wise` or
|
|
122
|
+
`ἡ ἀρίστη`, which are entries for other words entirely.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
build_pattern(["ὁ"])
|
|
126
|
+
# matches: "ὁ, ἡ, τό the" "ὁ/ἡ/τό" "ὁ" (end of field)
|
|
127
|
+
# doesn't match: "ὁ σοφός, -ή, -όν wise" "ἡ ἀρίστη"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This is why `/` is in `DEFAULT_TERMINATORS`: it's how a slash-separated
|
|
131
|
+
article citation (`ὁ/ἡ/τό`) still matches. See `docs/greek.md` for the full
|
|
132
|
+
list of forms and why grave-accented running-text forms (`τὸν`, `τὰς`, ...)
|
|
133
|
+
are intentionally excluded.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
uv sync
|
|
139
|
+
uv run pytest # coverage gate: 95%
|
|
140
|
+
uv run ruff check .
|
|
141
|
+
uv run ruff format --check .
|
|
142
|
+
uv run basedpyright
|
|
143
|
+
uv run pre-commit run --all-files
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Publish
|
|
147
|
+
|
|
148
|
+
Tag `vX.Y.Z`, push tag.
|
|
149
|
+
CI builds, checks with `twine`, publishes via PyPI Trusted Publishing (OIDC) —
|
|
150
|
+
no stored token.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
reglem/__init__.py,sha256=g2CeOWuHFrD3Jy0FH5rGgvXZNluEI6kPMwFmOei04ng,1363
|
|
2
|
+
reglem/anki.py,sha256=dkT22d3n2yk4CQAk02EyrBl9dTD6SDcxdji6CMrV2Hs,906
|
|
3
|
+
reglem/build.py,sha256=atfZ5APMUeiC0g7e1MSIi7ciIuau9h6euoHniFbhb78,4525
|
|
4
|
+
reglem/cli.py,sha256=26w-a5KqO0rcVWW6LoTUUuTghW4JKl1Dg0FXBX9Fqgw,4211
|
|
5
|
+
reglem/errors.py,sha256=UBmvL546v-TqNKwdV9GiD4Ief_y8CIwH0-OnqvhNzSs,800
|
|
6
|
+
reglem/normalize.py,sha256=WLJyQz9keS8vvkB5QBcCB8b4v3tD3gSHm4_852CpY5Q,1814
|
|
7
|
+
reglem/options.py,sha256=ZgSI-CRna17vOsVrSQwP-CpR0RRKvAWLnHZ__J9E3_g,2452
|
|
8
|
+
reglem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
reglem/variants.py,sha256=U_Ab4NgAWe9D5uVqLFTj9QtLm1Nw2wRMl1o7XA0KiCM,1178
|
|
10
|
+
reglem/languages/__init__.py,sha256=uCeZMS5kywGEWYR_y-ZN150EapV_q6WwqOlrFGrGkYg,886
|
|
11
|
+
reglem/languages/_base.py,sha256=1mwckLbbnvBoTjhcMduPGqaaQ67xlgUaFpZQtBPUSKc,1405
|
|
12
|
+
reglem/languages/greek.py,sha256=TiZ9RAmdx6hc9Gog_GgGQ6hZm6Uf281cIewzHPhJqp4,6451
|
|
13
|
+
reglem-0.1.0.dist-info/METADATA,sha256=uAS19ceQsNJD_5wVSyZ5xzLCSQjp4JUFtQTJgZLiiFk,5540
|
|
14
|
+
reglem-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
reglem-0.1.0.dist-info/entry_points.txt,sha256=rxps2uXLydkzf8naa3CyRTVjl_PmJNLvsG4SJesVuJg,43
|
|
16
|
+
reglem-0.1.0.dist-info/licenses/LICENSE,sha256=24vkaUM_k3XV1XLdmlOXCKxnmR-0sItHGixvySd_4GE,1065
|
|
17
|
+
reglem-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jaycrick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to
|
|
7
|
+
deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
9
|
+
sell copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
21
|
+
IN THE SOFTWARE.
|