Add Vietnamese address converter for post-merger admin units (01/07/2025)
Browse filesConverts 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 +7 -0
- .python-version +1 -0
- README.md +341 -0
- data/mapping.json +0 -0
- pyproject.toml +22 -0
- scripts/build_mapping.py +222 -0
- src/__init__.py +13 -0
- src/cli.py +73 -0
- src/converter.py +186 -0
- src/models.py +40 -0
- src/normalizer.py +52 -0
- src/parser.py +74 -0
- tests/__init__.py +0 -0
- tests/test_converter.py +116 -0
- uv.lock +267 -0
.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 |
+
]
|