amharic-segmenter 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.
@@ -0,0 +1,40 @@
1
+ """amharic-segmenter: sentence, word and morpheme tokenization for Amharic (አማርኛ)."""
2
+ from __future__ import annotations
3
+
4
+ from ._version import __version__
5
+ from .morphology import Analysis
6
+ from .normalize import normalize_homophones
7
+ from .tokenizer import (
8
+ AmharicStemmer,
9
+ AmharicTokenizer,
10
+ Token,
11
+ analyze,
12
+ lemma,
13
+ sent_tokenize,
14
+ sentence_tokenize,
15
+ split_morphemes,
16
+ stem,
17
+ stem_words,
18
+ tokenize,
19
+ tokenize_spans,
20
+ word_tokenize,
21
+ )
22
+
23
+ __all__ = [
24
+ "AmharicStemmer",
25
+ "AmharicTokenizer",
26
+ "Analysis",
27
+ "Token",
28
+ "__version__",
29
+ "analyze",
30
+ "lemma",
31
+ "normalize_homophones",
32
+ "sent_tokenize",
33
+ "sentence_tokenize",
34
+ "split_morphemes",
35
+ "stem",
36
+ "stem_words",
37
+ "tokenize",
38
+ "tokenize_spans",
39
+ "word_tokenize",
40
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,69 @@
1
+ """Low-level helpers for the Ethiopic (Fidel) script.
2
+
3
+ Each Ethiopic consonant occupies a block of 8 consecutive code points, one per
4
+ vowel *order*. The first seven are the classic orders used in Amharic
5
+ grammar books (the names below follow the traditional Ge'ez terminology):
6
+
7
+ 0 ግዕዝ (ä) 1 ካዕብ (u) 2 ሣልስ (i) 3 ራብዕ (a)
8
+ 4 ሃምስ (e) 5 ሳድስ (ə / no vowel) 6 ሳብዕ (o)
9
+
10
+ The eighth slot of a block is a labialised form (e.g. ሏ) and is ignored here.
11
+ A few blocks (e.g. ቈ, ኰ, ጐ) are irregular in Unicode and are deliberately
12
+ left out, because they never occur as word-final letters in ordinary text.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ GEEZ, KAEB, SALIS, RABI, HAMIS, SADIS, SABI = range(7)
17
+
18
+ ORDER_NAMES = ("ግዕዝ", "ካዕብ", "ሣልስ", "ራብዕ", "ሃምስ", "ሳድስ", "ሳብዕ")
19
+
20
+ _FIRST = 0x1200
21
+ _LAST_REGULAR = 0x1357
22
+ _IRREGULAR = (
23
+ (0x1248, 0x124F),
24
+ (0x1258, 0x125F),
25
+ (0x1288, 0x128F),
26
+ (0x12B0, 0x12B7),
27
+ (0x12C0, 0x12C7),
28
+ (0x1310, 0x1317),
29
+ )
30
+
31
+
32
+ def is_ethiopic_letter(ch: str) -> bool:
33
+ """True for a character in the Ethiopic syllable range."""
34
+ return _FIRST <= ord(ch) <= 0x135A
35
+
36
+
37
+ def _is_regular(cp: int) -> bool:
38
+ if not (_FIRST <= cp <= _LAST_REGULAR):
39
+ return False
40
+ return not any(lo <= cp <= hi for lo, hi in _IRREGULAR)
41
+
42
+
43
+ def fidel_order(ch: str) -> int | None:
44
+ """Return the vowel order (0-6) of a fidel, or ``None`` if not applicable."""
45
+ cp = ord(ch)
46
+ if not _is_regular(cp):
47
+ return None
48
+ order = (cp - _FIRST) % 8
49
+ return order if order <= SABI else None
50
+
51
+
52
+ def is_wa_form(ch: str) -> bool:
53
+ """True for the labialised 8th form of a letter (ሟ, ሏ, ጓ …).
54
+
55
+ It is how the feminine ``ዋ`` is written when it fuses into a consonant,
56
+ e.g. ሐኪም + ዋ -> ሐኪሟ.
57
+ """
58
+ cp = ord(ch)
59
+ return _is_regular(cp) and (cp - _FIRST) % 8 == 7
60
+
61
+
62
+ def with_order(ch: str, order: int) -> str:
63
+ """Return the fidel with the same consonant as ``ch`` in another vowel order."""
64
+ current = fidel_order(ch)
65
+ if current is None:
66
+ raise ValueError(f"{ch!r} is not a regular Ethiopic syllable")
67
+ if not 0 <= order <= SABI:
68
+ raise ValueError("order must be between 0 and 6")
69
+ return chr(ord(ch) - current + order)
@@ -0,0 +1,71 @@
1
+ """Command line interface: ``amharic-segmenter`` / ``python -m amharic_segmenter``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from ._version import __version__
9
+ from .tokenizer import AmharicTokenizer
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ p = argparse.ArgumentParser(
14
+ prog="amharic-segmenter",
15
+ description="Tokenize Amharic text into sentences, words or morphemes.",
16
+ )
17
+ p.add_argument("text", nargs="?", help="text to tokenize (reads stdin when omitted)")
18
+ p.add_argument("-s", "--sentences", action="store_true", help="split into sentences instead of words")
19
+ p.add_argument(
20
+ "-m", "--morphemes", action="store_true", help="split words into prefixes, stems and suffixes"
21
+ )
22
+ p.add_argument("--stem", action="store_true", help="print the stem of every word")
23
+ p.add_argument("--mark-affixes", action="store_true", help="write prefixes as 'በ+' and suffixes as '+ን'")
24
+ p.add_argument("--strategy", choices=["lexicon", "rules", "none"], default="lexicon",
25
+ help="morpheme splitting strategy (default: lexicon)")
26
+ p.add_argument("--lexicon", metavar="FILE", help="UTF-8 file with extra stems, one per line")
27
+ p.add_argument(
28
+ "--normalize", action="store_true", help="collapse homophone letters (ሐ->ሀ, ሠ->ሰ, ዐ->አ, ፀ->ጸ)"
29
+ )
30
+ p.add_argument("--no-punct", action="store_true", help="drop punctuation tokens")
31
+ p.add_argument("--json", action="store_true", help="print the result as JSON")
32
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
33
+ return p
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ args = build_parser().parse_args(argv)
38
+ for stream in (sys.stdout, sys.stdin):
39
+ if hasattr(stream, "reconfigure"):
40
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
41
+
42
+ text = args.text if args.text is not None else sys.stdin.read()
43
+ kwargs = dict(strategy=args.strategy, normalize=args.normalize)
44
+ tok = (AmharicTokenizer.from_file(args.lexicon, **kwargs) if args.lexicon
45
+ else AmharicTokenizer(**kwargs))
46
+
47
+ if args.stem:
48
+ result = [tok.stem(t.text) for t in tok.tokenize_spans(text, include_punct=not args.no_punct)
49
+ if t.kind != "punct" or not args.no_punct]
50
+ sep = " "
51
+ elif args.sentences:
52
+ result = tok.sentence_tokenize(text)
53
+ sep = "\n"
54
+ else:
55
+ result = tok.tokenize(
56
+ text,
57
+ morphemes=args.morphemes,
58
+ include_punct=not args.no_punct,
59
+ mark_affixes=args.mark_affixes,
60
+ )
61
+ sep = " "
62
+
63
+ if args.json:
64
+ print(json.dumps(result, ensure_ascii=False))
65
+ else:
66
+ print(sep.join(result))
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__": # pragma: no cover
71
+ raise SystemExit(main())
@@ -0,0 +1,43 @@
1
+ # Irregular plurals: singular<TAB>plural ('#' = comment)
2
+ #
3
+ # Transcribed from photographed pages of አማርኛ ሠዋሰው (1963 E.C.): the Geez-origin
4
+ # plurals in section 2.2 (pp. 42-44) and the reduplicated adjective plurals in
5
+ # section 2.3.4 (pp. 47-48). Both forms are added to the lexicon, so they can take
6
+ # suffixes (መጻሕፍትን) and the regular plural can be added on top (ቃላቶች).
7
+ # Only entries that are clearly legible in the photos are listed - please review.
8
+
9
+ # --- Geez-origin plurals (section 2.2) ---
10
+ ካህን ካህናት
11
+ ጳጳስ ጳጳሳት
12
+ ቃል ቃላት
13
+ ሐዋርያ ሐዋርያት
14
+ ንጉሥ ነገሥታት
15
+ ነቢይ ነቢያት
16
+ መጽሐፍ መጻሕፍት
17
+ መምህር መምህራን
18
+ መስፍን መሳፍንት
19
+ ደብር አድባራት
20
+ ቄስ ቀሳውስት
21
+ መልአክ መላእክት
22
+ ሊቅ ሊቃውንት
23
+ ቤት አብያት
24
+ ርእስ አርእስት
25
+ ሕዝብ አሕዛብ
26
+ ዐምድ አዕማድ
27
+ ገዳም ገዳማት
28
+ አንቀጽ አናቅጽ
29
+ ድንግል ደናግል
30
+ አንበሳ አናብስት
31
+ ተዋናይ ተዋናያን
32
+ ማኅበር ማኅበራት
33
+ ኢትዮጵያ ኢትዮጵያውያን
34
+
35
+ # --- adjectives with reduplicated plurals (section 2.3.4) ---
36
+ ደግ ደጋግ
37
+ ጥቁር ጥቋቁር
38
+ ረጅም ረጃጅም
39
+ ትልቅ ትልልቅ
40
+ ትንሽ ትንንሽ
41
+ አጭር አጫጭር
42
+ ቀይ ቀያይ
43
+ ነጭ ነጫጭ
@@ -0,0 +1,110 @@
1
+ # Seed lexicon for amharic-segmenter (UTF-8, one word per line, '#' = comment).
2
+ #
3
+ # These are stems that may take prefixes/suffixes. A split such as ተማሪዎች ->
4
+ # ተማሪ + ዎች is only accepted when the stem is listed here (or when the
5
+ # opt-in "rules" strategy is used). The list starts with the nouns, names and
6
+ # function words that appear as examples in አማርኛ ሠዋሰው (1963 E.C.); grow it
7
+ # with a pull request or pass your own words to AmharicTokenizer(lexicon=...).
8
+
9
+ # names
10
+ ከበደ
11
+ አበበ
12
+ ተፈራ
13
+
14
+ # people and relations
15
+ ሰው
16
+ ልጅ
17
+ ተማሪ
18
+ አስተማሪ
19
+ ሐኪም
20
+ ጸሐፊ
21
+ ሠራተኛ
22
+ ዘበኛ
23
+ ገበሬ
24
+ ወንድም
25
+ አባት
26
+ እናት
27
+ ወንድ
28
+ ሴት
29
+ ሴትዮ
30
+ ባለቤት
31
+
32
+ # animals
33
+ ፈረስ
34
+ በሬ
35
+ በቅሎ
36
+ ላም
37
+ ዶሮ
38
+
39
+ # things and places
40
+ ቤት
41
+ ጓዳ
42
+ ጣራ
43
+ መጽሐፍ
44
+ ወረቀት
45
+ ብዕር
46
+ በር
47
+ ወንበር
48
+ ጠረጴዛ
49
+ እርሳስ
50
+ እንጀራ
51
+ ልብስ
52
+ ድንጋይ
53
+ መሰንቆ
54
+ ከተማ
55
+ አገር
56
+ ትምህርት
57
+ ጌታ
58
+
59
+ # adjectives and other stems used in examples
60
+ ችግር
61
+ ክፉ
62
+ ደግ
63
+ ብቻ
64
+
65
+ # pronouns and demonstratives (take ን, ና, ም)
66
+ እኔ
67
+ እኛ
68
+ አንተ
69
+ አንቺ
70
+ እሱ
71
+ እሷ
72
+ እርሱ
73
+ እርሷ
74
+ እነሱ
75
+ እናንተ
76
+ ይህ
77
+ ይህች
78
+ ያ
79
+ ያች
80
+ እነዚህ
81
+ እነዚያ
82
+
83
+ # common function words: never split, whatever suffix they happen to end with
84
+ ነው
85
+ ናቸው
86
+ ናት
87
+ ነኝ
88
+ ነህ
89
+ ነሽ
90
+ ነበር
91
+ ነበሩ
92
+ ነበረች
93
+ አይደለም
94
+ አይደሉም
95
+ ውስጥ
96
+ ላይ
97
+ ጋር
98
+ ግን
99
+ እና
100
+ ወይም
101
+ እንዲሁም
102
+ ሁሉ
103
+ ሁሉም
104
+ እንደ
105
+ ወደ
106
+ ስለ
107
+ ብዙ
108
+ ትንሽ
109
+ ዛሬ
110
+ ነገር
@@ -0,0 +1,182 @@
1
+ """Rule-based morphological segmentation for Amharic nouns and noun phrases.
2
+
3
+ The rules implemented here come from the grammar book *አማርኛ ሠዋሰው* (1963 E.C.):
4
+ prepositions that attach to the following word, the plural, the definite
5
+ article and possessive suffixes, the object marker ``ን`` and the conjunctions
6
+ ``ና`` / ``ም``. See ``docs/GRAMMAR_RULES.md`` for the rule-by-rule mapping.
7
+
8
+ A candidate split is only accepted when the remaining *stem* is trustworthy:
9
+ either it is in the lexicon (default, high precision) or, in the opt-in
10
+ ``"rules"`` strategy, it is at least ``min_stem`` letters long.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Iterator, Set
16
+ from dataclasses import dataclass
17
+
18
+ from .charset import KAEB, RABI, SABI, SADIS, fidel_order, is_wa_form, with_order
19
+
20
+ # Prefixes written together with the word that follows them.
21
+ PREFIXES: tuple[str, ...] = ("ስለ", "በ", "ከ", "ለ", "የ")
22
+ MAX_PREFIXES = 2
23
+
24
+ # Suffix slots, from the stem outward: plural -> possessive/definite -> ን -> ና/ም
25
+ CONJUNCTIONS: tuple[str, ...] = ("ና", "ም")
26
+ OBJECT_MARKER = "ን"
27
+ POSSESSIVE_DEFINITE: tuple[str, ...] = ("ቸው", "ችሁ", "ችን", "ው", "ዋ", "ህ", "ሽ", "ዬ")
28
+ # Suffixes before which the stem's last letter surfaces in the 'a' order
29
+ # (አገር + ችሁ -> አገራችሁ, ተማሪዎች + ችን -> ተማሪዎቻችን).
30
+ _LINKING: tuple[str, ...] = ("ቸው", "ችሁ", "ችን")
31
+
32
+ # Prepositions fuse with pronouns that start with እ: በ + እኔ -> በኔ, ከ + እኛ -> ከኛ,
33
+ # እንደ + አንተ -> እንዳንተ.
34
+ _CONTRACTING_PRONOUNS: tuple[str, ...] = ("እኔ", "እኛ", "እሱ", "እሷ", "እነሱ", "እርሱ", "እርሷ", "እናንተ")
35
+ CONTRACTION_PREFIXES: tuple[str, ...] = ("በ", "ከ", "ለ", "የ", "እንደ")
36
+
37
+
38
+ def _build_contractions() -> dict[str, tuple[str, str]]:
39
+ table = {}
40
+ for prefix in CONTRACTION_PREFIXES:
41
+ for pronoun in _CONTRACTING_PRONOUNS:
42
+ table[prefix + pronoun[1:]] = (prefix, pronoun)
43
+ for pronoun in ("አንተ", "አንቺ"):
44
+ table["እንዳ" + pronoun[1:]] = ("እንደ", pronoun)
45
+ return table
46
+
47
+
48
+ CONTRACTIONS = _build_contractions()
49
+
50
+ _ETHIOPIC_WORD = re.compile(r"^[\u1200-\u135A]+$")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Analysis:
55
+ """Result of segmenting one word.
56
+
57
+ ``morphemes`` are normalised morphs (underlying forms), so concatenating
58
+ them does not always reproduce the surface word (e.g. ልጆች -> ልጅ + ች).
59
+ """
60
+
61
+ word: str
62
+ prefixes: tuple[str, ...] = ()
63
+ stem: str = ""
64
+ suffixes: tuple[str, ...] = ()
65
+
66
+ @property
67
+ def morphemes(self) -> list[str]:
68
+ return [*self.prefixes, self.stem, *self.suffixes]
69
+
70
+ @property
71
+ def is_split(self) -> bool:
72
+ return bool(self.prefixes or self.suffixes)
73
+
74
+
75
+ def _opt(word: str, suffixes: tuple[str, ...]) -> Iterator[tuple[str, str | None]]:
76
+ yield word, None
77
+ for s in suffixes:
78
+ if len(word) > len(s) and word.endswith(s):
79
+ yield word[: -len(s)], s
80
+
81
+
82
+ def _opt_possessive(word: str) -> Iterator[tuple[str, str | None, bool]]:
83
+ """Possessive / definite-article slot, including fused surface forms.
84
+
85
+ The third item is ``True`` for *fused* forms (ሐኪሟ, ልጁ), which only nouns have.
86
+ They are accepted for known stems only, so verbs such as አየሁ are never touched.
87
+ """
88
+ yield word, None, False
89
+ for s in POSSESSIVE_DEFINITE:
90
+ if len(word) > len(s) and word.endswith(s):
91
+ rest = word[: -len(s)]
92
+ yield rest, s, False
93
+ if s in _LINKING and rest:
94
+ last = rest[-1]
95
+ if fidel_order(last) == RABI: # አገራችሁ -> አገር, ተማሪዎቻችን -> ተማሪዎች
96
+ yield rest[:-1] + with_order(last, SADIS), s, False
97
+ # feminine 'ዋ' fused into the last letter (labialised form): ሐኪሟ -> ሐኪም + ዋ
98
+ if len(word) >= 2 and is_wa_form(word[-1]):
99
+ yield word[:-1] + chr(ord(word[-1]) - 2), "ዋ", True
100
+ # masculine definite 'ኡ' fused into the last letter: ልጁ -> ልጅ, ተማሪዎቹ -> ተማሪዎች
101
+ if len(word) >= 2 and fidel_order(word[-1]) == KAEB:
102
+ yield word[:-1] + with_order(word[-1], SADIS), "ው", True
103
+
104
+
105
+ def _opt_plural(word: str) -> Iterator[tuple[str, str | None]]:
106
+ """Plural slot (the 1948 spelling convention described in the book)."""
107
+ yield word, None
108
+ if len(word) > 2 and word.endswith("ዎች"):
109
+ yield word[:-2], "ዎች"
110
+ if len(word) >= 2 and word.endswith("ች"):
111
+ stem0, prev = word[:-2], word[-2]
112
+ if fidel_order(prev) == SABI:
113
+ for order in (SADIS, RABI): # ሰዎች -> ሰው, ጌቶች -> ጌታ
114
+ yield stem0 + with_order(prev, order), "ች"
115
+ yield stem0 + prev, "ች" # በቅሎች -> በቅሎ
116
+
117
+
118
+ def _suffix_variants(word: str) -> Iterator[tuple[str, tuple[str, ...], bool]]:
119
+ for w1, conj in _opt(word, CONJUNCTIONS):
120
+ for w2, obj in _opt(w1, (OBJECT_MARKER,)):
121
+ for w3, pos, fused in _opt_possessive(w2):
122
+ for w4, plural in _opt_plural(w3):
123
+ yield w4, tuple(x for x in (plural, pos, obj, conj) if x), fused
124
+
125
+
126
+ def _prefix_variants(stem: str, depth: int = MAX_PREFIXES) -> Iterator[tuple[tuple[str, ...], str]]:
127
+ yield (), stem
128
+ if depth == 0:
129
+ return
130
+ for p in PREFIXES:
131
+ if len(stem) > len(p) and stem.startswith(p):
132
+ for rest_prefixes, rest in _prefix_variants(stem[len(p):], depth - 1):
133
+ yield (p, *rest_prefixes), rest
134
+
135
+
136
+ def analyze_word(
137
+ word: str,
138
+ lexicon: Set[str],
139
+ strategy: str = "lexicon",
140
+ min_stem: int = 2,
141
+ ) -> Analysis:
142
+ """Segment ``word`` into prefixes, a stem and suffixes.
143
+
144
+ ``strategy``:
145
+ * ``"lexicon"`` - accept a split only if the stem is a known word.
146
+ * ``"rules"`` - also accept any stem of at least ``min_stem`` letters
147
+ (higher recall, more false splits).
148
+ * ``"none"`` - never split.
149
+ """
150
+ unsplit = Analysis(word, (), word, ())
151
+ if strategy == "none" or word in lexicon or not _ETHIOPIC_WORD.match(word):
152
+ return unsplit
153
+
154
+ # Ranking of candidate analyses:
155
+ # * a stem found in the lexicon always beats an unknown one; among those,
156
+ # fewer affixes and then the longer stem win (conservative);
157
+ # * for unknown stems ("rules" strategy) avoid guessing prefixes, then peel
158
+ # as many suffixes as possible (e.g. ቸው rather than only ው).
159
+ best: Analysis | None = None
160
+ best_key: tuple[int, ...] | None = None
161
+ for stem_candidate, suffixes, fused in _suffix_variants(word):
162
+ contraction = CONTRACTIONS.get(stem_candidate)
163
+ if contraction is not None and contraction[1] in lexicon:
164
+ prefix, pronoun = contraction
165
+ key = (0, 1 + len(suffixes), -len(pronoun))
166
+ if best_key is None or key < best_key:
167
+ best_key, best = key, Analysis(word, (prefix,), pronoun, suffixes)
168
+ for prefixes, stem in _prefix_variants(stem_candidate):
169
+ if not (prefixes or suffixes):
170
+ continue
171
+ known = stem in lexicon
172
+ if fused and not known:
173
+ continue # fused forms are only trusted for words in the lexicon
174
+ if not (known or (strategy == "rules" and len(stem) >= min_stem)):
175
+ continue
176
+ if known:
177
+ key: tuple[int, ...] = (0, len(prefixes) + len(suffixes), -len(stem))
178
+ else:
179
+ key = (1, len(prefixes), -len(suffixes), len(stem))
180
+ if best_key is None or key < best_key:
181
+ best_key, best = key, Analysis(word, prefixes, stem, suffixes)
182
+ return best or unsplit
@@ -0,0 +1,29 @@
1
+ """Optional orthographic normalisation for Amharic text.
2
+
3
+ Amharic has several groups of letters that are pronounced identically
4
+ (ሀ/ሐ/ኀ, ሰ/ሠ, አ/ዐ, ጸ/ፀ). Writers use them inconsistently, which fragments
5
+ vocabulary in NLP pipelines. :func:`normalize_homophones` collapses each
6
+ group to one representative while preserving the vowel order, and it never
7
+ changes the length of the text (so token offsets stay valid).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ # source block start -> representative block start (7 vowel orders each)
12
+ _BLOCKS = {
13
+ 0x1210: 0x1200, # ሐ -> ሀ
14
+ 0x1280: 0x1200, # ኀ -> ሀ
15
+ 0x1220: 0x1230, # ሠ -> ሰ
16
+ 0x12D0: 0x12A0, # ዐ -> አ
17
+ 0x1340: 0x1338, # ፀ -> ጸ
18
+ }
19
+
20
+ _TABLE = {
21
+ src + i: dst + i
22
+ for src, dst in _BLOCKS.items()
23
+ for i in range(7)
24
+ }
25
+
26
+
27
+ def normalize_homophones(text: str) -> str:
28
+ """Collapse homophone letter families to a single representative."""
29
+ return text.translate(_TABLE)
File without changes
@@ -0,0 +1,309 @@
1
+ """Word, sentence and morpheme tokenization for Amharic."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from collections.abc import Iterable, Iterator
6
+ from functools import lru_cache
7
+ from importlib import resources
8
+ from pathlib import Path
9
+ from typing import NamedTuple
10
+
11
+ from .morphology import Analysis, analyze_word
12
+ from .normalize import normalize_homophones
13
+
14
+ _STRATEGIES = ("lexicon", "rules", "none")
15
+
16
+ # One alternation, tried in order. Every non-space character belongs to
17
+ # exactly one token, so nothing is ever silently dropped.
18
+ _TOKEN_RE = re.compile(
19
+ r"(?P<word>[\u1200-\u135A\u135D-\u135F\u1380-\u138F]+)"
20
+ r"|(?P<ethnum>[\u1369-\u137C]+)"
21
+ r"|(?P<latin>[A-Za-z\u00C0-\u024F]+(?:['\u2019\-][A-Za-z\u00C0-\u024F]+)*)"
22
+ r"|(?P<number>\d+(?:[.,]\d+)*)"
23
+ r"|(?P<punct>\u1361\u1361|[\u1360-\u1368]|[^\w\s])"
24
+ r"|(?P<other>\S)"
25
+ )
26
+ _KINDS = {
27
+ "word": "word",
28
+ "ethnum": "number",
29
+ "latin": "latin",
30
+ "number": "number",
31
+ "punct": "punct",
32
+ "other": "other",
33
+ }
34
+
35
+ # Sentence terminators: ። ፧ ፨ ! ? and the old-style double word-separator ፡፡
36
+ _TERMINATOR_RE = re.compile(r"(?:\u1361\u1361|[\u1362\u1367\u1368!?]+)[\u00BB\u201D\"')\]]*")
37
+
38
+
39
+ class Token(NamedTuple):
40
+ """A token with its character offsets in the (normalised) input text."""
41
+
42
+ text: str
43
+ start: int
44
+ end: int
45
+ kind: str # "word", "latin", "number", "punct" or "other"
46
+
47
+
48
+ def _parse_lexicon(text: str) -> Iterator[str]:
49
+ for line in text.splitlines():
50
+ line = line.strip()
51
+ if line and not line.startswith("#"):
52
+ yield line
53
+
54
+
55
+ @lru_cache(maxsize=1)
56
+ def load_irregular_plurals() -> tuple[tuple[str, str], ...]:
57
+ """Load (singular, plural) pairs of irregular plurals shipped with the package."""
58
+ resource = resources.files("amharic_segmenter") / "data" / "irregular_plurals.tsv"
59
+ pairs = []
60
+ for line in _parse_lexicon(resource.read_text(encoding="utf-8")):
61
+ parts = line.split("\t")
62
+ if len(parts) == 2:
63
+ pairs.append((parts[0].strip(), parts[1].strip()))
64
+ return tuple(pairs)
65
+
66
+
67
+ @lru_cache(maxsize=1)
68
+ def load_default_lexicon() -> frozenset:
69
+ """Load the seed lexicon (plus irregular plural forms) shipped with the package."""
70
+ resource = resources.files("amharic_segmenter") / "data" / "lexicon.txt"
71
+ words = set(_parse_lexicon(resource.read_text(encoding="utf-8")))
72
+ for singular, plural in load_irregular_plurals():
73
+ words.update((singular, plural))
74
+ return frozenset(words)
75
+
76
+
77
+ class AmharicTokenizer:
78
+ """Configurable Amharic tokenizer.
79
+
80
+ Parameters
81
+ ----------
82
+ lexicon:
83
+ Extra stems (an iterable of words) that are allowed to be split off.
84
+ default_lexicon:
85
+ Include the seed lexicon that ships with the package.
86
+ strategy:
87
+ ``"lexicon"`` (default, high precision), ``"rules"`` (higher recall) or
88
+ ``"none"`` (never split morphemes).
89
+ normalize:
90
+ Collapse homophone letters (ሐ->ሀ, ሠ->ሰ, ዐ->አ, ፀ->ጸ) before tokenizing.
91
+ min_stem:
92
+ Minimum stem length (in letters) for the ``"rules"`` strategy.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ lexicon: Iterable[str] | None = None,
98
+ *,
99
+ default_lexicon: bool = True,
100
+ strategy: str = "lexicon",
101
+ normalize: bool = False,
102
+ min_stem: int = 2,
103
+ ) -> None:
104
+ if strategy not in _STRATEGIES:
105
+ raise ValueError(f"strategy must be one of {_STRATEGIES}, got {strategy!r}")
106
+ self.strategy = strategy
107
+ self.normalize = normalize
108
+ self.min_stem = min_stem
109
+ words = set(load_default_lexicon()) if default_lexicon else set()
110
+ if lexicon is not None:
111
+ words.update(w.strip() for w in lexicon if w.strip())
112
+ self._lexicon = frozenset(self._prep(w) for w in words)
113
+ self._irregular: dict[str, str] = (
114
+ {self._prep(pl): self._prep(sg) for sg, pl in load_irregular_plurals()}
115
+ if default_lexicon else {}
116
+ )
117
+ self._cache: dict[str, Analysis] = {}
118
+ self._rules_cache: dict[str, Analysis] = {}
119
+
120
+ # -- helpers ---------------------------------------------------------
121
+ def _prep(self, text: str) -> str:
122
+ return normalize_homophones(text) if self.normalize else text
123
+
124
+ @classmethod
125
+ def from_file(cls, path: str | Path, **kwargs) -> AmharicTokenizer:
126
+ """Build a tokenizer with extra stems read from a UTF-8 file."""
127
+ words = list(_parse_lexicon(Path(path).read_text(encoding="utf-8")))
128
+ return cls(lexicon=words, **kwargs)
129
+
130
+ def add_words(self, words: Iterable[str]) -> None:
131
+ """Add stems to the lexicon of this tokenizer."""
132
+ self._lexicon = self._lexicon | {self._prep(w.strip()) for w in words if w.strip()}
133
+ self._cache.clear()
134
+ self._rules_cache.clear()
135
+
136
+ @property
137
+ def lexicon(self) -> frozenset:
138
+ return self._lexicon
139
+
140
+ # -- morphology ------------------------------------------------------
141
+ def analyze(self, word: str) -> Analysis:
142
+ """Segment one word into prefixes, stem and suffixes."""
143
+ word = self._prep(word)
144
+ cached = self._cache.get(word)
145
+ if cached is None:
146
+ cached = analyze_word(word, self._lexicon, self.strategy, self.min_stem)
147
+ self._cache[word] = cached
148
+ return cached
149
+
150
+ def split_morphemes(self, word: str) -> list[str]:
151
+ """Return the morphemes of one word, e.g. ``['ለ', 'ተማሪ', 'ዎች']``."""
152
+ return self.analyze(word).morphemes
153
+
154
+ def stem(self, word: str, *, aggressive: bool = True) -> str:
155
+ """Strip prefixes and suffixes and return the stem (like ``PorterStemmer.stem``).
156
+
157
+ Known words are segmented with the lexicon. With ``aggressive=True`` (the
158
+ default) a word that is not in the lexicon still loses its suffixes using
159
+ the grammar rules alone, so the stemmer works on any noun, as English
160
+ stemmers do. Use ``aggressive=False`` to leave unknown words untouched.
161
+ """
162
+ word = self._prep(word)
163
+ analysis = self.analyze(word)
164
+ if not analysis.is_split and aggressive and self.strategy == "lexicon":
165
+ analysis = self._rules_cache.get(word) # type: ignore[assignment]
166
+ if analysis is None:
167
+ analysis = analyze_word(word, self._lexicon, "rules", self.min_stem)
168
+ self._rules_cache[word] = analysis
169
+ return analysis.stem
170
+
171
+ def lemma(self, word: str) -> str:
172
+ """Dictionary form of the stem: ``መጻሕፍትን -> መጽሐፍ``, ``ተማሪዎች -> ተማሪ``."""
173
+ stem = self.analyze(word).stem
174
+ return self._irregular.get(stem, stem)
175
+
176
+ # -- tokenization ----------------------------------------------------
177
+ def tokenize_spans(self, text: str, *, include_punct: bool = True) -> list[Token]:
178
+ """Word-level tokens with offsets (into the normalised text)."""
179
+ text = self._prep(text)
180
+ tokens = []
181
+ for m in _TOKEN_RE.finditer(text):
182
+ kind = _KINDS[m.lastgroup] # type: ignore[index]
183
+ if kind == "punct" and not include_punct:
184
+ continue
185
+ tokens.append(Token(m.group(), m.start(), m.end(), kind))
186
+ return tokens
187
+
188
+ def tokenize(
189
+ self,
190
+ text: str,
191
+ *,
192
+ morphemes: bool = False,
193
+ include_punct: bool = True,
194
+ mark_affixes: bool = False,
195
+ ) -> list[str]:
196
+ """Split text into tokens.
197
+
198
+ With ``morphemes=True`` every Amharic word is further split into its
199
+ prefixes, stem and suffixes. ``mark_affixes=True`` writes prefixes as
200
+ ``በ+`` and suffixes as ``+ን`` so the two can be told apart.
201
+ """
202
+ out: list[str] = []
203
+ for tok in self.tokenize_spans(text, include_punct=include_punct):
204
+ if morphemes and tok.kind == "word":
205
+ a = self.analyze(tok.text)
206
+ if mark_affixes:
207
+ out.extend(p + "+" for p in a.prefixes)
208
+ out.append(a.stem)
209
+ out.extend("+" + s for s in a.suffixes)
210
+ else:
211
+ out.extend(a.morphemes)
212
+ else:
213
+ out.append(tok.text)
214
+ return out
215
+
216
+ def sentence_tokenize(self, text: str) -> list[str]:
217
+ """Split text into sentences on ። ፧ ፨ ! ? and ፡፡."""
218
+ sentences: list[str] = []
219
+ start = 0
220
+ for m in _TERMINATOR_RE.finditer(text):
221
+ end = m.end()
222
+ if end == len(text) or text[end].isspace():
223
+ chunk = text[start:end].strip()
224
+ if chunk:
225
+ sentences.append(chunk)
226
+ start = end
227
+ tail = text[start:].strip()
228
+ if tail:
229
+ sentences.append(tail)
230
+ return sentences
231
+
232
+
233
+ class AmharicStemmer:
234
+ """Stemmer with the familiar NLTK shape: ``AmharicStemmer().stem("ተማሪዎችን")``."""
235
+
236
+ def __init__(self, tokenizer: AmharicTokenizer | None = None, *, aggressive: bool = True) -> None:
237
+ self.tokenizer = tokenizer or _default_tokenizer()
238
+ self.aggressive = aggressive
239
+
240
+ def stem(self, word: str) -> str:
241
+ """Return the stem of one word."""
242
+ return self.tokenizer.stem(word, aggressive=self.aggressive)
243
+
244
+ def stem_words(self, words: Iterable[str]) -> list[str]:
245
+ """Return the stems of many words."""
246
+ return [self.stem(w) for w in words]
247
+
248
+
249
+ @lru_cache(maxsize=1)
250
+ def _default_tokenizer() -> AmharicTokenizer:
251
+ return AmharicTokenizer()
252
+
253
+
254
+ def word_tokenize(text: str) -> list[str]:
255
+ """Split text into words and punctuation (like ``nltk.word_tokenize``)."""
256
+ return _default_tokenizer().tokenize(text)
257
+
258
+
259
+ def sent_tokenize(text: str) -> list[str]:
260
+ """Split text into sentences (like ``nltk.sent_tokenize``)."""
261
+ return _default_tokenizer().sentence_tokenize(text)
262
+
263
+
264
+ def stem(word: str, *, aggressive: bool = True) -> str:
265
+ """Stem of one word, using the default tokenizer."""
266
+ return _default_tokenizer().stem(word, aggressive=aggressive)
267
+
268
+
269
+ def stem_words(words: Iterable[str], *, aggressive: bool = True) -> list[str]:
270
+ """Stems of many words, using the default tokenizer."""
271
+ return [stem(w, aggressive=aggressive) for w in words]
272
+
273
+
274
+ def tokenize(
275
+ text: str,
276
+ *,
277
+ morphemes: bool = False,
278
+ include_punct: bool = True,
279
+ mark_affixes: bool = False,
280
+ ) -> list[str]:
281
+ """Tokenize ``text`` with the default tokenizer."""
282
+ return _default_tokenizer().tokenize(
283
+ text, morphemes=morphemes, include_punct=include_punct, mark_affixes=mark_affixes
284
+ )
285
+
286
+
287
+ def tokenize_spans(text: str, *, include_punct: bool = True) -> list[Token]:
288
+ """Tokens with offsets, using the default tokenizer."""
289
+ return _default_tokenizer().tokenize_spans(text, include_punct=include_punct)
290
+
291
+
292
+ def sentence_tokenize(text: str) -> list[str]:
293
+ """Split ``text`` into sentences with the default tokenizer."""
294
+ return _default_tokenizer().sentence_tokenize(text)
295
+
296
+
297
+ def analyze(word: str) -> Analysis:
298
+ """Morphological analysis of a single word."""
299
+ return _default_tokenizer().analyze(word)
300
+
301
+
302
+ def split_morphemes(word: str) -> list[str]:
303
+ """Morphemes of a single word, using the default tokenizer."""
304
+ return _default_tokenizer().split_morphemes(word)
305
+
306
+
307
+ def lemma(word: str) -> str:
308
+ """Dictionary form of the stem of ``word``, using the default tokenizer."""
309
+ return _default_tokenizer().lemma(word)
@@ -0,0 +1,340 @@
1
+ Metadata-Version: 2.5
2
+ Name: amharic-segmenter
3
+ Version: 0.1.0
4
+ Summary: Sentence/word tokenization, morpheme splitting and stemming for Amharic (አማርኛ), NLTK-style and dependency-free.
5
+ Project-URL: Homepage, https://github.com/Alki45/amharic-segmenter
6
+ Project-URL: Documentation, https://github.com/Alki45/amharic-segmenter#readme
7
+ Project-URL: Issues, https://github.com/Alki45/amharic-segmenter/issues
8
+ Project-URL: Changelog, https://github.com/Alki45/amharic-segmenter/blob/main/CHANGELOG.md
9
+ Author-email: Ali Kibret Muhamed <alikibretmuhamed@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: amharic,ethiopic,ge'ez,low-resource,morphology,nlp,stemmer,stemming,tokenizer,አማርኛ
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Text Processing :: Linguistic
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Provides-Extra: dev
28
+ Requires-Dist: build; extra == 'dev'
29
+ Requires-Dist: mypy; extra == 'dev'
30
+ Requires-Dist: pytest-cov; extra == 'dev'
31
+ Requires-Dist: pytest>=7; extra == 'dev'
32
+ Requires-Dist: ruff; extra == 'dev'
33
+ Requires-Dist: twine; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ <div align="center">
37
+
38
+ # amharic-segmenter
39
+
40
+ **Sentence and word tokenization, morpheme splitting and stemming for Amharic (አማርኛ)**
41
+ as easy as English NLP, built on real grammar rules, with zero dependencies.
42
+
43
+ [![PyPI](https://img.shields.io/pypi/v/amharic-segmenter.svg)](https://pypi.org/project/amharic-segmenter/)
44
+ [![Python](https://img.shields.io/pypi/pyversions/amharic-segmenter.svg)](https://pypi.org/project/amharic-segmenter/)
45
+ [![CI](https://github.com/Alki45/amharic-segmenter/actions/workflows/ci.yml/badge.svg)](https://github.com/Alki45/amharic-segmenter/actions/workflows/ci.yml)
46
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
47
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg)](https://github.com/astral-sh/ruff)
48
+
49
+ [Install](#installation) · [Quickstart](#quickstart) · [Features](#features) · [API](#api-reference) · [CLI](#command-line) · [How it works](#how-it-works) · [Contributing](#contributing)
50
+
51
+ </div>
52
+
53
+ > **በአማርኛ:** አማርኛ ጽሑፍን ወደ ዓረፍተ ነገሮችና ቃላት የሚከፋፍል፣ እንዲሁም ቃላትን ወደ ሞርፊሞች
54
+ > (`በ`፣ `ከ`፣ `ለ`፣ `ን`፣ `ና`፣ `ዎች`…) የሚሰብር ቀላል የፓይቶን ጥቅል። ለማንኛውም የአማርኛ ቋንቋ ፕሮጀክት ነጻ ነው።
55
+
56
+ ---
57
+
58
+ ## Why this exists
59
+
60
+ General-purpose tokenizers split on whitespace or learn subwords from data. For Amharic that
61
+ leaves two problems:
62
+
63
+ 1. **Ethiopic punctuation is not ASCII.** `።` (full stop), `፣` (comma), `፧` (question mark) and
64
+ the old word separator `፡` are missed or glued to words by generic tools.
65
+ 2. **Amharic is morphologically rich.** Prepositions, plural, possessives, the object marker
66
+ `ን` and conjunctions are written *attached* to the word, so `ለተማሪዎቻችን` ("for our
67
+ students") is one whitespace token but five units of meaning.
68
+
69
+ `amharic-segmenter` handles both with transparent, documented rules taken from an Amharic
70
+ grammar book, so results are explainable and easy to improve.
71
+
72
+ ## Features
73
+
74
+ - **NLTK-style API**: `sent_tokenize`, `word_tokenize`, `stem`, `AmharicStemmer`. Works right
75
+ after `pip install`: no model downloads, no setup
76
+ - **Stemming** that strips prefixes and suffixes, also for words it has never seen
77
+ - **Sentence splitting** on `።`, `፧`, `፨`, `!`, `?` and the old-style `፡፡`
78
+ - **Word tokenization** that understands Ethiopic letters, punctuation and numerals
79
+ (`፩፻`), plus Latin text and decimals, and **never drops a character**
80
+ - **Morpheme segmentation** of nouns and noun phrases: prepositions `በ ከ ለ የ ስለ`, plural
81
+ (`ች`, `ዎች`), definite article, possessive suffixes, object marker `ን`, conjunctions `ና`/`ም`
82
+ - **Precision-first design:** words are only split when the stem is known, so names like
83
+ `ከበደ` and words like `ወንድም` stay intact. An opt-in `rules` mode trades precision for recall
84
+ - Handles **pronoun contractions** (`በኔ → በ + እኔ`), **fused feminine forms** (`ሐኪሟ → ሐኪም + ዋ`)
85
+ and **irregular plurals** (`መጻሕፍትን`, `ቃላቶች`), with `lemma()` to get the dictionary form
86
+ - **Token offsets** for aligning tokens with the original text
87
+ - **Optional normalization** of homophone letters (`ሐ→ሀ`, `ሠ→ሰ`, `ዐ→አ`, `ፀ→ጸ`)
88
+ - **Extensible lexicon**: bring your own words, no retraining needed
89
+ - **Command line tool**, fully typed (`py.typed`), pure Python, **no dependencies**,
90
+ Python 3.9+
91
+
92
+ ## Installation
93
+
94
+ ```bash
95
+ pip install amharic-segmenter
96
+ ```
97
+
98
+ ## Quickstart
99
+
100
+ If you have used NLTK on English text, you already know how to use it. Install, import, call:
101
+
102
+ ```python
103
+ from amharic_segmenter import word_tokenize, sent_tokenize, stem
104
+
105
+ text = "ተማሪዎቹ መጻሕፍትን አነበቡ። ከበደና አበበ ለተማሪዎቻቸው ሰጡ።"
106
+
107
+ sent_tokenize(text)
108
+ # ['ተማሪዎቹ መጻሕፍትን አነበቡ።', 'ከበደና አበበ ለተማሪዎቻቸው ሰጡ።']
109
+
110
+ word_tokenize(text)
111
+ # ['ተማሪዎቹ', 'መጻሕፍትን', 'አነበቡ', '።', 'ከበደና', 'አበበ', 'ለተማሪዎቻቸው', 'ሰጡ', '።']
112
+
113
+ [stem(w) for w in word_tokenize(text)]
114
+ # ['ተማሪ', 'መጻሕፍት', 'አነበቡ', '።', 'ከበደ', 'አበበ', 'ተማሪ', 'ሰጡ', '።']
115
+ ```
116
+
117
+ Or with a stemmer object, the way you would use `PorterStemmer`:
118
+
119
+ ```python
120
+ from amharic_segmenter import AmharicStemmer
121
+
122
+ stemmer = AmharicStemmer()
123
+ stemmer.stem("ተማሪዎችን") # 'ተማሪ'
124
+ stemmer.stem_words(["ተማሪዎች", "ላሞች"]) # ['ተማሪ', 'ላም']
125
+ ```
126
+
127
+ | English (NLTK) | Amharic (this package) |
128
+ |----------------|------------------------|
129
+ | `nltk.sent_tokenize(text)` | `sent_tokenize(text)` |
130
+ | `nltk.word_tokenize(text)` | `word_tokenize(text)` |
131
+ | `PorterStemmer().stem(word)` | `AmharicStemmer().stem(word)` or `stem(word)` |
132
+ | `nltk.download("punkt")` | **nothing to download**: rules and data ship inside the package |
133
+
134
+ ### More examples
135
+
136
+ ```python
137
+ from amharic_segmenter import tokenize, sentence_tokenize
138
+
139
+ text = "ተማሪዎቹ በከተማ ውስጥ ይኖራሉ። ከበደና አበበ ለተማሪዎቻችን መጽሐፎችን ሰጡ።"
140
+
141
+ sentence_tokenize(text)
142
+ # ['ተማሪዎቹ በከተማ ውስጥ ይኖራሉ።', 'ከበደና አበበ ለተማሪዎቻችን መጽሐፎችን ሰጡ።']
143
+
144
+ tokenize(text)
145
+ # ['ተማሪዎቹ', 'በከተማ', 'ውስጥ', 'ይኖራሉ', '።', 'ከበደና', 'አበበ', 'ለተማሪዎቻችን', 'መጽሐፎችን', 'ሰጡ', '።']
146
+
147
+ tokenize(text, morphemes=True)
148
+ # ['ተማሪ', 'ዎች', 'ው', 'በ', 'ከተማ', 'ውስጥ', 'ይኖራሉ', '።', 'ከበደ', 'ና', 'አበበ',
149
+ # 'ለ', 'ተማሪ', 'ዎች', 'ችን', 'መጽሐፍ', 'ች', 'ን', 'ሰጡ', '።']
150
+
151
+ tokenize(text, morphemes=True, mark_affixes=True)
152
+ # ['ተማሪ', '+ዎች', '+ው', 'በ+', 'ከተማ', 'ውስጥ', 'ይኖራሉ', '።', 'ከበደ', '+ና', 'አበበ',
153
+ # 'ለ+', 'ተማሪ', '+ዎች', '+ችን', 'መጽሐፍ', '+ች', '+ን', 'ሰጡ', '።']
154
+ ```
155
+
156
+ Analyse a single word:
157
+
158
+ ```python
159
+ from amharic_segmenter import analyze
160
+
161
+ analyze("ለተማሪዎቻችን")
162
+ # Analysis(word='ለተማሪዎቻችን', prefixes=('ለ',), stem='ተማሪ', suffixes=('ዎች', 'ችን'))
163
+ ```
164
+
165
+ Token offsets:
166
+
167
+ ```python
168
+ from amharic_segmenter import tokenize_spans
169
+
170
+ tokenize_spans("ከበደን አየ።")
171
+ # [Token(text='ከበደን', start=0, end=4, kind='word'),
172
+ # Token(text='አየ', start=5, end=7, kind='word'),
173
+ # Token(text='።', start=7, end=8, kind='punct')]
174
+ ```
175
+
176
+ ## Command line
177
+
178
+ ```console
179
+ $ amharic-segmenter -m "ለተማሪዎቻችን መጽሐፎችን ሰጡ።"
180
+ ለ ተማሪ ዎች ችን መጽሐፍ ች ን ሰጡ ።
181
+
182
+ $ amharic-segmenter --sentences --json "አበበ መጣ። ከበደ ሄደ።"
183
+ ["አበበ መጣ።", "ከበደ ሄደ።"]
184
+
185
+ $ amharic-segmenter --stem "ተማሪዎቹ መጻሕፍትን አነበቡ።"
186
+ ተማሪ መጻሕፍት አነበቡ ።
187
+
188
+ $ echo "ተማሪውንም አየሁ።" | amharic-segmenter -m --mark-affixes
189
+ ተማሪ +ው +ን +ም አየሁ ።
190
+ ```
191
+
192
+ Run `amharic-segmenter --help` for all options (`--strategy`, `--lexicon`, `--normalize`,
193
+ `--no-punct`, `--json`). `python -m amharic_segmenter` works too.
194
+
195
+ ## API reference
196
+
197
+ ### Module-level functions
198
+
199
+ | Function | Returns | Description |
200
+ |----------|---------|-------------|
201
+ | `sent_tokenize(text)` | `list[str]` | Sentences (NLTK-style name for `sentence_tokenize`) |
202
+ | `word_tokenize(text)` | `list[str]` | Words and punctuation (NLTK-style, same as `tokenize(text)`) |
203
+ | `stem(word, *, aggressive=True)` | `str` | Stem of one word. `aggressive=False` leaves words outside the lexicon alone |
204
+ | `stem_words(words)` | `list[str]` | Stems of many words |
205
+ | `AmharicStemmer().stem(word)` | `str` | Stemmer object, `.stem()` and `.stem_words()` |
206
+ | `tokenize(text, *, morphemes=False, include_punct=True, mark_affixes=False)` | `list[str]` | Split text into tokens, optionally down to morphemes |
207
+ | `tokenize_spans(text, *, include_punct=True)` | `list[Token]` | Word-level tokens with `start`/`end` offsets and `kind` |
208
+ | `sentence_tokenize(text)` | `list[str]` | Split text into sentences |
209
+ | `analyze(word)` | `Analysis` | Prefixes, stem and suffixes of one word |
210
+ | `split_morphemes(word)` | `list[str]` | Morphemes of one word |
211
+ | `lemma(word)` | `str` | Dictionary form of the stem (`መጻሕፍትን → መጽሐፍ`) |
212
+ | `normalize_homophones(text)` | `str` | Collapse `ሐ ኀ → ሀ`, `ሠ → ሰ`, `ዐ → አ`, `ፀ → ጸ` (length-preserving) |
213
+
214
+ ### `AmharicTokenizer`
215
+
216
+ ```python
217
+ from amharic_segmenter import AmharicTokenizer
218
+
219
+ tok = AmharicTokenizer(
220
+ lexicon=["መምህር", "ጓደኛ"], # extra stems that may be split off
221
+ default_lexicon=True, # include the built-in seed lexicon
222
+ strategy="lexicon", # "lexicon" | "rules" | "none"
223
+ normalize=False, # collapse homophone letters first
224
+ )
225
+
226
+ tok.tokenize("መምህራችንን አየሁ።", morphemes=True)
227
+ tok.add_words(["ጋዜጣ"])
228
+ tok = AmharicTokenizer.from_file("my_words.txt") # one word per line, '#' comments
229
+ ```
230
+
231
+ | `strategy` | Behaviour |
232
+ |------------|-----------|
233
+ | `"lexicon"` *(default)* | Accept a split only if the stem is in the lexicon. High precision. |
234
+ | `"rules"` | Also accept unknown stems of at least `min_stem` letters. Higher recall, more false splits. |
235
+ | `"none"` | Never split words into morphemes. |
236
+
237
+ ### `Analysis` and `Token`
238
+
239
+ - `Analysis(word, prefixes, stem, suffixes)` with `.morphemes` and `.is_split`
240
+ - `Token(text, start, end, kind)` where `kind` is `word`, `latin`, `number`, `punct` or `other`
241
+
242
+ > **Note:** morphemes are *underlying forms*. `ልጆች` becomes `ልጅ + ች` because the plural rule
243
+ > changes the stem's last letter, so joining the morphemes does not always give back the
244
+ > surface word. Use `tokenize_spans` when you need exact offsets.
245
+
246
+ ## How it works
247
+
248
+ ```
249
+ raw text
250
+ │
251
+ ▼
252
+ ┌──────────────────────────┐ Layer 1: rule-based, always on
253
+ │ sentences and word tokens │ Ethiopic punctuation, numerals, Latin, no loss
254
+ └─────────────┬────────────┘
255
+ │ (optional, morphemes=True)
256
+ ▼
257
+ ┌──────────────────────────┐ Layer 2: morphology
258
+ │ generate candidate splits │ prefixes ← stem → plural → possessive → ን → ና/ም
259
+ │ accept if stem is known │ lexicon check keeps precision high
260
+ └──────────────────────────┘
261
+ ```
262
+
263
+ The morphology layer works on the structure of the Ethiopic script itself: every letter has
264
+ seven vowel *orders*, and the plural and possessive rules are defined in terms of them
265
+ (`ሰው → ሰዎች`, `አገር → አገራችሁ`). The rules are listed with book references in
266
+ [`docs/GRAMMAR_RULES.md`](docs/GRAMMAR_RULES.md).
267
+
268
+ The rules are based on the grammar book *አማርኛ ሠዋሰው* (1963 E.C.).
269
+
270
+ ## How it relates to other Amharic tools
271
+
272
+ | Project (PyPI) | Approach | Good for |
273
+ |----------------|----------|----------|
274
+ | **`amharic-segmenter`** (this package) | Grammar rules from a printed grammar book, NLTK-style API | Sentence and word splitting, morpheme splitting, stemming, explainable output |
275
+ | `amharic-tokenizer` | BPE-like merges learned from a corpus | Subword vocabularies for training language models |
276
+ | `amseg` | Rule-based sentence and word splitter, with normalizer and romanizer | Sentence/word splitting and transliteration |
277
+
278
+ They solve different problems and can be used together, for example `amharic-segmenter` for
279
+ sentences and stems and a trained BPE model for model vocabularies.
280
+
281
+ ## Current scope and limitations
282
+
283
+ This is an early release (`0.1`, alpha). Please read this before using it in production:
284
+
285
+ - Only **printed pages 7-110** of the source book have been used so far (chapters 1-5 and the
286
+ start of chapter 6; the punctuation chapter is still missing). See [`docs/BOOK_COVERAGE.md`](docs/BOOK_COVERAGE.md) for exactly what is
287
+ and is not covered, e.g. the book's chapter on punctuation.
288
+ - Stemming is a **noun-oriented** suffix stripper, like a light English stemmer. Verbs are
289
+ left alone rather than guessed. Prefixes (`በ ከ ለ የ`) are only stripped when the rest is a
290
+ known word, so unknown words lose suffixes only. For unknown nouns it can pick the wrong stem when the
291
+ last letter is ambiguous (`ጋዜጦች` could come from `ጋዜጣ` or `ጋዜጥ`).
292
+ - Morphology covers **nouns and noun phrases**. **Verb morphology is not implemented yet.**
293
+ - The built-in lexicon is a small seed. Unknown stems are left unsplit in the default mode,
294
+ so coverage grows as the lexicon grows. Add your own words or send a pull request.
295
+ - Some forms are ambiguous without context (a final `ም` can be a conjunction or part of the
296
+ word, as in `ወንድም`). The lexicon resolves most cases, not all.
297
+ - Rules reflect one grammar book. Modern usage and dialects may differ; native-speaker
298
+ review is very welcome.
299
+
300
+ ## Roadmap
301
+
302
+ - [ ] Punctuation rules from chapter 7 of the source book (pages still to be added)
303
+ - [ ] Verb morphology (agreement, tense, negation, relative `የ…`) from an additional source
304
+ - [ ] Larger open lexicon and a test corpus of annotated sentences
305
+ - [ ] Reduplicated plurals and internal plurals as rules
306
+ - [ ] Subword-vocabulary export for use with ML tokenizers
307
+ - [ ] Evaluation scripts against annotated Amharic data
308
+
309
+ ## Contributing
310
+
311
+ Contributions are very welcome, especially from Amharic speakers and linguists. The easiest
312
+ first contribution is adding words to
313
+ [`src/amharic_segmenter/data/lexicon.txt`](src/amharic_segmenter/data/lexicon.txt).
314
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, rules for adding grammar rules, and the pull
315
+ request checklist.
316
+
317
+ ```bash
318
+ git clone https://github.com/Alki45/amharic-segmenter.git
319
+ cd amharic-segmenter
320
+ pip install -e ".[dev]"
321
+ pytest
322
+ ```
323
+
324
+ ## Citation
325
+
326
+ If you use this package in research, please cite it:
327
+
328
+ ```bibtex
329
+ @software{amharic_segmenter,
330
+ title = {amharic-segmenter: Sentence, word and morpheme tokenization for Amharic},
331
+ author = {Ali Kibret Muhamed},
332
+ year = {2026},
333
+ url = {https://github.com/Alki45/amharic-segmenter}
334
+ }
335
+ ```
336
+
337
+ ## License
338
+
339
+ [MIT](LICENSE). The grammar rules are re-implemented in original code; the source book is
340
+ credited above and its text is not redistributed.
@@ -0,0 +1,16 @@
1
+ amharic_segmenter/__init__.py,sha256=UqStY6cNNXRM2t0atM8qoEupn_o8BGR-qchBzJmxWr4,850
2
+ amharic_segmenter/__main__.py,sha256=SCuGk-L-bb1zLj15YeA4J9PvoYe60R0p2hiL5YDrvuo,51
3
+ amharic_segmenter/_version.py,sha256=QTYqXqSTHFRkM9TEgpDFcHvwLbvqHDqvqfQ9EiXkcAM,23
4
+ amharic_segmenter/charset.py,sha256=zhYpKJ-yig3jiXFbX6ic1SYFvpVzDCqXqxvSXt2vHxg,2382
5
+ amharic_segmenter/cli.py,sha256=l4n67KIbSr-hpnCgbml8wWIbF0EgsMeVqTDcrmstAAE,2936
6
+ amharic_segmenter/morphology.py,sha256=Fv3xiYXKC4NojZANYMayjV0LhUAvC6C1paN6UIvxTrg,7960
7
+ amharic_segmenter/normalize.py,sha256=TQuBio21vLFFmeZ4Kr82ykGOnxa0E5NDvxeT_8_Lk7c,1003
8
+ amharic_segmenter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ amharic_segmenter/tokenizer.py,sha256=8SrmHWZRQQkaopCGGAnF_U3YP_zsNd-mNjbAg-hyIvQ,11755
10
+ amharic_segmenter/data/irregular_plurals.tsv,sha256=WEXdYZX2FF5tO41iGOr-n7ozDC1JtMdO6wYe0OxFjHw,1434
11
+ amharic_segmenter/data/lexicon.txt,sha256=-jALh2Ot71drJJi_4zKhu0wtJn-7I2DVRlbfpt6go04,1706
12
+ amharic_segmenter-0.1.0.dist-info/METADATA,sha256=FGxFHPEgqK4nExtTWkZSyqWe3nBDxjOy5qsRG5LIA_k,16280
13
+ amharic_segmenter-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ amharic_segmenter-0.1.0.dist-info/entry_points.txt,sha256=hm-5Ntu_VCNDAgp71dImtTZ6i_vq-oU4TAvx37rvhPw,65
15
+ amharic_segmenter-0.1.0.dist-info/licenses/LICENSE,sha256=FLUUa47oUr2WLIMBokbK5o4eqmPD_P5pJqrBm193irg,1096
16
+ amharic_segmenter-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ amharic-segmenter = amharic_segmenter.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Kibret Muhamed
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 deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ 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 all
13
+ 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 FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.