rain1024 Claude Opus 4.6 commited on
Commit
efd7cfc
·
1 Parent(s): 5a0d16f

Add Vietnamese address converter for post-merger admin units (01/07/2025)

Browse files

Converts addresses from old 63-province 3-level system (Province > District > Ward)
to new 34-province 2-level system (Province > Ward) with 10,602 ward mapping records.
Supports abbreviation expansion, 2-tier matching, CLI and batch CSV conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
README.md CHANGED
@@ -1,3 +1,344 @@
1
  ---
2
  license: apache-2.0
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
  ---
4
+
5
+ # address - Vietnamese Address Converter
6
+
7
+ Chuyển đổi địa chỉ Việt Nam từ hệ thống hành chính cũ (63 tỉnh/thành phố, 3 cấp) sang hệ thống mới sau sáp nhập (34 tỉnh/thành phố, 2 cấp), có hiệu lực từ 01/07/2025.
8
+
9
+ ## Technical Report
10
+
11
+ ### 1. Bối cảnh
12
+
13
+ Nghị quyết của Quốc hội về sáp nhập đơn vị hành chính có hiệu lực ngày 01/07/2025, thay đổi cấu trúc hành chính Việt Nam:
14
+
15
+ | | Cũ | Mới |
16
+ |---|---|---|
17
+ | Tỉnh/Thành phố | 63 | 34 |
18
+ | Cấp hành chính | Tỉnh > Huyện > Xã | Tỉnh > Xã |
19
+ | Tổng số xã/phường | ~10,600 | ~3,300 |
20
+
21
+ Cấp huyện (quận/huyện/thị xã/thành phố trực thuộc tỉnh) bị loại bỏ hoàn toàn khỏi địa chỉ hành chính. Các xã/phường được sáp nhập, đổi tên, chia tách hoặc giữ nguyên tùy khu vực.
22
+
23
+ ### 2. Kiến trúc hệ thống
24
+
25
+ ```
26
+ address/
27
+ ├── pyproject.toml # Package config (uv + hatchling)
28
+ ├── data/
29
+ │ └── mapping.json # 10,602 bản ghi mapping (4.7 MB)
30
+ ├── src/
31
+ │ ├── __init__.py # Public API
32
+ │ ├── models.py # Data models & enums
33
+ │ ├── normalizer.py # Chuẩn hóa tiếng Việt
34
+ │ ├── parser.py # Phân tích chuỗi địa chỉ
35
+ │ ├── converter.py # Logic chuyển đổi chính
36
+ │ └── cli.py # CLI (click)
37
+ ├── scripts/
38
+ │ └── build_mapping.py # Tạo mapping.json từ vietnamadminunits
39
+ └── tests/
40
+ └── test_converter.py # 13 test cases
41
+ ```
42
+
43
+ **Nguyên tắc thiết kế**: Package hoạt động standalone — dữ liệu mapping được extract một lần từ `vietnamadminunits` (dev dependency) vào `data/mapping.json`, sau đó runtime chỉ cần `click` là dependency duy nhất.
44
+
45
+ ### 3. Nguồn dữ liệu
46
+
47
+ Dữ liệu mapping được extract từ package `vietnamadminunits` v1.0.4 (PyPI, MIT license) thông qua script `scripts/build_mapping.py`. Package này cung cấp 3 file JSON chính:
48
+
49
+ | File | Nội dung | Kích thước |
50
+ |---|---|---|
51
+ | `converter_2025.json` | Mapping cũ → mới (province + ward) | 596 KB |
52
+ | `parser_legacy.json` | Dữ liệu hành chính cũ (63 tỉnh, 3 cấp) | 2.6 MB |
53
+ | `parser_from_2025.json` | Dữ liệu hành chính mới (34 tỉnh, 2 cấp) | 957 KB |
54
+
55
+ **Cấu trúc dữ liệu gốc** (trong `converter_2025.json`):
56
+
57
+ - `DICT_PROVINCE`: `{new_province_key: [old_province_key_1, old_province_key_2, ...]}` — mapping tỉnh
58
+ - `DICT_PROVINCE_WARD_NO_DIVIDED`: `{new_prov: {new_ward: [old_compound_key, ...]}}` — xã không bị chia tách
59
+ - `DICT_PROVINCE_WARD_DIVIDED`: `{new_prov: {old_compound_key: [{newWardKey, isDefaultNewWard, ...}]}}` — xã bị chia tách
60
+
61
+ Compound key format: `{province_key}_{district_key}_{ward_key}` (ví dụ: `thanhphohanoi_quanbadinh_phuongphucxa`).
62
+
63
+ ### 4. Build mapping (`scripts/build_mapping.py`)
64
+
65
+ Script extract và chuẩn hóa dữ liệu từ 3 file JSON của vietnamadminunits thành một file `data/mapping.json` thống nhất:
66
+
67
+ ```
68
+ uv run python scripts/build_mapping.py
69
+ ```
70
+
71
+ **Output `mapping.json`** chứa:
72
+
73
+ | Trường | Mô tả |
74
+ |---|---|
75
+ | `metadata` | Source, version, effective_date, thống kê |
76
+ | `province_mapping` | `{old_key: new_key}` — 63 entries |
77
+ | `province_names` | `{key: {name, short, code}}` — 34 tỉnh mới |
78
+ | `old_province_names` | `{key: {name, short, code}}` — 63 tỉnh cũ |
79
+ | `ward_mapping` | List 10,602 records chi tiết |
80
+
81
+ **Mỗi ward mapping record** gồm:
82
+
83
+ ```json
84
+ {
85
+ "old_province": "Thành phố Hà Nội",
86
+ "old_province_key": "thanhphohanoi",
87
+ "old_district": "Quận Ba Đình",
88
+ "old_district_key": "quanbadinh",
89
+ "old_ward": "Phường Phúc Xá",
90
+ "old_ward_key": "phuongphucxa",
91
+ "new_province": "Thành phố Hà Nội",
92
+ "new_province_key": "thanhphohanoi",
93
+ "new_ward": "Phường Hồng Hà",
94
+ "new_ward_key": "phuonghongha",
95
+ "mapping_type": "merged"
96
+ }
97
+ ```
98
+
99
+ **Phân loại mapping type:**
100
+
101
+ | Type | Số lượng | Mô tả |
102
+ |---|---|---|
103
+ | `unchanged` | 149 | Xã giữ nguyên tên |
104
+ | `renamed` | 92 | Xã đổi tên (1:1) |
105
+ | `merged` | 9,328 | Nhiều xã cũ gộp thành 1 xã mới |
106
+ | `divided` | 1,033 | 1 xã cũ chia thành nhiều xã mới |
107
+ | **Tổng** | **10,602** | |
108
+
109
+ **Logic phân loại:**
110
+ - `unchanged`: Chỉ có 1 compound key cũ trỏ vào ward mới VÀ tên trùng nhau
111
+ - `renamed`: Chỉ có 1 compound key cũ trỏ vào ward mới VÀ tên khác nhau
112
+ - `merged`: Nhiều compound key cũ cùng trỏ vào 1 ward mới
113
+ - `divided`: Từ `DICT_PROVINCE_WARD_DIVIDED`, có `is_default` flag cho mỗi option
114
+
115
+ ### 5. Modules
116
+
117
+ #### 5.1 `src/models.py` — Data Models
118
+
119
+ - `MappingType(str, Enum)`: `UNCHANGED`, `RENAMED`, `MERGED`, `DIVIDED`
120
+ - `ConversionStatus(str, Enum)`: `SUCCESS`, `PARTIAL`, `NOT_FOUND`
121
+ - `AdminUnit(@dataclass)`: `province`, `district`, `ward`, `street` + `to_address()`
122
+ - `ConversionResult(@dataclass)`: `original`, `converted`, `status`, `mapping_type`, `old`, `new`, `note`
123
+
124
+ #### 5.2 `src/normalizer.py` — Chuẩn hóa tiếng Việt
125
+
126
+ **Ba hàm chính:**
127
+
128
+ | Hàm | Input | Output | Mô tả |
129
+ |---|---|---|---|
130
+ | `remove_diacritics()` | `"Phường Phúc Xá"` | `"Phuong Phuc Xa"` | Unicode NFKD decomposition + xử lý đ/Đ |
131
+ | `normalize_key()` | `"Quận Ba Đình"` | `"quanbadinh"` | Lowercase + bỏ dấu + bỏ space/punctuation |
132
+ | `expand_abbreviations()` | `"P.Phúc Xá, Q.Ba Đình"` | `"phường phúc xá, quận ba đình"` | Mở rộng viết tắt |
133
+
134
+ **Bảng viết tắt hỗ trợ:**
135
+
136
+ | Viết tắt | Đầy đủ |
137
+ |---|---|
138
+ | `TP.`, `T.P.` | Thành phố |
139
+ | `P.` | Phường |
140
+ | `Q.` | Quận |
141
+ | `H.` | Huyện |
142
+ | `TX.`, `T.X.` | Thị xã |
143
+ | `TT.`, `T.T.` | Thị trấn |
144
+ | `X.` | Xã |
145
+
146
+ #### 5.3 `src/parser.py` — Phân tích địa chỉ
147
+
148
+ Parse chuỗi địa chỉ theo format `"street, ward, district, province"` bằng phương pháp **right-to-left positional assignment**:
149
+
150
+ ```
151
+ "123 Hàng Bông, Phường Phúc Xá, Quận Ba Đình, TP Hà Nội"
152
+ ↑ ↑ ↑ ↑
153
+ street ward district province
154
+ (parts[:-3]) (parts[-3]) (parts[-2]) (parts[-1])
155
+ ```
156
+
157
+ Xử lý đặc biệt cho địa chỉ 2 phần (`"ward, province"`): kiểm tra prefix phường/xã/thị trấn để phân biệt với district.
158
+
159
+ #### 5.4 `src/converter.py` — Logic chuyển đổi
160
+
161
+ **Khởi tạo (lazy load):**
162
+ - Load `data/mapping.json` lần đầu khi gọi `convert_address()`
163
+ - Build index một lần, cache trong module-level globals
164
+
165
+ **Index structure:**
166
+
167
+ | Index | Key | Value | Mục đích |
168
+ |---|---|---|---|
169
+ | `province` | `old_prov_key` | `new_prov_key` | Tra cứu tỉnh |
170
+ | `province_keywords` | `normalized_name` | `old_prov_key` | Fuzzy match tên tỉnh |
171
+ | `exact` | `(prov, dist, ward)` | `[records]` | Tra cứu chính xác |
172
+ | `ward_only` | `(prov, ward)` | `[records]` | Tra cứu bỏ qua quận/huyện |
173
+
174
+ **Luồng chuyển đổi (`convert_address`):**
175
+
176
+ ```
177
+ Input: "Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội"
178
+
179
+ ├─ 1. parse_address() → AdminUnit(ward, district, province)
180
+
181
+ ├─ 2. Resolve province
182
+ │ normalize("Thành phố Hà Nội") → "thanhphohanoi"
183
+ │ province_mapping["thanhphohanoi"] → "thanhphohanoi"
184
+ │ ❌ Không tìm thấy → NOT_FOUND
185
+
186
+ ├─ 3. Tra cứu ward (2-tier matching)
187
+ │ ├─ Tier 1: exact("thanhphohanoi", "quanbadinh", "phuongphucxa") ✅
188
+ │ └─ Tier 2: ward_only("thanhphohanoi", "phuongphucxa") (fallback)
189
+
190
+ ├─ 4. Chọn bản ghi tốt nhất
191
+ │ └─ Nếu divided: ưu tiên is_default=true
192
+
193
+ └─ 5. Build result
194
+ ConversionResult(converted="Phường Hồng Hà, Thành phố Hà Nội",
195
+ status=SUCCESS, mapping_type=MERGED)
196
+ ```
197
+
198
+ **Conversion status:**
199
+
200
+ | Status | Điều kiện |
201
+ |---|---|
202
+ | `SUCCESS` | Tìm thấy cả province + ward mapping |
203
+ | `PARTIAL` | Chỉ tìm thấy province (ward không match) |
204
+ | `NOT_FOUND` | Không nhận dạng được province |
205
+
206
+ #### 5.5 `src/cli.py` — Command Line Interface
207
+
208
+ Hai commands, sử dụng `click`:
209
+
210
+ ```bash
211
+ # Chuyển đổi 1 địa chỉ
212
+ address-convert convert "Phường Phúc Xá, Quận Ba Đình, TP Hà Nội"
213
+
214
+ # Chuyển đổi hàng loạt từ CSV
215
+ address-convert batch input.csv output.csv --column address
216
+ ```
217
+
218
+ Batch mode đọc CSV, thêm 3 cột vào output: `converted_address`, `conversion_status`, `mapping_type`.
219
+
220
+ ### 6. Test Results
221
+
222
+ 13 test cases, tất cả pass:
223
+
224
+ ```
225
+ tests/test_converter.py::TestMergedWard::test_phuc_xa_merged_to_hong_ha PASSED
226
+ tests/test_converter.py::TestMergedWard::test_an_binh_can_tho PASSED
227
+ tests/test_converter.py::TestUnchangedWard::test_tan_loc_can_tho PASSED
228
+ tests/test_converter.py::TestRenamedWard::test_long_hoa_renamed PASSED
229
+ tests/test_converter.py::TestDividedWard::test_divided_selects_default PASSED
230
+ tests/test_converter.py::TestAbbreviations::test_p_q_tp PASSED
231
+ tests/test_converter.py::TestAbbreviations::test_tp_shorthand PASSED
232
+ tests/test_converter.py::TestPartialAddress::test_province_only PASSED
233
+ tests/test_converter.py::TestPartialAddress::test_unknown_province PASSED
234
+ tests/test_converter.py::TestWithStreet::test_street_preserved PASSED
235
+ tests/test_converter.py::TestBatchConvert::test_batch PASSED
236
+ tests/test_converter.py::TestProvinceMapping::test_ha_noi_stays PASSED
237
+ tests/test_converter.py::TestProvinceMapping::test_merged_province PASSED
238
+ ```
239
+
240
+ **Coverage theo loại test:**
241
+
242
+ | Nhóm test | Kiểm tra |
243
+ |---|---|
244
+ | Merged ward | Nhiều xã cũ → 1 xã mới (Phúc Xá → Hồng Hà) |
245
+ | Unchanged ward | Xã giữ nguyên (Tân Lộc) |
246
+ | Renamed ward | Xã đổi tên (Long Hòa → Long Tuyền) |
247
+ | Divided ward | Xã chia tách, chọn default |
248
+ | Abbreviations | `P.`, `Q.`, `TP.` mở rộng đúng |
249
+ | Partial address | Chỉ tỉnh, tỉnh không tồn tại |
250
+ | Street preserved | Giữ nguyên phần đường khi chuyển đổi |
251
+ | Batch convert | Chuyển đổi hàng loạt, mixed results |
252
+ | Province mapping | Tỉnh giữ nguyên, tỉnh sáp nhập |
253
+
254
+ ### 7. Danh sách 34 tỉnh/thành phố mới
255
+
256
+ | Mã | Tên |
257
+ |---|---|
258
+ | 01 | Thành phố Hà Nội |
259
+ | 04 | Tỉnh Cao Bằng |
260
+ | 08 | Tỉnh Tuyên Quang |
261
+ | 11 | Tỉnh Điện Biên |
262
+ | 12 | Tỉnh Lai Châu |
263
+ | 14 | Tỉnh Sơn La |
264
+ | 15 | Tỉnh Lào Cai |
265
+ | 19 | Tỉnh Thái Nguyên |
266
+ | 20 | Tỉnh Lạng Sơn |
267
+ | 22 | Tỉnh Quảng Ninh |
268
+ | 24 | Tỉnh Bắc Ninh |
269
+ | 25 | Tỉnh Phú Thọ |
270
+ | 31 | Thành phố Hải Phòng |
271
+ | 33 | Tỉnh Hưng Yên |
272
+ | 37 | Tỉnh Ninh Bình |
273
+ | 38 | Tỉnh Thanh Hóa |
274
+ | 40 | Tỉnh Nghệ An |
275
+ | 42 | Tỉnh Hà Tĩnh |
276
+ | 44 | Tỉnh Quảng Trị |
277
+ | 46 | Thành phố Huế |
278
+ | 48 | Thành phố Đà Nẵng |
279
+ | 51 | Tỉnh Quảng Ngãi |
280
+ | 52 | Tỉnh Gia Lai |
281
+ | 56 | Tỉnh Khánh Hòa |
282
+ | 66 | Tỉnh Đắk Lắk |
283
+ | 68 | Tỉnh Lâm Đồng |
284
+ | 75 | Tỉnh Đồng Nai |
285
+ | 79 | Thành phố Hồ Chí Minh |
286
+ | 80 | Tỉnh Tây Ninh |
287
+ | 82 | Tỉnh Đồng Tháp |
288
+ | 86 | Tỉnh Vĩnh Long |
289
+ | 91 | Tỉnh An Giang |
290
+ | 92 | Thành phố Cần Thơ |
291
+ | 96 | Tỉnh Cà Mau |
292
+
293
+ ### 8. Cách sử dụng
294
+
295
+ #### Python API
296
+
297
+ ```python
298
+ from src import convert_address, batch_convert
299
+
300
+ # Chuyển đổi 1 địa chỉ
301
+ result = convert_address("Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội")
302
+ print(result.converted) # "Phường Hồng Hà, Thành phố Hà Nội"
303
+ print(result.status) # ConversionStatus.SUCCESS
304
+ print(result.mapping_type) # MappingType.MERGED
305
+
306
+ # Hỗ trợ viết tắt
307
+ result = convert_address("P. Phúc Xá, Q. Ba Đình, TP. Hà Nội")
308
+ print(result.converted) # "Phường Hồng Hà, Thành phố Hà Nội"
309
+
310
+ # Batch
311
+ results = batch_convert(["addr1", "addr2", "addr3"])
312
+ ```
313
+
314
+ #### CLI
315
+
316
+ ```bash
317
+ # Cài đặt
318
+ uv sync
319
+
320
+ # Chuyển đổi 1 địa chỉ
321
+ uv run address-convert convert "P. Phúc Xá, Q. Ba Đình, TP Hà Nội"
322
+
323
+ # Chuyển đổi CSV
324
+ uv run address-convert batch input.csv output.csv --column address
325
+ ```
326
+
327
+ #### Cập nhật mapping data
328
+
329
+ ```bash
330
+ uv run python scripts/build_mapping.py
331
+ ```
332
+
333
+ ### 9. Hạn chế và hướng phát triển
334
+
335
+ **Hạn chế hiện tại:**
336
+ - Xã bị chia tách (`divided`): chỉ chọn ward mặc định (`is_default`), không hỗ trợ geocoding để chọn chính xác
337
+ - Parser dựa vào vị trí (right-to-left), có thể sai với địa chỉ không chuẩn format
338
+ - Chưa hỗ trợ input không dấu hoàn toàn (ví dụ: "Phuong Phuc Xa")
339
+
340
+ **Hướng phát triển:**
341
+ - Thêm geocoding cho divided wards (dựa trên tọa độ đường phố)
342
+ - Hỗ trợ input không dấu bằng fuzzy matching trên normalized keys
343
+ - Thêm REST API endpoint
344
+ - Tích hợp với pandas DataFrame cho batch processing lớn
data/mapping.json ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "address"
3
+ version = "0.1.0"
4
+ description = "Vietnamese address converter for post-merger administrative units (01/07/2025)"
5
+ requires-python = ">=3.12"
6
+ dependencies = ["click"]
7
+
8
+ [project.scripts]
9
+ address-convert = "src.cli:main"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src", "data"]
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest", "vietnamadminunits"]
scripts/build_mapping.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Script to extract mapping data from vietnamadminunits package
3
+ and generate data/mapping.json for standalone use.
4
+
5
+ Usage:
6
+ uv run python scripts/build_mapping.py
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ def build_mapping():
14
+ import vietnamadminunits
15
+
16
+ pkg_dir = Path(vietnamadminunits.__file__).parent
17
+
18
+ # Load source data
19
+ with open(pkg_dir / "data" / "converter_2025.json") as f:
20
+ converter = json.load(f)
21
+ with open(pkg_dir / "data" / "parser_legacy.json") as f:
22
+ legacy = json.load(f)
23
+ with open(pkg_dir / "data" / "parser_from_2025.json") as f:
24
+ new_parser = json.load(f)
25
+
26
+ # === Province mapping: old_key -> new_key ===
27
+ # converter DICT_PROVINCE: {new_key: [old_key1, old_key2, ...]}
28
+ province_mapping = {}
29
+ for new_key, old_keys in converter["DICT_PROVINCE"].items():
30
+ for old_key in old_keys:
31
+ province_mapping[old_key] = new_key
32
+
33
+ # === Province info: key -> display name ===
34
+ province_names = {}
35
+ for key, info in new_parser["DICT_PROVINCE"].items():
36
+ province_names[key] = {
37
+ "name": info["province"],
38
+ "short": info["provinceShort"],
39
+ "code": info["provinceCode"],
40
+ }
41
+
42
+ old_province_names = {}
43
+ for key, info in legacy["DICT_PROVINCE"].items():
44
+ old_province_names[key] = {
45
+ "name": info["province"],
46
+ "short": info["provinceShort"],
47
+ "code": info["provinceCode"],
48
+ }
49
+
50
+ # === New ward info: province_key -> ward_key -> display name ===
51
+ new_ward_names = {}
52
+ for prov_key, wards in new_parser["DICT_PROVINCE_WARD_NO_ACCENTED"].items():
53
+ new_ward_names[prov_key] = {}
54
+ for ward_key, info in wards.items():
55
+ new_ward_names[prov_key][ward_key] = {
56
+ "name": info["ward"],
57
+ "short": info["wardShort"],
58
+ "type": info["wardType"],
59
+ "code": info["wardCode"],
60
+ }
61
+
62
+ # === Old ward info: province_key -> district_key -> ward_key -> display name ===
63
+ old_ward_names = {}
64
+ for prov_key, districts in legacy["DICT_PROVINCE_DISTRICT_WARD_NO_ACCENTED"].items():
65
+ old_ward_names[prov_key] = {}
66
+ for dist_key, wards in districts.items():
67
+ for ward_key, info in wards.items():
68
+ old_ward_names[prov_key][f"{prov_key}_{dist_key}_{ward_key}"] = {
69
+ "name": info["ward"],
70
+ "short": info["wardShort"],
71
+ "type": info["wardType"],
72
+ "code": info["wardCode"],
73
+ }
74
+
75
+ # === Old district info ===
76
+ old_district_names = {}
77
+ for prov_key, districts in legacy.get("DICT_PROVINCE_DISTRICT", {}).items():
78
+ old_district_names[prov_key] = {}
79
+ for dist_key, info in districts.items():
80
+ old_district_names[prov_key][dist_key] = {
81
+ "name": info.get("district", ""),
82
+ "short": info.get("districtShort", ""),
83
+ "type": info.get("districtType", ""),
84
+ }
85
+
86
+ # === Ward mapping records ===
87
+ ward_mapping = []
88
+
89
+ # NO_DIVIDED: each new ward maps to one or more old wards (unchanged or renamed/merged)
90
+ for new_prov_key, wards in converter["DICT_PROVINCE_WARD_NO_DIVIDED"].items():
91
+ new_prov_info = province_names.get(new_prov_key, {})
92
+
93
+ for new_ward_key, old_compound_keys in wards.items():
94
+ new_ward_info = new_ward_names.get(new_prov_key, {}).get(new_ward_key, {})
95
+
96
+ for old_compound_key in old_compound_keys:
97
+ # Parse old compound key: "old_prov_key_old_dist_key_old_ward_key"
98
+ parts = old_compound_key.split("_", 2)
99
+ if len(parts) < 2:
100
+ continue
101
+ old_prov_key = parts[0]
102
+ rest = "_".join(parts[1:]) if len(parts) > 1 else ""
103
+
104
+ # Find old ward info
105
+ old_full_key = old_compound_key
106
+ old_ward_info = {}
107
+ old_dist_info = {}
108
+
109
+ # Find in old_ward_names
110
+ if old_prov_key in old_ward_names:
111
+ old_ward_info = old_ward_names[old_prov_key].get(old_full_key, {})
112
+
113
+ # Parse district key from compound
114
+ if len(parts) == 3:
115
+ old_dist_key = parts[1]
116
+ old_ward_key_str = parts[2]
117
+ if old_prov_key in old_district_names:
118
+ old_dist_info = old_district_names[old_prov_key].get(old_dist_key, {})
119
+ elif len(parts) == 2:
120
+ old_dist_key = parts[1]
121
+ old_ward_key_str = ""
122
+ if old_prov_key in old_district_names:
123
+ old_dist_info = old_district_names[old_prov_key].get(old_dist_key, {})
124
+
125
+ # Determine mapping type
126
+ if len(old_compound_keys) == 1:
127
+ # Only one old ward maps to this new ward
128
+ if old_ward_info.get("name") == new_ward_info.get("name"):
129
+ mapping_type = "unchanged"
130
+ else:
131
+ mapping_type = "renamed"
132
+ else:
133
+ mapping_type = "merged"
134
+
135
+ record = {
136
+ "old_province": old_province_names.get(old_prov_key, {}).get("name", ""),
137
+ "old_province_key": old_prov_key,
138
+ "old_district": old_dist_info.get("name", ""),
139
+ "old_district_key": parts[1] if len(parts) >= 2 else "",
140
+ "old_ward": old_ward_info.get("name", ""),
141
+ "old_ward_key": old_ward_key_str if len(parts) == 3 else "",
142
+ "new_province": new_prov_info.get("name", ""),
143
+ "new_province_key": new_prov_key,
144
+ "new_ward": new_ward_info.get("name", ""),
145
+ "new_ward_key": new_ward_key,
146
+ "mapping_type": mapping_type,
147
+ }
148
+ ward_mapping.append(record)
149
+
150
+ # DIVIDED: old wards split into multiple new wards
151
+ for new_prov_key, old_wards in converter["DICT_PROVINCE_WARD_DIVIDED"].items():
152
+ new_prov_info = province_names.get(new_prov_key, {})
153
+
154
+ for old_compound_key, new_ward_options in old_wards.items():
155
+ parts = old_compound_key.split("_", 2)
156
+ if len(parts) < 2:
157
+ continue
158
+ old_prov_key = parts[0]
159
+
160
+ old_ward_info = {}
161
+ old_dist_info = {}
162
+ if old_prov_key in old_ward_names:
163
+ old_ward_info = old_ward_names[old_prov_key].get(old_compound_key, {})
164
+ if len(parts) >= 2 and old_prov_key in old_district_names:
165
+ old_dist_info = old_district_names[old_prov_key].get(parts[1], {})
166
+
167
+ for option in new_ward_options:
168
+ new_ward_key = option["newWardKey"]
169
+ new_ward_info = new_ward_names.get(new_prov_key, {}).get(new_ward_key, {})
170
+
171
+ record = {
172
+ "old_province": old_province_names.get(old_prov_key, {}).get("name", ""),
173
+ "old_province_key": old_prov_key,
174
+ "old_district": old_dist_info.get("name", ""),
175
+ "old_district_key": parts[1] if len(parts) >= 2 else "",
176
+ "old_ward": old_ward_info.get("name", ""),
177
+ "old_ward_key": parts[2] if len(parts) == 3 else "",
178
+ "new_province": new_prov_info.get("name", ""),
179
+ "new_province_key": new_prov_key,
180
+ "new_ward": new_ward_info.get("name", ""),
181
+ "new_ward_key": new_ward_key,
182
+ "mapping_type": "divided",
183
+ "is_default": option.get("isDefaultNewWard", False),
184
+ }
185
+ ward_mapping.append(record)
186
+
187
+ # Build final mapping
188
+ mapping = {
189
+ "metadata": {
190
+ "source": "vietnamadminunits",
191
+ "version": "1.0.4",
192
+ "effective_date": "2025-07-01",
193
+ "old_provinces": len(old_province_names),
194
+ "new_provinces": len(province_names),
195
+ "total_records": len(ward_mapping),
196
+ },
197
+ "province_mapping": province_mapping,
198
+ "province_names": province_names,
199
+ "old_province_names": old_province_names,
200
+ "ward_mapping": ward_mapping,
201
+ }
202
+
203
+ output = Path(__file__).parent.parent / "data" / "mapping.json"
204
+ output.parent.mkdir(parents=True, exist_ok=True)
205
+ with open(output, "w", encoding="utf-8") as f:
206
+ json.dump(mapping, f, ensure_ascii=False, indent=2)
207
+
208
+ print(f"Generated {output}")
209
+ print(f" Province mappings: {len(province_mapping)} old -> {len(province_names)} new")
210
+ print(f" Ward mapping records: {len(ward_mapping)}")
211
+
212
+ # Stats
213
+ types = {}
214
+ for r in ward_mapping:
215
+ t = r["mapping_type"]
216
+ types[t] = types.get(t, 0) + 1
217
+ for t, c in sorted(types.items()):
218
+ print(f" {t}: {c}")
219
+
220
+
221
+ if __name__ == "__main__":
222
+ build_mapping()
src/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vietnamese address converter for post-merger administrative units (01/07/2025)."""
2
+
3
+ from .converter import convert_address, batch_convert
4
+ from .models import ConversionResult, ConversionStatus, MappingType, AdminUnit
5
+
6
+ __all__ = [
7
+ "convert_address",
8
+ "batch_convert",
9
+ "ConversionResult",
10
+ "ConversionStatus",
11
+ "MappingType",
12
+ "AdminUnit",
13
+ ]
src/cli.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI interface for address conversion."""
2
+
3
+ import csv
4
+ import sys
5
+
6
+ import click
7
+
8
+ from .converter import convert_address, batch_convert
9
+
10
+
11
+ @click.group()
12
+ def main():
13
+ """Vietnamese address converter (post-merger 01/07/2025)."""
14
+ pass
15
+
16
+
17
+ @main.command()
18
+ @click.argument("address")
19
+ def convert(address):
20
+ """Convert a single address.
21
+
22
+ Example: address-convert convert "Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội"
23
+ """
24
+ result = convert_address(address)
25
+ click.echo(f"Input: {result.original}")
26
+ click.echo(f"Output: {result.converted}")
27
+ click.echo(f"Status: {result.status.value}")
28
+ if result.mapping_type:
29
+ click.echo(f"Type: {result.mapping_type.value}")
30
+ if result.note:
31
+ click.echo(f"Note: {result.note}")
32
+
33
+
34
+ @main.command()
35
+ @click.argument("input_file", type=click.Path(exists=True))
36
+ @click.argument("output_file", type=click.Path())
37
+ @click.option("--column", "-c", default="address", help="Column name containing addresses")
38
+ def batch(input_file, output_file, column):
39
+ """Convert addresses from a CSV file.
40
+
41
+ Reads INPUT_FILE CSV, converts the address column, writes to OUTPUT_FILE.
42
+ """
43
+ with open(input_file, newline="", encoding="utf-8") as f:
44
+ reader = csv.DictReader(f)
45
+ if column not in reader.fieldnames:
46
+ click.echo(f"Error: Column '{column}' not found. Available: {reader.fieldnames}", err=True)
47
+ sys.exit(1)
48
+
49
+ addresses = []
50
+ rows = []
51
+ for row in reader:
52
+ rows.append(row)
53
+ addresses.append(row[column])
54
+
55
+ results = batch_convert(addresses)
56
+
57
+ fieldnames = list(rows[0].keys()) + ["converted_address", "conversion_status", "mapping_type"]
58
+ with open(output_file, "w", newline="", encoding="utf-8") as f:
59
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
60
+ writer.writeheader()
61
+ for row, result in zip(rows, results):
62
+ row["converted_address"] = result.converted
63
+ row["conversion_status"] = result.status.value
64
+ row["mapping_type"] = result.mapping_type.value if result.mapping_type else ""
65
+ writer.writerow(row)
66
+
67
+ # Summary
68
+ total = len(results)
69
+ success = sum(1 for r in results if r.status.value == "success")
70
+ partial = sum(1 for r in results if r.status.value == "partial")
71
+ not_found = sum(1 for r in results if r.status.value == "not_found")
72
+ click.echo(f"Converted {total} addresses: {success} success, {partial} partial, {not_found} not found")
73
+ click.echo(f"Output: {output_file}")
src/converter.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core address conversion logic."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .models import AdminUnit, ConversionResult, ConversionStatus, MappingType
7
+ from .normalizer import normalize_key, normalize_for_matching
8
+ from .parser import parse_address
9
+
10
+ # Load mapping data
11
+ _DATA_PATH = Path(__file__).parent.parent / "data" / "mapping.json"
12
+ _mapping_data = None
13
+ _index = None
14
+
15
+
16
+ def _load_data():
17
+ global _mapping_data, _index
18
+ if _mapping_data is not None:
19
+ return
20
+
21
+ with open(_DATA_PATH, encoding="utf-8") as f:
22
+ _mapping_data = json.load(f)
23
+
24
+ _index = _build_index(_mapping_data)
25
+
26
+
27
+ def _build_index(data: dict) -> dict:
28
+ """Build lookup indices for fast matching."""
29
+ index = {
30
+ # old_province_key -> new_province_key
31
+ "province": data["province_mapping"],
32
+ # province_names for display
33
+ "province_names": data["province_names"],
34
+ "old_province_names": data["old_province_names"],
35
+ # Exact match: (old_prov_key, old_dist_key, old_ward_key) -> list of records
36
+ "exact": {},
37
+ # Fuzzy: (old_prov_key, old_ward_key) -> list of records (ignoring district)
38
+ "ward_only": {},
39
+ # Province keyword lookup: normalized_name -> province_key
40
+ "province_keywords": {},
41
+ }
42
+
43
+ # Build province keyword index
44
+ for key, info in data["old_province_names"].items():
45
+ index["province_keywords"][normalize_key(info["name"])] = key
46
+ index["province_keywords"][normalize_key(info["short"])] = key
47
+ index["province_keywords"][key] = key
48
+
49
+ # Build ward indices
50
+ for record in data["ward_mapping"]:
51
+ prov_key = record["old_province_key"]
52
+ dist_key = record["old_district_key"]
53
+ ward_key = record["old_ward_key"]
54
+
55
+ # Exact match index
56
+ exact_key = (prov_key, dist_key, ward_key)
57
+ index["exact"].setdefault(exact_key, []).append(record)
58
+
59
+ # Ward-only index (for matching without district)
60
+ wo_key = (prov_key, ward_key)
61
+ index["ward_only"].setdefault(wo_key, []).append(record)
62
+
63
+ return index
64
+
65
+
66
+ def _resolve_province(text: str) -> str | None:
67
+ """Resolve a province string to its key."""
68
+ normalized = normalize_for_matching(text)
69
+ return _index["province_keywords"].get(normalized)
70
+
71
+
72
+ def _find_mapping(old_prov_key: str, old_dist_key: str, old_ward_key: str) -> list[dict]:
73
+ """Find mapping records for given old admin unit keys."""
74
+ # Tier 1: Exact match (province + district + ward)
75
+ exact_key = (old_prov_key, old_dist_key, old_ward_key)
76
+ records = _index["exact"].get(exact_key, [])
77
+ if records:
78
+ return records
79
+
80
+ # Tier 2: Ward-only match (province + ward, ignoring district)
81
+ wo_key = (old_prov_key, old_ward_key)
82
+ records = _index["ward_only"].get(wo_key, [])
83
+ if records:
84
+ return records
85
+
86
+ return []
87
+
88
+
89
+ def _select_best_record(records: list[dict]) -> dict | None:
90
+ """Select the best record from multiple matches."""
91
+ if not records:
92
+ return None
93
+ if len(records) == 1:
94
+ return records[0]
95
+
96
+ # For divided wards, prefer the default
97
+ for r in records:
98
+ if r.get("is_default"):
99
+ return r
100
+
101
+ # Otherwise return the first
102
+ return records[0]
103
+
104
+
105
+ def convert_address(address: str) -> ConversionResult:
106
+ """
107
+ Convert a Vietnamese address from old format (63 provinces, 3-level)
108
+ to new format (34 provinces, 2-level).
109
+
110
+ Args:
111
+ address: Vietnamese address string, e.g.
112
+ "Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội"
113
+
114
+ Returns:
115
+ ConversionResult with conversion details.
116
+ """
117
+ _load_data()
118
+
119
+ result = ConversionResult(original=address)
120
+ parsed = parse_address(address)
121
+ result.old = parsed
122
+
123
+ # Resolve province
124
+ old_prov_key = _resolve_province(parsed.province)
125
+ if not old_prov_key:
126
+ # Try district field as province (2-part address might be misparse)
127
+ if parsed.district:
128
+ old_prov_key = _resolve_province(parsed.district)
129
+ if not old_prov_key:
130
+ result.status = ConversionStatus.NOT_FOUND
131
+ result.note = f"Province not found: {parsed.province}"
132
+ return result
133
+
134
+ # Get new province
135
+ new_prov_key = _index["province"].get(old_prov_key)
136
+ if not new_prov_key:
137
+ result.status = ConversionStatus.NOT_FOUND
138
+ result.note = f"No province mapping for: {old_prov_key}"
139
+ return result
140
+
141
+ new_prov_info = _index["province_names"].get(new_prov_key, {})
142
+ result.new.province = new_prov_info.get("name", "")
143
+
144
+ # If no ward info, return province-only result
145
+ if not parsed.ward and not parsed.district:
146
+ result.status = ConversionStatus.PARTIAL
147
+ result.converted = result.new.province
148
+ result.note = "Province-only conversion"
149
+ return result
150
+
151
+ # Resolve ward
152
+ old_dist_key = normalize_key(parsed.district) if parsed.district else ""
153
+ old_ward_key = normalize_key(parsed.ward) if parsed.ward else ""
154
+
155
+ records = _find_mapping(old_prov_key, old_dist_key, old_ward_key)
156
+
157
+ if not records and parsed.ward:
158
+ # Try ward in district field (for 2-part: "ward, province")
159
+ old_ward_key2 = normalize_key(parsed.district) if parsed.district else ""
160
+ if old_ward_key2:
161
+ records = _find_mapping(old_prov_key, "", old_ward_key2)
162
+
163
+ if not records:
164
+ result.status = ConversionStatus.PARTIAL
165
+ result.new.street = parsed.street
166
+ result.converted = result.new.to_address()
167
+ result.note = f"Ward not found, province converted"
168
+ return result
169
+
170
+ record = _select_best_record(records)
171
+ result.mapping_type = MappingType(record["mapping_type"])
172
+ result.new.ward = record["new_ward"]
173
+ result.new.street = parsed.street
174
+ result.converted = result.new.to_address()
175
+ result.status = ConversionStatus.SUCCESS
176
+
177
+ if result.mapping_type == MappingType.DIVIDED:
178
+ result.note = "Old ward was split; default new ward selected"
179
+
180
+ return result
181
+
182
+
183
+ def batch_convert(addresses: list[str]) -> list[ConversionResult]:
184
+ """Convert a list of addresses."""
185
+ _load_data()
186
+ return [convert_address(addr) for addr in addresses]
src/models.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for address conversion."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+
7
+ class MappingType(str, Enum):
8
+ UNCHANGED = "unchanged"
9
+ RENAMED = "renamed"
10
+ MERGED = "merged"
11
+ DIVIDED = "divided"
12
+
13
+
14
+ class ConversionStatus(str, Enum):
15
+ SUCCESS = "success"
16
+ PARTIAL = "partial" # Only province matched
17
+ NOT_FOUND = "not_found"
18
+
19
+
20
+ @dataclass
21
+ class AdminUnit:
22
+ province: str = ""
23
+ district: str = ""
24
+ ward: str = ""
25
+ street: str = ""
26
+
27
+ def to_address(self) -> str:
28
+ parts = [p for p in (self.street, self.ward, self.district, self.province) if p]
29
+ return ", ".join(parts)
30
+
31
+
32
+ @dataclass
33
+ class ConversionResult:
34
+ original: str = ""
35
+ converted: str = ""
36
+ status: ConversionStatus = ConversionStatus.NOT_FOUND
37
+ mapping_type: MappingType | None = None
38
+ old: AdminUnit = field(default_factory=AdminUnit)
39
+ new: AdminUnit = field(default_factory=AdminUnit)
40
+ note: str = ""
src/normalizer.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vietnamese text normalization for address matching."""
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ # Abbreviation expansions
7
+ ABBREVIATIONS = {
8
+ "tp.": "thành phố ",
9
+ "tp ": "thành phố ",
10
+ "t.p.": "thành phố ",
11
+ "t.p ": "thành phố ",
12
+ "p.": "phường ",
13
+ "q.": "quận ",
14
+ "h.": "huyện ",
15
+ "tx.": "thị xã ",
16
+ "t.x.": "thị xã ",
17
+ "tt.": "thị trấn ",
18
+ "t.t.": "thị trấn ",
19
+ "x.": "xã ",
20
+ }
21
+
22
+
23
+ def remove_diacritics(text: str) -> str:
24
+ """Remove Vietnamese diacritics from text."""
25
+ nfkd = unicodedata.normalize("NFKD", text)
26
+ result = "".join(c for c in nfkd if not unicodedata.combining(c))
27
+ # Handle đ/Đ separately (not decomposed by NFKD)
28
+ result = result.replace("đ", "d").replace("Đ", "D")
29
+ return result
30
+
31
+
32
+ def normalize_key(text: str) -> str:
33
+ """Normalize text to a lookup key (lowercase, no diacritics, no spaces/punctuation)."""
34
+ text = text.lower().strip()
35
+ text = remove_diacritics(text)
36
+ text = re.sub(r"[^a-z0-9]", "", text)
37
+ return text
38
+
39
+
40
+ def expand_abbreviations(text: str) -> str:
41
+ """Expand common Vietnamese address abbreviations."""
42
+ result = text.lower().strip()
43
+ # Sort by length descending to match longer abbreviations first
44
+ for abbr, full in sorted(ABBREVIATIONS.items(), key=lambda x: -len(x[0])):
45
+ result = result.replace(abbr, full)
46
+ return result.strip()
47
+
48
+
49
+ def normalize_for_matching(text: str) -> str:
50
+ """Full normalization pipeline for fuzzy matching."""
51
+ text = expand_abbreviations(text)
52
+ return normalize_key(text)
src/parser.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse Vietnamese address strings into components."""
2
+
3
+ import re
4
+
5
+ from .models import AdminUnit
6
+ from .normalizer import expand_abbreviations
7
+
8
+ # Province-level prefixes
9
+ PROVINCE_PREFIXES = ("thành phố", "tỉnh")
10
+ # District-level prefixes
11
+ DISTRICT_PREFIXES = ("quận", "huyện", "thị xã", "thành phố")
12
+ # Ward-level prefixes
13
+ WARD_PREFIXES = ("phường", "xã", "thị trấn")
14
+
15
+
16
+ def _classify_part(text: str) -> str | None:
17
+ """Classify an address part as province/district/ward/street."""
18
+ lower = text.lower().strip()
19
+ # Check province
20
+ for prefix in PROVINCE_PREFIXES:
21
+ if lower.startswith(prefix):
22
+ # "Thành phố" can be both province and district
23
+ # Province-level: TP Hà Nội, TP Hồ Chí Minh, etc.
24
+ return None # ambiguous, resolved by position
25
+ # Check district
26
+ for prefix in DISTRICT_PREFIXES:
27
+ if lower.startswith(prefix):
28
+ return None # ambiguous
29
+ # Check ward
30
+ for prefix in WARD_PREFIXES:
31
+ if lower.startswith(prefix):
32
+ return "ward"
33
+ return None
34
+
35
+
36
+ def parse_address(address: str) -> AdminUnit:
37
+ """
38
+ Parse Vietnamese address string into AdminUnit components.
39
+
40
+ Expected format: "street, ward, district, province"
41
+ Parsing is right-to-left (province is rightmost).
42
+ """
43
+ # Expand abbreviations first
44
+ expanded = expand_abbreviations(address)
45
+
46
+ # Split by comma
47
+ parts = [p.strip() for p in expanded.split(",") if p.strip()]
48
+
49
+ if not parts:
50
+ return AdminUnit()
51
+
52
+ unit = AdminUnit()
53
+
54
+ # Right-to-left assignment
55
+ if len(parts) >= 1:
56
+ unit.province = parts[-1].strip()
57
+ if len(parts) >= 2:
58
+ unit.district = parts[-2].strip()
59
+ if len(parts) >= 3:
60
+ unit.ward = parts[-3].strip()
61
+ if len(parts) >= 4:
62
+ # Everything before ward is street
63
+ unit.street = ", ".join(parts[:-3]).strip()
64
+
65
+ # Handle 2-part addresses: could be "ward, province" or "district, province"
66
+ if len(parts) == 2:
67
+ lower = parts[0].lower().strip()
68
+ for prefix in WARD_PREFIXES:
69
+ if lower.startswith(prefix):
70
+ unit.ward = unit.district
71
+ unit.district = ""
72
+ break
73
+
74
+ return unit
tests/__init__.py ADDED
File without changes
tests/test_converter.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for address converter."""
2
+
3
+ from src import convert_address, batch_convert, ConversionStatus, MappingType
4
+
5
+
6
+ class TestMergedWard:
7
+ """Test wards that were merged into new wards."""
8
+
9
+ def test_phuc_xa_merged_to_hong_ha(self):
10
+ result = convert_address("Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội")
11
+ assert result.status == ConversionStatus.SUCCESS
12
+ assert result.mapping_type == MappingType.MERGED
13
+ assert result.new.ward == "Phường Hồng Hà"
14
+ assert result.new.province == "Thành phố Hà Nội"
15
+
16
+ def test_an_binh_can_tho(self):
17
+ result = convert_address("Phường An Bình, Quận Ninh Kiều, Thành phố Cần Thơ")
18
+ assert result.status == ConversionStatus.SUCCESS
19
+ assert result.new.province == "Thành phố Cần Thơ"
20
+
21
+
22
+ class TestUnchangedWard:
23
+ """Test wards that remain unchanged."""
24
+
25
+ def test_tan_loc_can_tho(self):
26
+ result = convert_address("Phường Tân Lộc, Quận Thốt Nốt, Thành phố Cần Thơ")
27
+ assert result.status == ConversionStatus.SUCCESS
28
+ assert result.mapping_type == MappingType.UNCHANGED
29
+ assert result.new.ward == "Phường Tân Lộc"
30
+
31
+
32
+ class TestRenamedWard:
33
+ """Test wards that were renamed."""
34
+
35
+ def test_long_hoa_renamed(self):
36
+ result = convert_address("Phường Long Hòa, Quận Bình Thủy, Thành phố Cần Thơ")
37
+ assert result.status == ConversionStatus.SUCCESS
38
+ assert result.mapping_type == MappingType.RENAMED
39
+ assert result.new.ward == "Phường Long Tuyền"
40
+
41
+
42
+ class TestDividedWard:
43
+ """Test wards that were split into multiple new wards."""
44
+
45
+ def test_divided_selects_default(self):
46
+ result = convert_address("Xã Tân Thạnh, Huyện Thới Lai, Thành phố Cần Thơ")
47
+ assert result.status == ConversionStatus.SUCCESS
48
+ assert result.mapping_type == MappingType.DIVIDED
49
+
50
+
51
+ class TestAbbreviations:
52
+ """Test address abbreviation expansion."""
53
+
54
+ def test_p_q_tp(self):
55
+ result = convert_address("P. Phúc Xá, Q. Ba Đình, TP. Hà Nội")
56
+ assert result.status == ConversionStatus.SUCCESS
57
+ assert result.new.ward == "Phường Hồng Hà"
58
+ assert result.new.province == "Thành phố Hà Nội"
59
+
60
+ def test_tp_shorthand(self):
61
+ result = convert_address("P.Phúc Xá, Q.Ba Đình, TP.Hà Nội")
62
+ assert result.status == ConversionStatus.SUCCESS
63
+ assert result.new.province == "Thành phố Hà Nội"
64
+
65
+
66
+ class TestPartialAddress:
67
+ """Test partial addresses (province-only, no ward)."""
68
+
69
+ def test_province_only(self):
70
+ result = convert_address("Thành phố Hà Nội")
71
+ assert result.status == ConversionStatus.PARTIAL
72
+ assert result.new.province == "Thành phố Hà Nội"
73
+
74
+ def test_unknown_province(self):
75
+ result = convert_address("Tỉnh Không Tồn Tại")
76
+ assert result.status == ConversionStatus.NOT_FOUND
77
+
78
+
79
+ class TestWithStreet:
80
+ """Test addresses that include street information."""
81
+
82
+ def test_street_preserved(self):
83
+ result = convert_address("123 Phố Hàng Bông, Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội")
84
+ assert result.status == ConversionStatus.SUCCESS
85
+ assert "123" in result.converted
86
+ assert "Phường Hồng Hà" in result.converted
87
+
88
+
89
+ class TestBatchConvert:
90
+ """Test batch conversion."""
91
+
92
+ def test_batch(self):
93
+ addresses = [
94
+ "Phường Phúc Xá, Quận Ba Đình, Thành phố Hà Nội",
95
+ "Phường Tân Lộc, Quận Thốt Nốt, Thành phố Cần Thơ",
96
+ "Tỉnh Không Tồn Tại",
97
+ ]
98
+ results = batch_convert(addresses)
99
+ assert len(results) == 3
100
+ assert results[0].status == ConversionStatus.SUCCESS
101
+ assert results[1].status == ConversionStatus.SUCCESS
102
+ assert results[2].status == ConversionStatus.NOT_FOUND
103
+
104
+
105
+ class TestProvinceMapping:
106
+ """Test province-level conversions (63 -> 34)."""
107
+
108
+ def test_ha_noi_stays(self):
109
+ result = convert_address("Thành phố Hà Nội")
110
+ assert result.new.province == "Thành phố Hà Nội"
111
+
112
+ def test_merged_province(self):
113
+ # Hà Giang merged with another province
114
+ result = convert_address("Tỉnh Hà Giang")
115
+ assert result.status == ConversionStatus.PARTIAL
116
+ assert result.new.province # should have a new province name
uv.lock ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "address"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "click" },
11
+ ]
12
+
13
+ [package.dev-dependencies]
14
+ dev = [
15
+ { name = "pytest" },
16
+ { name = "vietnamadminunits" },
17
+ ]
18
+
19
+ [package.metadata]
20
+ requires-dist = [{ name = "click" }]
21
+
22
+ [package.metadata.requires-dev]
23
+ dev = [
24
+ { name = "pytest" },
25
+ { name = "vietnamadminunits" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "click"
30
+ version = "8.3.1"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ dependencies = [
33
+ { name = "colorama", marker = "sys_platform == 'win32'" },
34
+ ]
35
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
36
+ wheels = [
37
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
38
+ ]
39
+
40
+ [[package]]
41
+ name = "colorama"
42
+ version = "0.4.6"
43
+ source = { registry = "https://pypi.org/simple" }
44
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
45
+ wheels = [
46
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
47
+ ]
48
+
49
+ [[package]]
50
+ name = "geographiclib"
51
+ version = "2.1"
52
+ source = { registry = "https://pypi.org/simple" }
53
+ sdist = { url = "https://files.pythonhosted.org/packages/df/78/4892343230a9d29faa1364564e525307a37e54ad776ea62c12129dbba704/geographiclib-2.1.tar.gz", hash = "sha256:6a6545e6262d0ed3522e13c515713718797e37ed8c672c31ad7b249f372ef108", size = 37004, upload-time = "2025-08-21T21:34:26Z" }
54
+ wheels = [
55
+ { url = "https://files.pythonhosted.org/packages/31/b3/802576f2ea5dcb48501bb162e4c7b7b3ca5654a42b2c968ef98a797a4c79/geographiclib-2.1-py3-none-any.whl", hash = "sha256:e2a873b9b9e7fc38721ad73d5f4e6c9ed140d428a339970f505c07056997d40b", size = 40740, upload-time = "2025-08-21T21:34:24.955Z" },
56
+ ]
57
+
58
+ [[package]]
59
+ name = "geopy"
60
+ version = "2.4.1"
61
+ source = { registry = "https://pypi.org/simple" }
62
+ dependencies = [
63
+ { name = "geographiclib" },
64
+ ]
65
+ sdist = { url = "https://files.pythonhosted.org/packages/0e/fd/ef6d53875ceab72c1fad22dbed5ec1ad04eb378c2251a6a8024bad890c3b/geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1", size = 117625, upload-time = "2023-11-23T21:49:32.734Z" }
66
+ wheels = [
67
+ { url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
68
+ ]
69
+
70
+ [[package]]
71
+ name = "iniconfig"
72
+ version = "2.3.0"
73
+ source = { registry = "https://pypi.org/simple" }
74
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
75
+ wheels = [
76
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
77
+ ]
78
+
79
+ [[package]]
80
+ name = "numpy"
81
+ version = "2.4.2"
82
+ source = { registry = "https://pypi.org/simple" }
83
+ sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
84
+ wheels = [
85
+ { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
86
+ { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
87
+ { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
88
+ { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
89
+ { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
90
+ { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
91
+ { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
92
+ { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
93
+ { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
94
+ { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
95
+ { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
96
+ { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
97
+ { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
98
+ { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
99
+ { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
100
+ { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
101
+ { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
102
+ { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
103
+ { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
104
+ { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
105
+ { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
106
+ { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
107
+ { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
108
+ { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
109
+ { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
110
+ { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
111
+ { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
112
+ { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
113
+ { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
114
+ { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
115
+ { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
116
+ { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
117
+ { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
118
+ { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
119
+ { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
120
+ { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
121
+ { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
122
+ { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
123
+ { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
124
+ { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
125
+ { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
126
+ { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
127
+ { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
128
+ { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
129
+ { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
130
+ { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
131
+ { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
132
+ { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
133
+ { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
134
+ { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
135
+ { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
136
+ { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
137
+ { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
138
+ ]
139
+
140
+ [[package]]
141
+ name = "packaging"
142
+ version = "26.0"
143
+ source = { registry = "https://pypi.org/simple" }
144
+ sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
145
+ wheels = [
146
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
147
+ ]
148
+
149
+ [[package]]
150
+ name = "pluggy"
151
+ version = "1.6.0"
152
+ source = { registry = "https://pypi.org/simple" }
153
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
154
+ wheels = [
155
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
156
+ ]
157
+
158
+ [[package]]
159
+ name = "pygments"
160
+ version = "2.19.2"
161
+ source = { registry = "https://pypi.org/simple" }
162
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
163
+ wheels = [
164
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
165
+ ]
166
+
167
+ [[package]]
168
+ name = "pytest"
169
+ version = "9.0.2"
170
+ source = { registry = "https://pypi.org/simple" }
171
+ dependencies = [
172
+ { name = "colorama", marker = "sys_platform == 'win32'" },
173
+ { name = "iniconfig" },
174
+ { name = "packaging" },
175
+ { name = "pluggy" },
176
+ { name = "pygments" },
177
+ ]
178
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
179
+ wheels = [
180
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
181
+ ]
182
+
183
+ [[package]]
184
+ name = "shapely"
185
+ version = "2.1.2"
186
+ source = { registry = "https://pypi.org/simple" }
187
+ dependencies = [
188
+ { name = "numpy" },
189
+ ]
190
+ sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
191
+ wheels = [
192
+ { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
193
+ { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
194
+ { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
195
+ { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
196
+ { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
197
+ { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
198
+ { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
199
+ { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
200
+ { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
201
+ { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
202
+ { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
203
+ { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
204
+ { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
205
+ { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
206
+ { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
207
+ { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
208
+ { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
209
+ { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
210
+ { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
211
+ { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
212
+ { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
213
+ { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
214
+ { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
215
+ { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
216
+ { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
217
+ { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
218
+ { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
219
+ { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
220
+ { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
221
+ { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
222
+ { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
223
+ { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
224
+ { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
225
+ { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
226
+ { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
227
+ { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
228
+ { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
229
+ { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
230
+ { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
231
+ { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
232
+ ]
233
+
234
+ [[package]]
235
+ name = "tqdm"
236
+ version = "4.67.3"
237
+ source = { registry = "https://pypi.org/simple" }
238
+ dependencies = [
239
+ { name = "colorama", marker = "sys_platform == 'win32'" },
240
+ ]
241
+ sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
242
+ wheels = [
243
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
244
+ ]
245
+
246
+ [[package]]
247
+ name = "unidecode"
248
+ version = "1.4.0"
249
+ source = { registry = "https://pypi.org/simple" }
250
+ sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
251
+ wheels = [
252
+ { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
253
+ ]
254
+
255
+ [[package]]
256
+ name = "vietnamadminunits"
257
+ version = "1.0.4"
258
+ source = { registry = "https://pypi.org/simple" }
259
+ dependencies = [
260
+ { name = "geopy" },
261
+ { name = "shapely" },
262
+ { name = "tqdm" },
263
+ { name = "unidecode" },
264
+ ]
265
+ wheels = [
266
+ { url = "https://files.pythonhosted.org/packages/6e/ce/69bd8f43fea385cfa8935641f899dd20583e559c8fb3b1bdb9288519f71a/vietnamadminunits-1.0.4-py3-none-any.whl", hash = "sha256:737ee3662e877290386c1c45ed4d71f2e5fae34ab6fb9c64bfb4cb74b216ea35", size = 1941018, upload-time = "2025-10-08T15:35:32.53Z" },
267
+ ]