mboka 0.0.1__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.
- mboka-0.0.1/.gitignore +12 -0
- mboka-0.0.1/PKG-INFO +34 -0
- mboka-0.0.1/README.md +16 -0
- mboka-0.0.1/pyproject.toml +34 -0
- mboka-0.0.1/src/mboka/__init__.py +6 -0
- mboka-0.0.1/src/mboka/_data.py +14 -0
- mboka-0.0.1/src/mboka/data/geo/arrondissements.json +4 -0
- mboka-0.0.1/src/mboka/data/geo/communes.json +4 -0
- mboka-0.0.1/src/mboka/data/geo/departements.json +4 -0
- mboka-0.0.1/src/mboka/data/phone/numerotation.json +24 -0
- mboka-0.0.1/src/mboka/geo.py +61 -0
- mboka-0.0.1/src/mboka/money.py +32 -0
- mboka-0.0.1/src/mboka/phone.py +98 -0
- mboka-0.0.1/src/mboka/text.py +18 -0
- mboka-0.0.1/src/py.typed +0 -0
- mboka-0.0.1/tests/test_fixtures.py +55 -0
- mboka-0.0.1/tests/test_geo.py +19 -0
mboka-0.0.1/.gitignore
ADDED
mboka-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mboka
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mboka-dev/core
|
|
6
|
+
Project-URL: Issues, https://github.com/mboka-dev/core/issues
|
|
7
|
+
Author: Akieni Academy and contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: africa,brazzaville,congo,fcfa,geo,phone,xaf
|
|
10
|
+
Classifier: Natural Language :: French
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# mboka
|
|
20
|
+
|
|
21
|
+
Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts. No dependencies.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install mboka
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from mboka import phone, money
|
|
29
|
+
|
|
30
|
+
phone.parse("06 123 45 67")["e164"] # '+242061234567'
|
|
31
|
+
money.format(125000) # '125 000 FCFA'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Documentation (in French): https://github.com/mboka-dev/core
|
mboka-0.0.1/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# mboka
|
|
2
|
+
|
|
3
|
+
Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts. No dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install mboka
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from mboka import phone, money
|
|
11
|
+
|
|
12
|
+
phone.parse("06 123 45 67")["e164"] # '+242061234567'
|
|
13
|
+
money.format(125000) # '125 000 FCFA'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Documentation (in French): https://github.com/mboka-dev/core
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mboka"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Akieni Academy and contributors" }]
|
|
13
|
+
keywords = ["congo", "brazzaville", "phone", "fcfa", "xaf", "geo", "africa"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Natural Language :: French",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/mboka-dev/core"
|
|
27
|
+
Issues = "https://github.com/mboka-dev/core/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/mboka"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
pythonpath = ["src"]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Loads the JSON files shipped in the package (copied from /data by `npm run sync`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import cache
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@cache
|
|
10
|
+
def load(relative_path: str) -> Any:
|
|
11
|
+
resource = files("mboka").joinpath("data", *relative_path.split("/"))
|
|
12
|
+
content = json.loads(resource.read_text(encoding="utf-8"))
|
|
13
|
+
content.pop("$schema", None)
|
|
14
|
+
return content
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schema/numerotation.schema.json",
|
|
3
|
+
"countryCode": "242",
|
|
4
|
+
"nationalNumberLength": 9,
|
|
5
|
+
"nationalGrouping": [2, 3, 2, 2],
|
|
6
|
+
"verification": {
|
|
7
|
+
"status": "a_verifier",
|
|
8
|
+
"note": "Bootstrap value. To be confirmed against the national numbering plan published by the regulator (issue #2)."
|
|
9
|
+
},
|
|
10
|
+
"prefixes": [
|
|
11
|
+
{
|
|
12
|
+
"prefix": "06",
|
|
13
|
+
"type": "mobile",
|
|
14
|
+
"operator": "MTN",
|
|
15
|
+
"verification": { "status": "a_verifier", "note": "Bootstrap value, see issue #2." }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"prefix": "05",
|
|
19
|
+
"type": "mobile",
|
|
20
|
+
"operator": "Airtel",
|
|
21
|
+
"verification": { "status": "a_verifier", "note": "Bootstrap value, see issue #2." }
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from typing import Any, Literal, Optional
|
|
3
|
+
|
|
4
|
+
from ._data import load
|
|
5
|
+
from .text import normalize
|
|
6
|
+
|
|
7
|
+
Level = Literal["departement", "commune", "arrondissement"]
|
|
8
|
+
_FILES: dict[str, str] = {
|
|
9
|
+
"departement": "geo/departements.json",
|
|
10
|
+
"commune": "geo/communes.json",
|
|
11
|
+
"arrondissement": "geo/arrondissements.json",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _items(level: str) -> list[dict[str, Any]]:
|
|
16
|
+
return load(_FILES[level])["items"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def departements() -> list[dict[str, Any]]:
|
|
20
|
+
return copy.deepcopy(_items("departement"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def communes(departement_id: Optional[str] = None) -> list[dict[str, Any]]:
|
|
24
|
+
items = _items("commune")
|
|
25
|
+
if departement_id:
|
|
26
|
+
items = [c for c in items if c["departement"] == departement_id]
|
|
27
|
+
return copy.deepcopy(items)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def arrondissements(commune_id: Optional[str] = None) -> list[dict[str, Any]]:
|
|
31
|
+
items = _items("arrondissement")
|
|
32
|
+
if commune_id:
|
|
33
|
+
items = [a for a in items if a["commune"] == commune_id]
|
|
34
|
+
return copy.deepcopy(items)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_all(name: str, level: Optional[Level] = None) -> list[dict[str, Any]]:
|
|
38
|
+
"""All entries whose official name or alias matches, in order: départements, communes, arrondissements."""
|
|
39
|
+
needle = normalize(name)
|
|
40
|
+
if not needle:
|
|
41
|
+
return []
|
|
42
|
+
results = []
|
|
43
|
+
for lvl in _FILES:
|
|
44
|
+
if level and level != lvl:
|
|
45
|
+
continue
|
|
46
|
+
for entry in _items(lvl):
|
|
47
|
+
if any(normalize(n) == needle for n in [entry["nom"], *entry["aliases"]]):
|
|
48
|
+
results.append({"level": lvl, **copy.deepcopy(entry)})
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find(name: str, level: Optional[Level] = None) -> Optional[dict[str, Any]]:
|
|
53
|
+
"""First exact match after normalization, or None. Fuzzy matching is planned for v0.3."""
|
|
54
|
+
matches = find_all(name, level)
|
|
55
|
+
return matches[0] if matches else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_name(name: str) -> Optional[str]:
|
|
59
|
+
"""Official name for a free-text input, or None if unknown. Handy to clean a data column."""
|
|
60
|
+
match = find(name)
|
|
61
|
+
return match["nom"] if match else None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
NARROW_NBSP = " "
|
|
4
|
+
NBSP = " "
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _check_amount(amount: object, fn: str) -> int:
|
|
8
|
+
if isinstance(amount, bool) or not isinstance(amount, (int, float)):
|
|
9
|
+
raise TypeError(f"money.{fn} expects a number")
|
|
10
|
+
if isinstance(amount, float):
|
|
11
|
+
raise ValueError(f"money.{fn} expects an integer amount: the CFA franc has no subunit")
|
|
12
|
+
return amount
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format(amount: int, label: str = "FCFA", separator: str = NARROW_NBSP) -> str: # noqa: A001
|
|
16
|
+
"""format(125000) == '125 000 FCFA' (non-breaking spaces)."""
|
|
17
|
+
value = _check_amount(amount, "format")
|
|
18
|
+
sign = "-" if value < 0 else ""
|
|
19
|
+
grouped = f"{abs(value):,}".replace(",", separator)
|
|
20
|
+
return f"{sign}{grouped}{NBSP}{label}" if label else f"{sign}{grouped}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_words(amount: int, currency: bool = True,
|
|
24
|
+
orthographe: Literal["traditionnelle", "1990"] = "traditionnelle") -> str:
|
|
25
|
+
"""Amount in words: to_words(125000) == 'cent vingt-cinq mille francs CFA'.
|
|
26
|
+
|
|
27
|
+
Specified by fixtures/money/to-words.json. Not implemented yet: see the open issue.
|
|
28
|
+
"""
|
|
29
|
+
_check_amount(amount, "to_words")
|
|
30
|
+
raise NotImplementedError(
|
|
31
|
+
"money.to_words is not implemented yet. Contributions welcome: see fixtures/money/to-words.json"
|
|
32
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Literal, Optional, TypedDict
|
|
3
|
+
|
|
4
|
+
from ._data import load
|
|
5
|
+
|
|
6
|
+
InvalidReason = Literal["empty", "invalid_characters", "foreign_number", "length", "unknown_prefix"]
|
|
7
|
+
FormatStyle = Literal["national", "international", "e164"]
|
|
8
|
+
|
|
9
|
+
_SEPARATORS = re.compile(r"[\s().\-/]")
|
|
10
|
+
_DIGITS = re.compile(r"^\+?[0-9]+$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParsedPhone(TypedDict):
|
|
14
|
+
input: str
|
|
15
|
+
valid: bool
|
|
16
|
+
e164: Optional[str]
|
|
17
|
+
national: Optional[str]
|
|
18
|
+
international: Optional[str]
|
|
19
|
+
operator: Optional[str]
|
|
20
|
+
type: Optional[str]
|
|
21
|
+
reason: Optional[InvalidReason]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _plan() -> Any:
|
|
25
|
+
return load("phone/numerotation.json")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _group(digits: str) -> str:
|
|
29
|
+
sizes = _plan().get("nationalGrouping") or [len(digits)]
|
|
30
|
+
parts, i = [], 0
|
|
31
|
+
for size in sizes:
|
|
32
|
+
if i >= len(digits):
|
|
33
|
+
break
|
|
34
|
+
parts.append(digits[i : i + size])
|
|
35
|
+
i += size
|
|
36
|
+
if i < len(digits):
|
|
37
|
+
parts.append(digits[i:])
|
|
38
|
+
return " ".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _invalid(value: str, reason: InvalidReason) -> ParsedPhone:
|
|
42
|
+
return ParsedPhone(input=value, valid=False, e164=None, national=None, international=None,
|
|
43
|
+
operator=None, type=None, reason=reason)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse(value: str) -> ParsedPhone:
|
|
47
|
+
"""Parse any common way of writing a Congo-Brazzaville number. Only raises on non-strings."""
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
raise TypeError("phone.parse expects a string")
|
|
50
|
+
|
|
51
|
+
plan = _plan()
|
|
52
|
+
cc, length = plan["countryCode"], plan["nationalNumberLength"]
|
|
53
|
+
|
|
54
|
+
compact = _SEPARATORS.sub("", value)
|
|
55
|
+
if compact == "":
|
|
56
|
+
return _invalid(value, "empty")
|
|
57
|
+
if not _DIGITS.match(compact):
|
|
58
|
+
return _invalid(value, "invalid_characters")
|
|
59
|
+
|
|
60
|
+
if compact.startswith("+") or compact.startswith("00"):
|
|
61
|
+
rest = compact[1:] if compact.startswith("+") else compact[2:]
|
|
62
|
+
if not rest.startswith(cc):
|
|
63
|
+
return _invalid(value, "foreign_number")
|
|
64
|
+
national = rest[len(cc):]
|
|
65
|
+
elif compact.startswith(cc) and len(compact) == len(cc) + length:
|
|
66
|
+
national = compact[len(cc):]
|
|
67
|
+
else:
|
|
68
|
+
national = compact
|
|
69
|
+
|
|
70
|
+
if len(national) != length:
|
|
71
|
+
return _invalid(value, "length")
|
|
72
|
+
|
|
73
|
+
prefix = next((p for p in plan["prefixes"] if national.startswith(p["prefix"])), None)
|
|
74
|
+
if prefix is None:
|
|
75
|
+
return _invalid(value, "unknown_prefix")
|
|
76
|
+
|
|
77
|
+
grouped = _group(national)
|
|
78
|
+
return ParsedPhone(input=value, valid=True, e164=f"+{cc}{national}", national=grouped,
|
|
79
|
+
international=f"+{cc} {grouped}", operator=prefix.get("operator"),
|
|
80
|
+
type=prefix["type"], reason=None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_valid(value: str) -> bool:
|
|
84
|
+
return isinstance(value, str) and parse(value)["valid"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format(value: str, style: FormatStyle = "national") -> str: # noqa: A001 - mirrors the JS API
|
|
88
|
+
"""Format a number in the requested style. Raises ValueError if the number is invalid."""
|
|
89
|
+
parsed = parse(value)
|
|
90
|
+
if not parsed["valid"]:
|
|
91
|
+
raise ValueError(f'Invalid +242 number ({parsed["reason"]}): "{value}"')
|
|
92
|
+
if style == "national":
|
|
93
|
+
return parsed["national"] # type: ignore[return-value]
|
|
94
|
+
if style == "international":
|
|
95
|
+
return parsed["international"] # type: ignore[return-value]
|
|
96
|
+
if style == "e164":
|
|
97
|
+
return parsed["e164"] # type: ignore[return-value]
|
|
98
|
+
raise ValueError(f'Unknown format style "{style}". Use national, international or e164.')
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
|
|
4
|
+
_LIGATURES = str.maketrans({"œ": "oe", "Œ": "OE", "æ": "ae", "Æ": "AE"})
|
|
5
|
+
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize(value: str) -> str:
|
|
9
|
+
"""Lowercase, no accents, any run of non-alphanumeric characters becomes one space.
|
|
10
|
+
|
|
11
|
+
>>> normalize("Pointe-Noire")
|
|
12
|
+
'pointe noire'
|
|
13
|
+
"""
|
|
14
|
+
if not isinstance(value, str):
|
|
15
|
+
raise TypeError("text.normalize expects a string")
|
|
16
|
+
decomposed = unicodedata.normalize("NFD", value.translate(_LIGATURES))
|
|
17
|
+
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
18
|
+
return _NON_ALNUM.sub(" ", stripped.lower()).strip()
|
mboka-0.0.1/src/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runs every active file in /fixtures against the Python package."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
FIXTURES = Path(__file__).resolve().parents[3] / "fixtures"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def snake(name: str) -> str:
|
|
14
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def collect():
|
|
18
|
+
for path in sorted(FIXTURES.rglob("*.json")):
|
|
19
|
+
fixture = json.loads(path.read_text(encoding="utf-8"))
|
|
20
|
+
module_name, fn_name = fixture["function"].split(".")
|
|
21
|
+
marks = [pytest.mark.skip(reason="pending fixture")] if fixture["status"] == "pending" else []
|
|
22
|
+
for case in fixture["cases"]:
|
|
23
|
+
yield pytest.param(module_name, snake(fn_name), case,
|
|
24
|
+
id=f'{path.relative_to(FIXTURES)}::{case["name"]}', marks=marks)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def assert_partial(actual, expected, path="result"):
|
|
28
|
+
if isinstance(expected, dict):
|
|
29
|
+
assert isinstance(actual, dict), f"{path}: expected an object, got {actual!r}"
|
|
30
|
+
for key, value in expected.items():
|
|
31
|
+
assert_partial(actual.get(key), value, f"{path}.{key}")
|
|
32
|
+
else:
|
|
33
|
+
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
|
|
34
|
+
assert type(actual) is type(expected) or expected is None, f"{path}: type {type(actual)} != {type(expected)}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize("module_name, fn_name, case", list(collect()))
|
|
38
|
+
def test_fixture(module_name, fn_name, case):
|
|
39
|
+
module = importlib.import_module(f"mboka.{module_name}")
|
|
40
|
+
fn = getattr(module, fn_name, None)
|
|
41
|
+
assert callable(fn), f"mboka.{module_name}.{fn_name} is missing"
|
|
42
|
+
|
|
43
|
+
def call():
|
|
44
|
+
if "options" not in case:
|
|
45
|
+
return fn(case["input"])
|
|
46
|
+
options = case["options"]
|
|
47
|
+
if isinstance(options, dict):
|
|
48
|
+
return fn(case["input"], **{snake(k): v for k, v in options.items()})
|
|
49
|
+
return fn(case["input"], options)
|
|
50
|
+
|
|
51
|
+
if case.get("expectedError"):
|
|
52
|
+
with pytest.raises(Exception):
|
|
53
|
+
call()
|
|
54
|
+
else:
|
|
55
|
+
assert_partial(call(), case["expected"])
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from mboka import geo
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_lists_are_lists():
|
|
5
|
+
assert isinstance(geo.departements(), list)
|
|
6
|
+
assert isinstance(geo.communes(), list)
|
|
7
|
+
assert isinstance(geo.arrondissements(), list)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_returned_data_cannot_mutate_shipped_data():
|
|
11
|
+
first = geo.departements()
|
|
12
|
+
first.append({"id": "fake"})
|
|
13
|
+
assert len(geo.departements()) != len(first)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_unknown_or_empty_names_return_none():
|
|
17
|
+
assert geo.find("Atlantis") is None
|
|
18
|
+
assert geo.find(" ") is None
|
|
19
|
+
assert geo.normalize_name("Atlantis") is None
|