syscli-algeria 0.1.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,17 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ *.tsbuildinfo
5
+ .DS_Store
6
+ .env
7
+ .env.local
8
+ .npmrc
9
+
10
+ *.tgz
11
+ src/generated/
12
+ geo/.cache/
13
+ public/
14
+ python/tests/parity.json
15
+ python/dist/
16
+ __pycache__/
17
+ *.egg-info/
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-10
4
+
5
+ First release, with the data and search of @syscli/algeria 0.2.0:
6
+ `wilayas`, `wilaya`, `communes`, `commune`, `search`, `changes`, `normalize`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 syscli
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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: syscli-algeria
3
+ Version: 0.1.0
4
+ Summary: Algeria's 69 wilayas and 1541 communes from the Journal officiel, with search in French and Arabic.
5
+ Project-URL: Homepage, https://github.com/syscli/algeria
6
+ Project-URL: Source, https://github.com/syscli/algeria/tree/main/python
7
+ Project-URL: Changelog, https://github.com/syscli/algeria/blob/main/python/CHANGELOG.md
8
+ Author-email: Sysserv <me@sysserv.eu.org>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: algeria,algerie,commune,dz,geography,wilaya
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Natural Language :: Arabic
15
+ Classifier: Natural Language :: French
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: GIS
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=8; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # syscli-algeria
25
+
26
+ The 69 wilayas and 1541 communes of Algeria, taken from the Journal officiel,
27
+ with French and Arabic names, ONS codes, and search that understands the many
28
+ ways a place name gets spelled. The Python twin of the npm package
29
+ [@syscli/algeria](https://github.com/syscli/algeria), with the same data and
30
+ the same search.
31
+
32
+ ```sh
33
+ pip install syscli-algeria
34
+ ```
35
+
36
+ ```python
37
+ from syscli_algeria import wilaya, communes, commune, search, changes
38
+
39
+ wilaya(16).name_fr # "Alger"
40
+ len(communes(68)) # 23
41
+ commune("1601").wilaya_code # "16"
42
+
43
+ search("bou saada")[0].wilaya.code # "68"
44
+ search("Bains Romains")[0].commune.name_fr # "Hammamet", by its old official name
45
+ search("tamanghaset")[0].commune.name_fr # "Tamenghasset", despite the typo
46
+
47
+ len(changes(2026)) # 108 communes moved to another wilaya in 2026
48
+ ```
49
+
50
+ Up to date with loi 26-06 of 4 April 2026 (58 to 69 wilayas) and decret
51
+ presidentiel 26-206. Every wilaya and commune traces back to a text published
52
+ in the Journal officiel; see https://github.com/syscli/algeria.
53
+
54
+ No dependencies. Python 3.9 or later.
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,35 @@
1
+ # syscli-algeria
2
+
3
+ The 69 wilayas and 1541 communes of Algeria, taken from the Journal officiel,
4
+ with French and Arabic names, ONS codes, and search that understands the many
5
+ ways a place name gets spelled. The Python twin of the npm package
6
+ [@syscli/algeria](https://github.com/syscli/algeria), with the same data and
7
+ the same search.
8
+
9
+ ```sh
10
+ pip install syscli-algeria
11
+ ```
12
+
13
+ ```python
14
+ from syscli_algeria import wilaya, communes, commune, search, changes
15
+
16
+ wilaya(16).name_fr # "Alger"
17
+ len(communes(68)) # 23
18
+ commune("1601").wilaya_code # "16"
19
+
20
+ search("bou saada")[0].wilaya.code # "68"
21
+ search("Bains Romains")[0].commune.name_fr # "Hammamet", by its old official name
22
+ search("tamanghaset")[0].commune.name_fr # "Tamenghasset", despite the typo
23
+
24
+ len(changes(2026)) # 108 communes moved to another wilaya in 2026
25
+ ```
26
+
27
+ Up to date with loi 26-06 of 4 April 2026 (58 to 69 wilayas) and decret
28
+ presidentiel 26-206. Every wilaya and commune traces back to a text published
29
+ in the Journal officiel; see https://github.com/syscli/algeria.
30
+
31
+ No dependencies. Python 3.9 or later.
32
+
33
+ ## License
34
+
35
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "syscli-algeria"
7
+ version = "0.1.0"
8
+ description = "Algeria's 69 wilayas and 1541 communes from the Journal officiel, with search in French and Arabic."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Sysserv", email = "me@sysserv.eu.org" }]
13
+ requires-python = ">=3.9"
14
+ keywords = ["algeria", "algerie", "wilaya", "commune", "dz", "geography"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Natural Language :: French",
19
+ "Natural Language :: Arabic",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Scientific/Engineering :: GIS",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/syscli/algeria"
27
+ Source = "https://github.com/syscli/algeria/tree/main/python"
28
+ Changelog = "https://github.com/syscli/algeria/blob/main/python/CHANGELOG.md"
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=8"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/syscli_algeria"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,199 @@
1
+ """Algeria's 69 wilayas and 1541 communes, from the Journal officiel.
2
+
3
+ The same data and search as the npm package @syscli/algeria.
4
+
5
+ >>> from syscli_algeria import wilaya, commune, search
6
+ >>> wilaya(16).name_fr
7
+ 'Alger'
8
+ >>> search("bou saada")[0].wilaya.code
9
+ '68'
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import io
16
+ import re
17
+ from dataclasses import dataclass
18
+ from importlib import resources
19
+ from typing import Literal, Optional, Union
20
+
21
+ from ._normalize import distance, normalize
22
+
23
+ __all__ = [
24
+ "Commune",
25
+ "SearchResult",
26
+ "Wilaya",
27
+ "WilayaChange",
28
+ "changes",
29
+ "commune",
30
+ "communes",
31
+ "normalize",
32
+ "search",
33
+ "wilaya",
34
+ "wilayas",
35
+ ]
36
+
37
+ __version__ = "0.1.0"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Wilaya:
42
+ code: str
43
+ """Two-digit code, "01" to "69". Also the vehicle plate number."""
44
+ name_fr: str
45
+ name_ar: str
46
+ created_by: str
47
+ """The law that created the wilaya: "loi 84-09", "loi 19-12" or "loi 26-06"."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Commune:
52
+ code: str
53
+ """Four-digit ONS code, e.g. "1601"."""
54
+ wilaya_code: str
55
+ name_fr: str
56
+ name_ar: str
57
+ daira_1991: Optional[str]
58
+ """Seat of the daira the commune belonged to in decret 91-306 (1991)."""
59
+ wilaya_code_58: str
60
+ """Wilaya under the 58-wilaya map (2019 to 2026)."""
61
+ wilaya_code_48: str
62
+ """Wilaya under the 48-wilaya map (1984 to 2019)."""
63
+ aliases: tuple[str, ...]
64
+ """Older official names and other common spellings."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SearchResult:
69
+ commune: Commune
70
+ wilaya: Wilaya
71
+ score: float
72
+ """0 is an exact match; higher is a weaker match."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WilayaChange:
77
+ commune: Commune
78
+ from_: str
79
+ to: str
80
+
81
+
82
+ def _rows(name: str) -> list[dict[str, str]]:
83
+ text = resources.files(__package__).joinpath("data", name).read_text(encoding="utf-8-sig")
84
+ return list(csv.DictReader(io.StringIO(text)))
85
+
86
+
87
+ _WILAYAS: tuple[Wilaya, ...] = tuple(
88
+ Wilaya(r["code"], r["name_fr"], r["name_ar"], r["created_by"]) for r in _rows("wilayas.csv")
89
+ )
90
+ _COMMUNES: tuple[Commune, ...] = tuple(
91
+ Commune(
92
+ code=r["code"],
93
+ wilaya_code=r["wilaya_code"],
94
+ name_fr=r["name_fr"],
95
+ name_ar=r["name_ar"],
96
+ daira_1991=r["daira_1991"] or None,
97
+ wilaya_code_58=r["wilaya_code_58"],
98
+ wilaya_code_48=r["wilaya_code_48"],
99
+ aliases=tuple(r["aliases"].split("|")) if r["aliases"] else (),
100
+ )
101
+ for r in _rows("communes.csv")
102
+ )
103
+
104
+ _BY_WILAYA = {w.code: w for w in _WILAYAS}
105
+ _BY_COMMUNE = {c.code: c for c in _COMMUNES}
106
+
107
+
108
+ def _keys(c: Commune) -> list[str]:
109
+ seen: dict[str, None] = {}
110
+ for name in (c.name_fr, c.name_ar, *c.aliases):
111
+ k = normalize(name)
112
+ if k:
113
+ seen[k] = None
114
+ return list(seen)
115
+
116
+
117
+ _INDEX = [(c, _keys(c)) for c in _COMMUNES]
118
+
119
+
120
+ _TWO_DIGITS = re.compile("[0-9]{1,2}")
121
+
122
+
123
+ def _code(value: Union[str, int], width: int) -> str:
124
+ return str(value).strip().zfill(width)
125
+
126
+
127
+ def wilayas() -> tuple[Wilaya, ...]:
128
+ """All 69 wilayas, ordered by code."""
129
+ return _WILAYAS
130
+
131
+
132
+ def wilaya(query: Union[str, int]) -> Optional[Wilaya]:
133
+ """A wilaya by code (5, "05") or by name in French or Arabic."""
134
+ if isinstance(query, int) or _TWO_DIGITS.fullmatch(query.strip()):
135
+ return _BY_WILAYA.get(_code(query, 2))
136
+ q = normalize(query)
137
+ for w in _WILAYAS:
138
+ if normalize(w.name_fr) == q or normalize(w.name_ar) == q:
139
+ return w
140
+ return None
141
+
142
+
143
+ def communes(wilaya_code: Union[str, int, None] = None) -> tuple[Commune, ...]:
144
+ """All communes, or the communes of one wilaya."""
145
+ if wilaya_code is None:
146
+ return _COMMUNES
147
+ w = _code(wilaya_code, 2)
148
+ return tuple(c for c in _COMMUNES if c.wilaya_code == w)
149
+
150
+
151
+ def commune(ons_code: Union[str, int]) -> Optional[Commune]:
152
+ """A commune by its four-digit ONS code."""
153
+ return _BY_COMMUNE.get(_code(ons_code, 4))
154
+
155
+
156
+ def search(query: str, limit: int = 10, wilaya: Union[str, int, None] = None) -> list[SearchResult]:
157
+ """Finds communes by name.
158
+
159
+ Accepts French, Arabic, old official names and loose spellings ("setif"
160
+ finds Setif with or without the accent), and tolerates small typos.
161
+ """
162
+ q = normalize(query)
163
+ if not q:
164
+ return []
165
+ only = None if wilaya is None else _code(wilaya, 2)
166
+ results: list[SearchResult] = []
167
+ for c, keys in _INDEX:
168
+ if only and c.wilaya_code != only:
169
+ continue
170
+ best = float("inf")
171
+ for k in keys:
172
+ if k == q:
173
+ s: float = 0
174
+ elif k.startswith(q):
175
+ s = 1 + (len(k) - len(q)) / 100
176
+ elif q in k:
177
+ s = 2 + (len(k) - len(q)) / 100
178
+ else:
179
+ d = distance(q, k)
180
+ s = 3 + d if len(q) >= 4 and d <= max(1, len(q) // 4) else float("inf")
181
+ if s < best:
182
+ best = s
183
+ if best != float("inf"):
184
+ results.append(SearchResult(c, _BY_WILAYA[c.wilaya_code], best))
185
+ results.sort(key=lambda r: (r.score, r.commune.code))
186
+ return results[:limit]
187
+
188
+
189
+ def changes(reform: Literal[2019, 2026] = 2026) -> list[WilayaChange]:
190
+ """Communes whose wilaya changed with a reform.
191
+
192
+ 2026: 58 to 69 wilayas (loi 26-06). 2019: 48 to 58 wilayas (loi 19-12).
193
+ """
194
+ out = []
195
+ for c in _COMMUNES:
196
+ before, after = (c.wilaya_code_58, c.wilaya_code) if reform == 2026 else (c.wilaya_code_48, c.wilaya_code_58)
197
+ if before != after:
198
+ out.append(WilayaChange(c, before, after))
199
+ return out
@@ -0,0 +1,69 @@
1
+ """Folds the many ways a place name gets written into one comparable key.
2
+
3
+ A port of src/normalize.ts; tests/test_parity.py checks that both give the
4
+ same keys for every commune name.
5
+ """
6
+
7
+ import re
8
+ import unicodedata
9
+
10
+ _LATIN = [
11
+ (re.compile(r"\b(el|al|les?|la)\b"), " "),
12
+ (re.compile(r"\bbeni\b|\bbni\b"), "bn"),
13
+ (re.compile(r"\bain\b"), "an"),
14
+ (re.compile(r"dj"), "j"),
15
+ (re.compile(r"ou"), "u"),
16
+ (re.compile(r"ch|sh"), "c"),
17
+ (re.compile(r"kh"), "k"),
18
+ (re.compile(r"gh"), "g"),
19
+ (re.compile(r"th"), "t"),
20
+ (re.compile(r"dh"), "d"),
21
+ (re.compile(r"q"), "k"),
22
+ (re.compile(r"y"), "i"),
23
+ (re.compile(r"e"), "a"),
24
+ (re.compile(r"([a-z])\1+"), r"\1"),
25
+ ]
26
+
27
+ _ARABIC = [
28
+ (re.compile("[\u064b-\u065f\u0670\u0640]"), ""),
29
+ (re.compile("[\u0622\u0623\u0625\u0671]"), "\u0627"),
30
+ (re.compile("\u0649"), "\u064a"),
31
+ (re.compile("\u0629"), "\u0647"),
32
+ (re.compile("\u0624"), "\u0648"),
33
+ (re.compile("\u0626"), "\u064a"),
34
+ (re.compile("(^|\\s)\u0627\u0644"), "\\1"),
35
+ ]
36
+
37
+ _HAS_ARABIC = re.compile("[\u0600-\u06ff]")
38
+ _NOT_ARABIC = re.compile("[^\u0600-\u06ff]")
39
+ _MARKS = re.compile("[\u0300-\u036f]")
40
+ _NOT_LATIN = re.compile("[^a-z]+")
41
+ _SPACES = re.compile(r"\s+")
42
+
43
+
44
+ def normalize(text: str) -> str:
45
+ """Returns the comparison key of a place name, in French or Arabic."""
46
+ s = unicodedata.normalize("NFKD", text).lower()
47
+ if _HAS_ARABIC.search(s):
48
+ for pattern, repl in _ARABIC:
49
+ s = pattern.sub(repl, s)
50
+ return _NOT_ARABIC.sub("", s)
51
+ s = _NOT_LATIN.sub(" ", _MARKS.sub("", s)).strip()
52
+ for pattern, repl in _LATIN:
53
+ s = pattern.sub(repl, s)
54
+ return _SPACES.sub("", s)
55
+
56
+
57
+ def distance(a: str, b: str) -> int:
58
+ """Levenshtein distance, or 99 when the lengths differ by more than 3."""
59
+ if abs(len(a) - len(b)) > 3:
60
+ return 99
61
+ row = list(range(len(b) + 1))
62
+ for i in range(1, len(a) + 1):
63
+ prev = row[0]
64
+ row[0] = i
65
+ for j in range(1, len(b) + 1):
66
+ tmp = row[j]
67
+ row[j] = min(row[j] + 1, row[j - 1] + 1, prev + (0 if a[i - 1] == b[j - 1] else 1))
68
+ prev = tmp
69
+ return row[len(b)]