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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reglem = reglem.cli:main
@@ -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.