naijanorm 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ name: tests
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python-version }}
14
+ - run: python -m pip install --upgrade pip
15
+ - run: pip install -e ".[dev]"
16
+ - run: pytest
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+ - Initial five-language cardinal number profiles.
5
+ - Nigerian currency, phone, shortcode, percentage, decimal and unit rules.
6
+ - Batch API and normalization event output.
7
+ - Configurable Yoruba traditional Oyo / modern decimal profile.
8
+ - Test and packaging scaffold.
9
+ - Explicitly documents linguistic and contextual limitations.
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NaijaNorm contributors
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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: naijanorm
3
+ Version: 1.0.0
4
+ Summary: Nigerian-context text normalization for speech and language AI
5
+ Project-URL: Homepage, https://github.com/YOUR-ORG/naijanorm
6
+ Project-URL: Issues, https://github.com/YOUR-ORG/naijanorm/issues
7
+ Author: NaijaNorm contributors
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 NaijaNorm contributors
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
25
+ License-File: LICENSE
26
+ Keywords: ASR,Hausa,Igbo,Nigerian languages,Pidgin,TTS,Yoruba,text normalization
27
+ Classifier: Development Status :: 3 - Alpha
28
+ Classifier: Intended Audience :: Developers
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.9
32
+ Classifier: Programming Language :: Python :: 3.10
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Classifier: Topic :: Text Processing :: Linguistic
36
+ Requires-Python: >=3.9
37
+ Provides-Extra: dev
38
+ Requires-Dist: build>=1.2; extra == 'dev'
39
+ Requires-Dist: pytest>=8.0; extra == 'dev'
40
+ Requires-Dist: ruff>=0.5; extra == 'dev'
41
+ Provides-Extra: excel
42
+ Requires-Dist: openpyxl>=3.1; extra == 'excel'
43
+ Requires-Dist: pandas>=2.0; extra == 'excel'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # NaijaNorm
47
+
48
+ Nigerian-context text normalization for ASR, TTS, NLP and conversational AI.
49
+
50
+ > **Release note:** NaijaNorm 1.0.0 provides working baseline cardinal number generation
51
+ > for Nigerian English, Nigerian Pidgin, Yoruba, Hausa and Igbo, plus context rules for
52
+ > common numeric forms. It is an alpha-quality linguistic baseline, not a claim that every
53
+ > regional form, ordinal, date convention or spoken variant has been exhaustively validated.
54
+ > Before production dataset generation, run native-speaker review and add gold references.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install naijanorm
60
+ ```
61
+
62
+ For local development:
63
+
64
+ ```bash
65
+ pip install -e ".[dev]"
66
+ pytest
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ ```python
72
+ from naijanorm import NaijaNorm
73
+
74
+ normalizer = NaijaNorm(language="en-NG")
75
+ print(normalizer.normalize("Pay ₦5,000 for 250MB and dial *310#"))
76
+ ```
77
+
78
+ ## Five language profiles
79
+
80
+ ```python
81
+ from naijanorm import NaijaNorm
82
+
83
+ for language in ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG"):
84
+ n = NaijaNorm(language=language)
85
+ print(language, n.normalize("123"))
86
+ ```
87
+
88
+ Language identifiers:
89
+ - `en-NG`: Nigerian English
90
+ - `pcm-NG`: Nigerian Pidgin (practical baseline; spelling is not fully standardized)
91
+ - `yo-NG`: Yoruba, Standard Oyo traditional counting by default
92
+ - `ha-NG`: Hausa, standard Boko baseline
93
+ - `ig-NG`: Igbo, standard orthographic baseline
94
+
95
+ ## Yoruba counting profile
96
+
97
+ Traditional Standard Oyo is selected by default:
98
+
99
+ ```python
100
+ yoruba = NaijaNorm("yo-NG", yoruba_system="oyo-traditional")
101
+ print(yoruba.normalize("16")) # ẹ̀rìndínlógún
102
+ ```
103
+
104
+ For modern decimal/additive reading:
105
+
106
+ ```python
107
+ yoruba_modern = NaijaNorm("yo-NG", yoruba_system="modern-decimal")
108
+ ```
109
+
110
+ Yoruba numeral forms vary by region, register and context. Traditional forms are not always the form speakers choose when reading a phone number, price, mathematical expression or serial number. Use category-aware speech policy and native-speaker validation.
111
+
112
+ ## Context examples
113
+
114
+ ```python
115
+ n = NaijaNorm("en-NG")
116
+ samples = [
117
+ "250MB",
118
+ "₦5,000",
119
+ "08012345678",
120
+ "25th",
121
+ "3.5GB",
122
+ "*310#",
123
+ "75%",
124
+ ]
125
+ for sample in samples:
126
+ print(sample, "=>", n.normalize(sample))
127
+ ```
128
+
129
+ ## Events and audit trail
130
+
131
+ ```python
132
+ result = n.normalize_with_events("₦5,000 and 250MB")
133
+ print(result.text)
134
+ for event in result.events:
135
+ print(event.original, event.normalized, event.category)
136
+ ```
137
+
138
+ ## Select categories
139
+
140
+ ```python
141
+ n = NaijaNorm("en-NG", categories=frozenset({"currency", "telephone"}))
142
+ ```
143
+
144
+ Or:
145
+
146
+ ```python
147
+ n.normalize("₦5,000 and 250MB", categories=["currency"])
148
+ ```
149
+
150
+ ## Batch processing
151
+
152
+ ```python
153
+ sentences = ["I bought 3GB", "Send ₦2,500", "Call 08012345678"]
154
+ normalized = n.normalize_batch(sentences)
155
+ ```
156
+
157
+ ## Important limitations and safe use
158
+
159
+ 1. **Ambiguity:** A bare `2026` may be a year, quantity, identifier or model number. The default treats bare numbers as cardinals. Use protected tokens or category controls when identifiers must remain unchanged.
160
+ 2. **Ordinals:** English ordinal suffixes are supported. The other languages currently return a cardinal plus a review note; full ordinal morphology requires language-specific validated rules.
161
+ 3. **Yoruba:** The Oyo profile includes a traditional vigesimal strategy and common forms, but compounds above the well-attested table should be reviewed. Do not assume one form is universal across Yoruba dialects.
162
+ 4. **Hausa and Igbo:** The included algorithms are practical standard profiles. Regional variation and alternative counting conventions require separate tested lexicons.
163
+ 5. **Nigerian Pidgin:** Orthography is not uniformly standardized. The package uses one consistent practical spelling profile; projects should publish their chosen style guide.
164
+ 6. **Dates and times:** Automatic date/time expansion is deliberately not enabled in this first baseline because numeric date order is ambiguous (DD/MM vs MM/DD), and speech readings depend on context.
165
+ 7. **Phone numbers:** Recognizes common Nigerian 11-digit local numbers and +234 formats. It does not validate allocation, carrier or whether a number is active.
166
+ 8. **Currencies:** Currency symbol expansion is supported; currency codes and complex mixed-currency accounting expressions need explicit project rules.
167
+ 9. **Units:** Common metric and telecom units are supported. Unit case is normalized for matching; abbreviations embedded in words are not intentionally expanded.
168
+
169
+ Do not use the default pipeline blindly on OTPs, bank account numbers, NINs, BVNs, transaction references, postal codes, coordinates, URLs or product identifiers. These need explicit token classes and project policies.
170
+
171
+ ## Contributing linguistic data
172
+
173
+ Every language rule should have:
174
+ - a language and dialect/standard label;
175
+ - a source or native-speaker reviewer;
176
+ - examples with expected output;
177
+ - Unicode NFC tests;
178
+ - tests for numbers around every scale boundary;
179
+ - tests for code-switching and preservation.
180
+
181
+ Please submit corrections with audio or a clear orthographic convention where possible.
182
+
183
+ ## License
184
+
185
+ MIT. See `LICENSE`.
@@ -0,0 +1,140 @@
1
+ # NaijaNorm
2
+
3
+ Nigerian-context text normalization for ASR, TTS, NLP and conversational AI.
4
+
5
+ > **Release note:** NaijaNorm 1.0.0 provides working baseline cardinal number generation
6
+ > for Nigerian English, Nigerian Pidgin, Yoruba, Hausa and Igbo, plus context rules for
7
+ > common numeric forms. It is an alpha-quality linguistic baseline, not a claim that every
8
+ > regional form, ordinal, date convention or spoken variant has been exhaustively validated.
9
+ > Before production dataset generation, run native-speaker review and add gold references.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install naijanorm
15
+ ```
16
+
17
+ For local development:
18
+
19
+ ```bash
20
+ pip install -e ".[dev]"
21
+ pytest
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ from naijanorm import NaijaNorm
28
+
29
+ normalizer = NaijaNorm(language="en-NG")
30
+ print(normalizer.normalize("Pay ₦5,000 for 250MB and dial *310#"))
31
+ ```
32
+
33
+ ## Five language profiles
34
+
35
+ ```python
36
+ from naijanorm import NaijaNorm
37
+
38
+ for language in ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG"):
39
+ n = NaijaNorm(language=language)
40
+ print(language, n.normalize("123"))
41
+ ```
42
+
43
+ Language identifiers:
44
+ - `en-NG`: Nigerian English
45
+ - `pcm-NG`: Nigerian Pidgin (practical baseline; spelling is not fully standardized)
46
+ - `yo-NG`: Yoruba, Standard Oyo traditional counting by default
47
+ - `ha-NG`: Hausa, standard Boko baseline
48
+ - `ig-NG`: Igbo, standard orthographic baseline
49
+
50
+ ## Yoruba counting profile
51
+
52
+ Traditional Standard Oyo is selected by default:
53
+
54
+ ```python
55
+ yoruba = NaijaNorm("yo-NG", yoruba_system="oyo-traditional")
56
+ print(yoruba.normalize("16")) # ẹ̀rìndínlógún
57
+ ```
58
+
59
+ For modern decimal/additive reading:
60
+
61
+ ```python
62
+ yoruba_modern = NaijaNorm("yo-NG", yoruba_system="modern-decimal")
63
+ ```
64
+
65
+ Yoruba numeral forms vary by region, register and context. Traditional forms are not always the form speakers choose when reading a phone number, price, mathematical expression or serial number. Use category-aware speech policy and native-speaker validation.
66
+
67
+ ## Context examples
68
+
69
+ ```python
70
+ n = NaijaNorm("en-NG")
71
+ samples = [
72
+ "250MB",
73
+ "₦5,000",
74
+ "08012345678",
75
+ "25th",
76
+ "3.5GB",
77
+ "*310#",
78
+ "75%",
79
+ ]
80
+ for sample in samples:
81
+ print(sample, "=>", n.normalize(sample))
82
+ ```
83
+
84
+ ## Events and audit trail
85
+
86
+ ```python
87
+ result = n.normalize_with_events("₦5,000 and 250MB")
88
+ print(result.text)
89
+ for event in result.events:
90
+ print(event.original, event.normalized, event.category)
91
+ ```
92
+
93
+ ## Select categories
94
+
95
+ ```python
96
+ n = NaijaNorm("en-NG", categories=frozenset({"currency", "telephone"}))
97
+ ```
98
+
99
+ Or:
100
+
101
+ ```python
102
+ n.normalize("₦5,000 and 250MB", categories=["currency"])
103
+ ```
104
+
105
+ ## Batch processing
106
+
107
+ ```python
108
+ sentences = ["I bought 3GB", "Send ₦2,500", "Call 08012345678"]
109
+ normalized = n.normalize_batch(sentences)
110
+ ```
111
+
112
+ ## Important limitations and safe use
113
+
114
+ 1. **Ambiguity:** A bare `2026` may be a year, quantity, identifier or model number. The default treats bare numbers as cardinals. Use protected tokens or category controls when identifiers must remain unchanged.
115
+ 2. **Ordinals:** English ordinal suffixes are supported. The other languages currently return a cardinal plus a review note; full ordinal morphology requires language-specific validated rules.
116
+ 3. **Yoruba:** The Oyo profile includes a traditional vigesimal strategy and common forms, but compounds above the well-attested table should be reviewed. Do not assume one form is universal across Yoruba dialects.
117
+ 4. **Hausa and Igbo:** The included algorithms are practical standard profiles. Regional variation and alternative counting conventions require separate tested lexicons.
118
+ 5. **Nigerian Pidgin:** Orthography is not uniformly standardized. The package uses one consistent practical spelling profile; projects should publish their chosen style guide.
119
+ 6. **Dates and times:** Automatic date/time expansion is deliberately not enabled in this first baseline because numeric date order is ambiguous (DD/MM vs MM/DD), and speech readings depend on context.
120
+ 7. **Phone numbers:** Recognizes common Nigerian 11-digit local numbers and +234 formats. It does not validate allocation, carrier or whether a number is active.
121
+ 8. **Currencies:** Currency symbol expansion is supported; currency codes and complex mixed-currency accounting expressions need explicit project rules.
122
+ 9. **Units:** Common metric and telecom units are supported. Unit case is normalized for matching; abbreviations embedded in words are not intentionally expanded.
123
+
124
+ Do not use the default pipeline blindly on OTPs, bank account numbers, NINs, BVNs, transaction references, postal codes, coordinates, URLs or product identifiers. These need explicit token classes and project policies.
125
+
126
+ ## Contributing linguistic data
127
+
128
+ Every language rule should have:
129
+ - a language and dialect/standard label;
130
+ - a source or native-speaker reviewer;
131
+ - examples with expected output;
132
+ - Unicode NFC tests;
133
+ - tests for numbers around every scale boundary;
134
+ - tests for code-switching and preservation.
135
+
136
+ Please submit corrections with audio or a clear orthographic convention where possible.
137
+
138
+ ## License
139
+
140
+ MIT. See `LICENSE`.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "naijanorm"
7
+ version = "1.0.0"
8
+ description = "Nigerian-context text normalization for speech and language AI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "NaijaNorm contributors" }]
13
+ keywords = ["Nigerian languages", "text normalization", "ASR", "TTS", "Yoruba", "Hausa", "Igbo", "Pidgin"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Text Processing :: Linguistic",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ excel = ["pandas>=2.0", "openpyxl>=3.1"]
29
+ dev = ["pytest>=8.0", "build>=1.2", "ruff>=0.5"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/YOUR-ORG/naijanorm"
33
+ Issues = "https://github.com/YOUR-ORG/naijanorm/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/naijanorm"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ addopts = "-q"
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py39"
@@ -0,0 +1,10 @@
1
+ """NaijaNorm: Nigerian-context text normalization."""
2
+ from .normalizer import NaijaNorm, normalize, normalize_batch
3
+ from .config import NormalizerConfig
4
+ from .result import NormalizationResult, NormalizationEvent
5
+
6
+ __version__ = "1.0.0"
7
+ __all__ = [
8
+ "NaijaNorm", "normalize", "normalize_batch", "NormalizerConfig",
9
+ "NormalizationResult", "NormalizationEvent", "__version__"
10
+ ]
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import FrozenSet, Optional
3
+
4
+ SUPPORTED_LANGUAGES = ("en-NG", "pcm-NG", "yo-NG", "ha-NG", "ig-NG")
5
+
6
+ @dataclass
7
+ class NormalizerConfig:
8
+ language: str = "en-NG"
9
+ yoruba_system: str = "oyo-traditional" # or "modern-decimal"
10
+ igbo_variant: str = "standard" # regional variants require separate lexicons
11
+ hausa_variant: str = "standard-boko"
12
+ categories: Optional[FrozenSet[str]] = None
13
+ preserve_unknown: bool = True
14
+ expand_units: bool = True
15
+ expand_currency: bool = True
16
+ expand_telephone: bool = True
17
+ decimal_style: str = "point"
18
+ include_events: bool = False
19
+ max_integer: int = 999_999_999_999
20
+
21
+ def __post_init__(self):
22
+ if self.language not in SUPPORTED_LANGUAGES:
23
+ raise ValueError(
24
+ f"Unsupported language {self.language!r}. Choose from {SUPPORTED_LANGUAGES}."
25
+ )
26
+ if self.yoruba_system not in {"oyo-traditional", "modern-decimal"}:
27
+ raise ValueError("yoruba_system must be 'oyo-traditional' or 'modern-decimal'")
@@ -0,0 +1,156 @@
1
+ import re
2
+ from typing import Iterable, Optional, List
3
+ from .config import NormalizerConfig
4
+ from .numbers import spell_integer
5
+ from .result import NormalizationEvent, NormalizationResult
6
+
7
+ UNIT_MAP = {
8
+ "kb": ("kilobyte", "kilobytes"), "mb": ("megabyte", "megabytes"),
9
+ "gb": ("gigabyte", "gigabytes"), "tb": ("terabyte", "terabytes"),
10
+ "kbps": ("kilobit per second", "kilobits per second"),
11
+ "mbps": ("megabit per second", "megabits per second"),
12
+ "gbps": ("gigabit per second", "gigabits per second"),
13
+ "kg": ("kilogram", "kilograms"), "g": ("gram", "grams"),
14
+ "km": ("kilometre", "kilometres"), "cm": ("centimetre", "centimetres"),
15
+ "mm": ("millimetre", "millimetres"), "m": ("metre", "metres"),
16
+ "l": ("litre", "litres"), "ml": ("millilitre", "millilitres"),
17
+ "°c": ("degree Celsius", "degrees Celsius"),
18
+ "hrs": ("hour", "hours"), "hr": ("hour", "hours"),
19
+ "mins": ("minute", "minutes"), "min": ("minute", "minutes"),
20
+ }
21
+ CURRENCY = {"₦": ("naira", "naira"), "$": ("US dollar", "US dollars"),
22
+ "£": ("pound sterling", "pounds sterling"), "€": ("euro", "euros")}
23
+ DIGIT_WORDS = {
24
+ "en-NG": ["zero","one","two","three","four","five","six","seven","eight","nine"],
25
+ "pcm-NG": ["zero","wan","tu","tri","fo","faiv","siks","sevn","et","nain"],
26
+ "yo-NG": ["odo","ọ̀kan","èjì","ẹ̀ta","ẹ̀rin","àrún-ún","ẹ̀fà","èje","ẹ̀jọ","ẹ̀sàn-án"],
27
+ "ha-NG": ["sifili","ɗaya","biyu","uku","huɗu","biyar","shida","bakwai","takwas","tara"],
28
+ "ig-NG": ["efu","otu","abụọ","atọ","anọ","ise","isii","asaa","asatọ","itoolu"],
29
+ }
30
+ SHORTCODE_WORDS = {"*":"star", "#":"hash", "+":"plus"}
31
+
32
+ class NaijaNorm:
33
+ """Context-aware Nigerian text normalizer.
34
+
35
+ This is a deterministic rule-based baseline. Language profiles are explicit;
36
+ dialect-sensitive forms should be reviewed by native speakers for production.
37
+ """
38
+ def __init__(self, language: str = "en-NG", **kwargs):
39
+ self.config = NormalizerConfig(language=language, **kwargs)
40
+
41
+ def _num(self, token: str) -> str:
42
+ clean = token.replace(",", "").replace("_", "")
43
+ if "." in clean:
44
+ left, right = clean.split(".", 1)
45
+ return spell_integer(int(left), self.config.language, self.config.yoruba_system) + \
46
+ " point " + " ".join(DIGIT_WORDS[self.config.language][int(x)] for x in right if x.isdigit())
47
+ return spell_integer(int(clean), self.config.language, self.config.yoruba_system)
48
+
49
+ def normalize_with_events(self, text: str) -> NormalizationResult:
50
+ if not isinstance(text, str):
51
+ raise TypeError("text must be a string")
52
+ events: List[NormalizationEvent] = []
53
+ lang = self.config.language
54
+
55
+ def replace(pattern, fn, category):
56
+ nonlocal text
57
+ if self.config.categories is not None and category not in self.config.categories:
58
+ return
59
+ def sub(m):
60
+ original = m.group(0)
61
+ normalized = fn(m)
62
+ events.append(NormalizationEvent(original, normalized, category, lang))
63
+ return normalized
64
+ text = re.sub(pattern, sub, text, flags=re.IGNORECASE)
65
+
66
+ # Telephone numbers: preserve every digit; recognize common Nigerian formats.
67
+ if self.config.expand_telephone:
68
+ def phone(m):
69
+ raw = re.sub(r"\D", "", m.group(0))
70
+ if raw.startswith("234") and len(raw) == 13:
71
+ digits = "0" + raw[3:]
72
+ else:
73
+ digits = raw
74
+ return " ".join(DIGIT_WORDS[lang][int(d)] for d in digits)
75
+ replace(r"(?<!\w)(?:\+?234[\s-]?\d{3}[\s-]?\d{3}[\s-]?\d{4}|0\d{10})(?!\w)", phone, "telephone")
76
+
77
+ # USSD / short codes. Keep symbol semantics explicit.
78
+ replace(r"(?<!\w)\*\d{2,6}#", lambda m: " ".join(
79
+ [SHORTCODE_WORDS["*"]] +
80
+ [DIGIT_WORDS[lang][int(d)] for d in m.group(0)[1:-1]] +
81
+ [SHORTCODE_WORDS["#"]]
82
+ ), "shortcode")
83
+
84
+ # Currency values, including decimals and grouped thousands.
85
+ if self.config.expand_currency:
86
+ replace(r"[₦$£€]\s?-?\d[\d,]*(?:\.\d+)?",
87
+ lambda m: self._num(re.search(r"-?\d[\d,]*(?:\.\d+)?", m.group(0)).group(0)) +
88
+ " " + CURRENCY[m.group(0)[0]][1], "currency")
89
+
90
+ # Percentages.
91
+ replace(r"(?<!\w)-?\d[\d,]*(?:\.\d+)?\s*%",
92
+ lambda m: self._num(re.sub(r"\s*%","",m.group(0))) + " percent", "percentage")
93
+
94
+ # Ordinals (English suffix notation). Non-English ordinal morphology is not
95
+ # guessed; use cardinal fallback with a review event for now.
96
+ def ordinal(m):
97
+ n = int(m.group(1))
98
+ if lang == "en-NG":
99
+ if 10 <= n % 100 <= 20: suffix = "th"
100
+ else: suffix = {1:"st",2:"nd",3:"rd"}.get(n % 10,"th")
101
+ words = self._num(str(n))
102
+ if n in (1, 2, 3):
103
+ return {1: "first", 2: "second", 3: "third"}[n]
104
+ if n % 100 in (11, 12, 13):
105
+ return words + "-th"
106
+ last = n % 10
107
+ irregular = {5: "fifth", 8: "eighth", 9: "ninth", 12: "twelfth"}
108
+ if last in irregular and n < 100:
109
+ prefix = words.rsplit("-", 1)[0] if "-" in words else ""
110
+ return (prefix + "-" if prefix else "") + irregular[last]
111
+ if last == 1:
112
+ return words[:-3] + "first" if words.endswith("one") else words + "-first"
113
+ if last == 2:
114
+ return words[:-3] + "second" if words.endswith("two") else words + "-second"
115
+ if last == 3:
116
+ return words[:-5] + "third" if words.endswith("three") else words + "-third"
117
+ return words + "-th"
118
+ return self._num(str(n)) + " (ordinal form requires language-specific review)"
119
+ replace(r"\b(\d+)(st|nd|rd|th)\b", ordinal, "ordinal")
120
+
121
+ # Measurement and data units. Avoid matching substrings in words.
122
+ if self.config.expand_units:
123
+ unit_pattern = r"(?<!\w)(-?\d[\d,]*(?:\.\d+)?)\s*(KBPS|MBPS|GBPS|KB|MB|GB|TB|KG|G|KM|CM|MM|ML|L|M|°C|HRS|HR|MINS|MIN)\b"
124
+ def unit(m):
125
+ key=m.group(2).lower()
126
+ singular, plural=UNIT_MAP[key]
127
+ amount=m.group(1)
128
+ return self._num(amount) + " " + (singular if amount.replace(",","") in ("1","1.0") else plural)
129
+ replace(unit_pattern, unit, "measurement")
130
+
131
+ # Plain decimals before plain integers.
132
+ replace(r"(?<![\w.])-?\d[\d,]*\.\d+(?!\w)", lambda m: self._num(m.group(0)), "decimal")
133
+
134
+ # Remaining cardinal integers. Protect years/IDs only when embedded in letters;
135
+ # bare numbers are treated as cardinals.
136
+ replace(r"(?<![\w])\d{1,12}(?![\w])", lambda m: self._num(m.group(0)), "cardinal")
137
+
138
+ return NormalizationResult(text=text, events=events)
139
+
140
+ def normalize(self, text: str, categories: Optional[Iterable[str]] = None) -> str:
141
+ if categories is None:
142
+ return self.normalize_with_events(text).text
143
+ options = self.config.__dict__.copy()
144
+ options.pop("language", None)
145
+ options["categories"] = frozenset(categories)
146
+ clone = NaijaNorm(self.config.language, **options)
147
+ return clone.normalize_with_events(text).text
148
+
149
+ def normalize_batch(self, texts: Iterable[str]) -> List[str]:
150
+ return [self.normalize(t) for t in texts]
151
+
152
+ def normalize(text: str, language="en-NG", **kwargs) -> str:
153
+ return NaijaNorm(language, **kwargs).normalize(text)
154
+
155
+ def normalize_batch(texts: Iterable[str], language="en-NG", **kwargs) -> List[str]:
156
+ return NaijaNorm(language, **kwargs).normalize_batch(texts)
@@ -0,0 +1,170 @@
1
+ """Language-specific cardinal number spell-out.
2
+
3
+ Important: language numerals have dialectal and register variation. The lexicons
4
+ here are an initial documented standard profile, not a substitute for validation
5
+ by native-speaking linguists before safety-critical or commercial corpus release.
6
+ """
7
+ from typing import Dict
8
+
9
+ EN_ONES = ["zero","one","two","three","four","five","six","seven","eight","nine"]
10
+ EN_TEENS = ["ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]
11
+ EN_TENS = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]
12
+ SCALES_EN = [(10**9,"billion"),(10**6,"million"),(1000,"thousand"),(100,"hundred")]
13
+
14
+ def en(n: int) -> str:
15
+ if n < 0: return "minus " + en(-n)
16
+ if n < 10: return EN_ONES[n]
17
+ if n < 20: return EN_TEENS[n-10]
18
+ if n < 100:
19
+ return EN_TENS[n//10] + (("-" + en(n%10)) if n%10 else "")
20
+ if n < 1000:
21
+ return en(n//100) + " hundred" + (" and " + en(n%100) if n%100 else "")
22
+ for scale, name in SCALES_EN:
23
+ if n >= scale:
24
+ q, r = divmod(n, scale)
25
+ join = " and " if scale == 100 and r else (" and " if r < 100 and r else ", ")
26
+ return en(q) + " " + name + ("" if q == 1 and name == "million" else "s" if q != 1 and name in ("million","billion") else "") + (join + en(r) if r else "")
27
+ return str(n)
28
+
29
+ YO_ONES = {
30
+ 0:"òdo", 1:"ọ̀kan", 2:"èjì", 3:"ẹ̀ta", 4:"ẹ̀rin", 5:"àrún-ún",
31
+ 6:"ẹ̀fà", 7:"èje", 8:"ẹ̀jọ", 9:"ẹ̀sàn-án", 10:"ẹ̀wá"
32
+ }
33
+ YO_TEENS = {
34
+ 11:"mọ́kànlá",12:"méjìlá",13:"mẹ́tàlá",14:"mẹ́rìnlá",
35
+ 15:"mẹ́ẹ̀dógún",16:"mẹ́rìndínlógún",17:"mẹ́tàdínlógún",
36
+ 18:"méjìdínlógún",19:"mọ́kàndínlógún"
37
+ }
38
+ YO_ROUND = {
39
+ 20:"ogún",30:"ọgbọ̀n",40:"ogójì",50:"àádọ́ta",60:"ọgọ́ta",
40
+ 70:"àádọ́rin",80:"ọgọ́rin",90:"àádọ́rùn-ún",100:"ọgọ́rùn-ún",
41
+ 200:"igba",300:"ọ̀dúnrún",400:"irínwó",1000:"ẹgbẹ̀rún"
42
+ }
43
+ YO_MODERN_ONES = ["òdo","ọ̀kan","méjì","mẹ́ta","mẹ́rin","márùn-ún","mẹ́fà","méje","mẹ́jọ","mẹ́sàn-án"]
44
+ YO_MODERN_TENS = {10:"mẹ́wàá",20:"ogún",30:"ọgbọ̀n",40:"ogójì",50:"àádọ́ta",60:"ọgọ́ta",70:"àádọ́rin",80:"ọgọ́rin",90:"àádọ́rùn-ún"}
45
+
46
+ def yo(n: int, system="oyo-traditional") -> str:
47
+ if n < 0: return "òdì " + yo(-n, system)
48
+ if system == "modern-decimal":
49
+ if n < 10: return YO_MODERN_ONES[n]
50
+ if n in YO_MODERN_TENS: return YO_MODERN_TENS[n]
51
+ if n < 100:
52
+ t=(n//10)*10; r=n%10
53
+ return YO_MODERN_TENS[t] + (" ó lé " + YO_MODERN_ONES[r] if r else "")
54
+ # Modern profile composes large quantities using standard Yoruba scale words.
55
+ if n < 1000:
56
+ q,r=divmod(n,100)
57
+ return yo(q,system) + " ọgọ́rùn-ún" + (" àti " + yo(r,system) if r else "")
58
+ q,r=divmod(n,1000)
59
+ return yo(q,system) + " ẹgbẹ̀rún" + (" àti " + yo(r,system) if r else "")
60
+ if n in YO_ONES: return YO_ONES[n]
61
+ if n in YO_TEENS: return YO_TEENS[n]
62
+ if n in YO_ROUND: return YO_ROUND[n]
63
+ if 21 <= n <= 24:
64
+ return YO_ROUND[20] + " lé " + YO_ONES[n-20]
65
+ if 25 <= n <= 29:
66
+ return YO_ONES[30-n] + " dín lọ́gbọ̀n"
67
+ if 31 <= n <= 34:
68
+ return YO_ROUND[30] + " lé " + YO_ONES[n-30]
69
+ if 35 <= n <= 39:
70
+ return YO_ONES[40-n] + " dín ogójì"
71
+ if 41 <= n <= 44:
72
+ return YO_ROUND[40] + " lé " + YO_ONES[n-40]
73
+ if 45 <= n <= 49:
74
+ return YO_ONES[50-n] + " dín àádọ́ta"
75
+ if 51 <= n <= 54:
76
+ return YO_ROUND[50] + " lé " + YO_ONES[n-50]
77
+ if 55 <= n <= 59:
78
+ return YO_ONES[60-n] + " dín ọgọ́ta"
79
+ if 61 <= n <= 64:
80
+ return YO_ROUND[60] + " lé " + YO_ONES[n-60]
81
+ if 65 <= n <= 69:
82
+ return YO_ONES[70-n] + " dín àádọ́rin"
83
+ if 71 <= n <= 74:
84
+ return YO_ROUND[70] + " lé " + YO_ONES[n-70]
85
+ if 75 <= n <= 79:
86
+ return YO_ONES[80-n] + " dín ọgọ́rin"
87
+ if 81 <= n <= 84:
88
+ return YO_ROUND[80] + " lé " + YO_ONES[n-80]
89
+ if 85 <= n <= 89:
90
+ return YO_ONES[90-n] + " dín àádọ́rùn-ún"
91
+ if 91 <= n <= 94:
92
+ return YO_ROUND[90] + " lé " + YO_ONES[n-90]
93
+ if 95 <= n <= 99:
94
+ return YO_ONES[100-n] + " dín ọgọ́rùn-ún"
95
+ if n < 1000:
96
+ q,r=divmod(n,100)
97
+ hundreds = YO_ROUND.get(q*100)
98
+ if hundreds is None: hundreds = yo(q,system) + " ọgọ́rùn-ún"
99
+ return hundreds + (" àti " + yo(r,system) if r else "")
100
+ if n < 1000000:
101
+ q,r=divmod(n,1000)
102
+ return yo(q,system) + " ẹgbẹ̀rún" + (" àti " + yo(r,system) if r else "")
103
+ raise ValueError("Yoruba number conversion currently supports values below 1,000,000")
104
+
105
+ HA_ONES = ["sifili","ɗaya","biyu","uku","huɗu","biyar","shida","bakwai","takwas","tara"]
106
+ HA_TENS = {10:"goma",20:"ashirin",30:"talatin",40:"arba'in",50:"hamsin",60:"sittin",70:"saba'in",80:"tamanin",90:"casa'in"}
107
+ def ha(n: int) -> str:
108
+ if n < 0: return "mara kyau " + ha(-n)
109
+ if n < 10: return HA_ONES[n]
110
+ if n < 20: return "goma sha " + HA_ONES[n-10]
111
+ if n in HA_TENS: return HA_TENS[n]
112
+ if n < 100:
113
+ t=n//10*10; r=n%10
114
+ # Hausa joins units with "da" in common compound forms.
115
+ return HA_TENS[t] + (" da " + HA_ONES[r] if r else "")
116
+ if n < 200:
117
+ return "ɗari" + (" da " + ha(n-100) if n>100 else "")
118
+ if n < 1000:
119
+ q,r=divmod(n,100)
120
+ return ha(q) + " ɗari" + (" da " + ha(r) if r else "")
121
+ if n < 1_000_000:
122
+ q,r=divmod(n,1000)
123
+ return ha(q) + " dubu" + (" da " + ha(r) if r else "")
124
+ q,r=divmod(n,1_000_000)
125
+ return ha(q) + " miliyan" + (" da " + ha(r) if r else "")
126
+
127
+ IG_ONES = ["efu","otu","abụọ","atọ","anọ","ise","isii","asaa","asatọ","itoolu"]
128
+ IG_TEENS = {10:"iri",11:"iri na otu",12:"iri na abụọ",13:"iri na atọ",14:"iri na anọ",15:"iri na ise",16:"iri na isii",17:"iri na asaa",18:"iri na asatọ",19:"iri na itoolu"}
129
+ IG_TENS = {20:"iri abụọ",30:"iri atọ",40:"iri anọ",50:"iri ise",60:"iri isii",70:"iri asaa",80:"iri asatọ",90:"iri itoolu"}
130
+ def ig(n: int) -> str:
131
+ if n < 0: return "mwepu " + ig(-n)
132
+ if n < 10: return IG_ONES[n]
133
+ if n < 20: return IG_TEENS[n]
134
+ if n < 100:
135
+ t=n//10*10; r=n%10
136
+ return IG_TENS[t] + (" na " + IG_ONES[r] if r else "")
137
+ if n < 1000:
138
+ q,r=divmod(n,100)
139
+ return IG_ONES[q] + " narị" + (" na " + ig(r) if r else "")
140
+ if n < 1_000_000:
141
+ q,r=divmod(n,1000)
142
+ return ig(q) + " puku" + (" na " + ig(r) if r else "")
143
+ q,r=divmod(n,1_000_000)
144
+ return ig(q) + " nde" + (" na " + ig(r) if r else "")
145
+
146
+ PCM_ONES = ["zero","wan","tu","tri","fo","faiv","siks","sevn","et","nain"]
147
+ PCM_TEENS = ["ten","ilevn","twelv","tetin","fotin","fiftin","sikstin","seventin","eitin","naintin"]
148
+ PCM_TENS = ["","","twenti","teti","foti","fifti","siksti","seventi","eiti","nainti"]
149
+ def pcm(n: int) -> str:
150
+ if n < 0: return "minus " + pcm(-n)
151
+ if n < 10: return PCM_ONES[n]
152
+ if n < 20: return PCM_TEENS[n-10]
153
+ if n < 100:
154
+ return PCM_TENS[n//10] + (" " + pcm(n%10) if n%10 else "")
155
+ if n < 1000:
156
+ q,r=divmod(n,100)
157
+ return pcm(q) + " hundred" + (" " + pcm(r) if r else "")
158
+ if n < 1_000_000:
159
+ q,r=divmod(n,1000)
160
+ return pcm(q) + " thousand" + (" " + pcm(r) if r else "")
161
+ q,r=divmod(n,1_000_000)
162
+ return pcm(q) + " million" + (" " + pcm(r) if r else "")
163
+
164
+ def spell_integer(n: int, language: str, yoruba_system="oyo-traditional") -> str:
165
+ if language == "en-NG": return en(n)
166
+ if language == "pcm-NG": return pcm(n)
167
+ if language == "yo-NG": return yo(n, yoruba_system)
168
+ if language == "ha-NG": return ha(n)
169
+ if language == "ig-NG": return ig(n)
170
+ raise ValueError(f"Unsupported language: {language}")
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Dict, Any
3
+
4
+ @dataclass
5
+ class NormalizationEvent:
6
+ original: str
7
+ normalized: str
8
+ category: str
9
+ language: str
10
+ confidence: str = "rule-based"
11
+
12
+ @dataclass
13
+ class NormalizationResult:
14
+ text: str
15
+ events: List[NormalizationEvent] = field(default_factory=list)
16
+
17
+ def as_dict(self) -> Dict[str, Any]:
18
+ return {
19
+ "text": self.text,
20
+ "events": [e.__dict__.copy() for e in self.events],
21
+ }
@@ -0,0 +1,48 @@
1
+ import pytest
2
+ from naijanorm.numbers import spell_integer
3
+ from naijanorm import NaijaNorm
4
+
5
+ @pytest.mark.parametrize("lang, expected", [
6
+ ("en-NG", "two hundred and fifty"),
7
+ ("pcm-NG", "tu hundred faiv"),
8
+ ("yo-NG", "ẹ̀ta"),
9
+ ("ha-NG", "ɗaya"),
10
+ ("ig-NG", "abụọ"),
11
+ ])
12
+ def test_language_profiles(lang, expected):
13
+ values = {"en-NG":250, "pcm-NG":205, "yo-NG":3, "ha-NG":1, "ig-NG":2}
14
+ assert spell_integer(values[lang], lang).startswith(expected)
15
+
16
+ def test_english_examples():
17
+ n = NaijaNorm("en-NG")
18
+ assert n.normalize("250") == "two hundred and fifty"
19
+ assert n.normalize("25th") == "twenty-fifth"
20
+ assert n.normalize("3.5") == "three point five"
21
+
22
+ def test_phone_digit_by_digit():
23
+ out = NaijaNorm("en-NG").normalize("08012345678")
24
+ assert out == "zero eight zero one two three four five six seven eight"
25
+
26
+ def test_currency():
27
+ assert NaijaNorm("en-NG").normalize("₦5,000") == "five thousand naira"
28
+
29
+ def test_data_unit():
30
+ assert NaijaNorm("en-NG").normalize("250MB") == "two hundred and fifty megabytes"
31
+
32
+ def test_shortcode():
33
+ assert NaijaNorm("en-NG").normalize("*310#") == "star three one zero hash"
34
+
35
+ def test_unicode_preservation():
36
+ text = "Ọ̀ṣun àti Ìbàdàn"
37
+ assert NaijaNorm("yo-NG").normalize(text) == text
38
+
39
+ def test_yoruba_traditional_boundaries():
40
+ from naijanorm.numbers import yo
41
+ assert yo(16) == "mẹ́rìndínlógún"
42
+ assert yo(21) == "ogún lé ọ̀kan"
43
+ assert yo(25) == "àrún-ún dín lọ́gbọ̀n"
44
+ assert yo(31) == "ọgbọ̀n lé ọ̀kan"
45
+
46
+ def test_category_filter():
47
+ n = NaijaNorm("en-NG")
48
+ assert n.normalize("₦5,000 and 250MB", categories=["currency"]) == "five thousand naira and 250MB"