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 ADDED
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ *.tsbuildinfo
5
+ __pycache__/
6
+ *.py[cod]
7
+ .venv/
8
+ *.egg-info/
9
+ build/
10
+ .pytest_cache/
11
+ .DS_Store
12
+ .env
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,6 @@
1
+ """Local data and helpers for Congo-Brazzaville."""
2
+
3
+ from . import geo, money, phone, text
4
+
5
+ __version__ = "0.0.1"
6
+ __all__ = ["geo", "money", "phone", "text", "__version__"]
@@ -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,4 @@
1
+ {
2
+ "$schema": "../schema/arrondissements.schema.json",
3
+ "items": []
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "../schema/communes.schema.json",
3
+ "items": []
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "../schema/departements.schema.json",
3
+ "items": []
4
+ }
@@ -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()
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