geographicol 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geographicol-0.1.0/.gitignore +10 -0
- geographicol-0.1.0/LICENSE +21 -0
- geographicol-0.1.0/PKG-INFO +95 -0
- geographicol-0.1.0/README.md +77 -0
- geographicol-0.1.0/pyproject.toml +38 -0
- geographicol-0.1.0/src/geographicol/__init__.py +33 -0
- geographicol-0.1.0/src/geographicol/codes.py +139 -0
- geographicol-0.1.0/src/geographicol/parse.py +315 -0
- geographicol-0.1.0/src/geographicol/py.typed +0 -0
- geographicol-0.1.0/src/geographicol/render.py +71 -0
- geographicol-0.1.0/src/geographicol/tokenize.py +85 -0
- geographicol-0.1.0/src/geographicol/types.py +60 -0
- geographicol-0.1.0/tests/test_fixtures.py +53 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geographicol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: geographicol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Normalize Colombian addresses into a canonical, structured form. Zero dependencies.
|
|
5
|
+
Project-URL: Repository, https://github.com/geographicol/geographicol
|
|
6
|
+
Author: geographicol
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: address,address-normalization,colombia,dian,direccion,igac
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff==0.16.8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# geographicol
|
|
20
|
+
|
|
21
|
+
Normalize Colombian addresses into one canonical, structured form. Python 3.10+, zero dependencies.
|
|
22
|
+
|
|
23
|
+
| Input | Canonical (`igac`, default) | `dian` style |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `Cra 45 No 12 30 Loc 3` | `KR 45 12 30 LC 3` | `CR 45 12 30 LC 3` |
|
|
26
|
+
| `cl. 26 nº 13-19` | `CL 26 13 19` | `CL 26 13 19` |
|
|
27
|
+
| `Av. Cra 68 #22 - 47` | `AK 68 22 47` | `AK 68 22 47` |
|
|
28
|
+
| `calle 38 a bis sur # 3A-18 este` | `CL 38A BIS SUR 3A 18 ESTE` | `CL 38A BIS SUR 3A 18 ESTE` |
|
|
29
|
+
| `KR45#12-30` | `KR 45 12 30` | `CR 45 12 30` |
|
|
30
|
+
| `Cll 147 No 7 70 To 2 Ap 501` | `CL 147 7 70 TO 2 APTO 501` | `CL 147 7 70 TO 2 AP 501` |
|
|
31
|
+
| `Kr 43A # 1-50 Ed San Fernando Of 801` | `KR 43A 1 50 ED SAN FERNANDO OF 801` | `CR 43A 1 50 ED SAN FERNANDO OF 801` |
|
|
32
|
+
| `Calle 8 # 530` | `CL 8 5 30`, confidence 0.8, warning `AMBIGUOUS_PLATE` | `CL 8 5 30` |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install geographicol
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from geographicol import is_valid, normalize, parse
|
|
44
|
+
|
|
45
|
+
normalize("Cra 7 # 45-12 Torre 2 Apto 501") # "KR 7 45 12 TO 2 APTO 501"
|
|
46
|
+
normalize("Cra 7 # 45-12 Torre 2 Apto 501", style="dian") # "CR 7 45 12 TO 2 AP 501"
|
|
47
|
+
is_valid("Calle 45") # False: no cross street
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Styles
|
|
51
|
+
|
|
52
|
+
| `style` | Codes | Use it for |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `"igac"` (default) | IGAC cadastral table: `KR`, `APTO`, `PI` | Cadastre, municipalities, general storage |
|
|
55
|
+
| `"dian"` | DIAN table: `CR`, `AP`, `P` | RUT, tax forms, accounting and ERP software |
|
|
56
|
+
| `"readable"` | Full words: `Carrera 7 # 45-12, Torre 2` | Showing addresses to people |
|
|
57
|
+
|
|
58
|
+
Every style accepts codes from both tables as input. `strict=True` doubles every confidence penalty.
|
|
59
|
+
|
|
60
|
+
## `ParsedAddress`
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
parse("Cra 7 # 45-12 Torre 2 Apto 501, Bogotá")
|
|
64
|
+
# ParsedAddress(
|
|
65
|
+
# street_type="KR", street_number=7, street_letter=None, street_quadrant=None,
|
|
66
|
+
# cross_number=45, cross_letter=None, cross_quadrant=None, plate_number=12,
|
|
67
|
+
# complements=[
|
|
68
|
+
# Complement(type="TORRE", code="TO", value="2"),
|
|
69
|
+
# Complement(type="APARTAMENTO", code="APTO", value="501"),
|
|
70
|
+
# ],
|
|
71
|
+
# locality="Bogotá", department=None,
|
|
72
|
+
# canonical="KR 7 45 12 TO 2 APTO 501",
|
|
73
|
+
# normalized="Carrera 7 # 45-12, Torre 2, Apartamento 501",
|
|
74
|
+
# confidence=1.0,
|
|
75
|
+
# warnings=["LOCALITY_UNVERIFIED"],
|
|
76
|
+
# raw="Cra 7 # 45-12 Torre 2 Apto 501, Bogotá",
|
|
77
|
+
# )
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`ParsedAddress` is a dataclass, so `dataclasses.asdict()` turns it into a plain dict. `parse` never raises. When it can't make sense of the input, it says so through `confidence` (0 to 1) and machine-readable `warnings` instead of guessing silently. The full rules, codes and warning list are in [`docs/nomenclature.md`](https://github.com/geographicol/geographicol/blob/main/docs/nomenclature.md).
|
|
81
|
+
|
|
82
|
+
## What this library does NOT do
|
|
83
|
+
|
|
84
|
+
It normalizes the *syntax* of an address, offline, from the string alone. It does not:
|
|
85
|
+
|
|
86
|
+
- **Geocode:** no coordinates, no maps.
|
|
87
|
+
- **Validate against real addresses:** `KR 999 999 99` parses fine even though it doesn't exist.
|
|
88
|
+
- **Look up cities or neighbourhoods:** `locality` and `department` are captured as written and never checked (warning `LOCALITY_UNVERIFIED`).
|
|
89
|
+
- **Fix typos or fuzzy-match:** `Carerra` is not recognized.
|
|
90
|
+
|
|
91
|
+
Those need data and infrastructure, and will be part of the geographicol API.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
[MIT](https://github.com/geographicol/geographicol/blob/main/LICENSE)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# geographicol
|
|
2
|
+
|
|
3
|
+
Normalize Colombian addresses into one canonical, structured form. Python 3.10+, zero dependencies.
|
|
4
|
+
|
|
5
|
+
| Input | Canonical (`igac`, default) | `dian` style |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `Cra 45 No 12 30 Loc 3` | `KR 45 12 30 LC 3` | `CR 45 12 30 LC 3` |
|
|
8
|
+
| `cl. 26 nº 13-19` | `CL 26 13 19` | `CL 26 13 19` |
|
|
9
|
+
| `Av. Cra 68 #22 - 47` | `AK 68 22 47` | `AK 68 22 47` |
|
|
10
|
+
| `calle 38 a bis sur # 3A-18 este` | `CL 38A BIS SUR 3A 18 ESTE` | `CL 38A BIS SUR 3A 18 ESTE` |
|
|
11
|
+
| `KR45#12-30` | `KR 45 12 30` | `CR 45 12 30` |
|
|
12
|
+
| `Cll 147 No 7 70 To 2 Ap 501` | `CL 147 7 70 TO 2 APTO 501` | `CL 147 7 70 TO 2 AP 501` |
|
|
13
|
+
| `Kr 43A # 1-50 Ed San Fernando Of 801` | `KR 43A 1 50 ED SAN FERNANDO OF 801` | `CR 43A 1 50 ED SAN FERNANDO OF 801` |
|
|
14
|
+
| `Calle 8 # 530` | `CL 8 5 30`, confidence 0.8, warning `AMBIGUOUS_PLATE` | `CL 8 5 30` |
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install geographicol
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from geographicol import is_valid, normalize, parse
|
|
26
|
+
|
|
27
|
+
normalize("Cra 7 # 45-12 Torre 2 Apto 501") # "KR 7 45 12 TO 2 APTO 501"
|
|
28
|
+
normalize("Cra 7 # 45-12 Torre 2 Apto 501", style="dian") # "CR 7 45 12 TO 2 AP 501"
|
|
29
|
+
is_valid("Calle 45") # False: no cross street
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Styles
|
|
33
|
+
|
|
34
|
+
| `style` | Codes | Use it for |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `"igac"` (default) | IGAC cadastral table: `KR`, `APTO`, `PI` | Cadastre, municipalities, general storage |
|
|
37
|
+
| `"dian"` | DIAN table: `CR`, `AP`, `P` | RUT, tax forms, accounting and ERP software |
|
|
38
|
+
| `"readable"` | Full words: `Carrera 7 # 45-12, Torre 2` | Showing addresses to people |
|
|
39
|
+
|
|
40
|
+
Every style accepts codes from both tables as input. `strict=True` doubles every confidence penalty.
|
|
41
|
+
|
|
42
|
+
## `ParsedAddress`
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
parse("Cra 7 # 45-12 Torre 2 Apto 501, Bogotá")
|
|
46
|
+
# ParsedAddress(
|
|
47
|
+
# street_type="KR", street_number=7, street_letter=None, street_quadrant=None,
|
|
48
|
+
# cross_number=45, cross_letter=None, cross_quadrant=None, plate_number=12,
|
|
49
|
+
# complements=[
|
|
50
|
+
# Complement(type="TORRE", code="TO", value="2"),
|
|
51
|
+
# Complement(type="APARTAMENTO", code="APTO", value="501"),
|
|
52
|
+
# ],
|
|
53
|
+
# locality="Bogotá", department=None,
|
|
54
|
+
# canonical="KR 7 45 12 TO 2 APTO 501",
|
|
55
|
+
# normalized="Carrera 7 # 45-12, Torre 2, Apartamento 501",
|
|
56
|
+
# confidence=1.0,
|
|
57
|
+
# warnings=["LOCALITY_UNVERIFIED"],
|
|
58
|
+
# raw="Cra 7 # 45-12 Torre 2 Apto 501, Bogotá",
|
|
59
|
+
# )
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`ParsedAddress` is a dataclass, so `dataclasses.asdict()` turns it into a plain dict. `parse` never raises. When it can't make sense of the input, it says so through `confidence` (0 to 1) and machine-readable `warnings` instead of guessing silently. The full rules, codes and warning list are in [`docs/nomenclature.md`](https://github.com/geographicol/geographicol/blob/main/docs/nomenclature.md).
|
|
63
|
+
|
|
64
|
+
## What this library does NOT do
|
|
65
|
+
|
|
66
|
+
It normalizes the *syntax* of an address, offline, from the string alone. It does not:
|
|
67
|
+
|
|
68
|
+
- **Geocode:** no coordinates, no maps.
|
|
69
|
+
- **Validate against real addresses:** `KR 999 999 99` parses fine even though it doesn't exist.
|
|
70
|
+
- **Look up cities or neighbourhoods:** `locality` and `department` are captured as written and never checked (warning `LOCALITY_UNVERIFIED`).
|
|
71
|
+
- **Fix typos or fuzzy-match:** `Carerra` is not recognized.
|
|
72
|
+
|
|
73
|
+
Those need data and infrastructure, and will be part of the geographicol API.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
[MIT](https://github.com/geographicol/geographicol/blob/main/LICENSE)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "geographicol"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Normalize Colombian addresses into a canonical, structured form. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "geographicol" }]
|
|
13
|
+
keywords = ["colombia", "address", "address-normalization", "direccion", "igac", "dian"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/geographicol/geographicol"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=8", "ruff==0.16.8"]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/geographicol"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
target-version = "py310"
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Normalize Colombian addresses into a canonical, structured form."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .parse import parse
|
|
6
|
+
from .types import Complement, ComplementType, ParsedAddress, Quadrant, StreetType, Style
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Complement",
|
|
10
|
+
"ComplementType",
|
|
11
|
+
"ParsedAddress",
|
|
12
|
+
"Quadrant",
|
|
13
|
+
"StreetType",
|
|
14
|
+
"Style",
|
|
15
|
+
"is_valid",
|
|
16
|
+
"normalize",
|
|
17
|
+
"parse",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(input: str, style: Style = "igac", strict: bool = False) -> str:
|
|
22
|
+
"""The canonical string for ``input``: ``parse(input, style, strict).canonical``."""
|
|
23
|
+
return parse(input, style=style, strict=strict).canonical
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_valid(input: str) -> bool:
|
|
27
|
+
"""True when confidence is at least 0.7 and both the street and cross numbers are present."""
|
|
28
|
+
result = parse(input)
|
|
29
|
+
return (
|
|
30
|
+
result.confidence >= 0.7
|
|
31
|
+
and result.street_number is not None
|
|
32
|
+
and result.cross_number is not None
|
|
33
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Every code table lives here, so a correction is a one-line change.
|
|
2
|
+
|
|
3
|
+
See docs/nomenclature.md sections 3, 5, 7 and 9.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .types import ComplementType, Quadrant, StreetType
|
|
11
|
+
|
|
12
|
+
# Input aliases, already normalized (lowercase, no accents). Multi-word aliases are space-separated.
|
|
13
|
+
STREET_ALIASES: dict[StreetType, list[str]] = {
|
|
14
|
+
"CL": ["calle", "cll", "cl", "cle", "c"],
|
|
15
|
+
"KR": ["carrera", "cra", "cr", "kr", "kra", "k"],
|
|
16
|
+
"AV": ["avenida", "av", "avda"],
|
|
17
|
+
"AK": ["avenida carrera", "av carrera", "av cra", "av kr", "ak"],
|
|
18
|
+
"AC": ["avenida calle", "av calle", "av cl", "ac"],
|
|
19
|
+
"DG": ["diagonal", "diag", "dg"],
|
|
20
|
+
"TV": ["transversal", "transv", "trans", "tv", "tr"],
|
|
21
|
+
"CIR": ["circular", "circ", "cir", "cq"],
|
|
22
|
+
"CCV": ["circunvalar", "cvlar", "ccv", "crv", "cv"],
|
|
23
|
+
"AUTOP": ["autopista", "autop", "auto", "aut"],
|
|
24
|
+
"VIA": ["via"],
|
|
25
|
+
"KM": ["kilometro", "km"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Output codes and names per style.
|
|
29
|
+
STREET_OUTPUT: dict[StreetType, dict[str, str]] = {
|
|
30
|
+
"CL": {"igac": "CL", "dian": "CL", "readable": "Calle"},
|
|
31
|
+
"KR": {"igac": "KR", "dian": "CR", "readable": "Carrera"},
|
|
32
|
+
"AV": {"igac": "AV", "dian": "AV", "readable": "Avenida"},
|
|
33
|
+
"AK": {"igac": "AK", "dian": "AK", "readable": "Avenida Carrera"},
|
|
34
|
+
"AC": {"igac": "AC", "dian": "AC", "readable": "Avenida Calle"},
|
|
35
|
+
"DG": {"igac": "DG", "dian": "DG", "readable": "Diagonal"},
|
|
36
|
+
"TV": {"igac": "TV", "dian": "TV", "readable": "Transversal"},
|
|
37
|
+
"CIR": {"igac": "CIR", "dian": "CIR", "readable": "Circular"},
|
|
38
|
+
"CCV": {"igac": "CCV", "dian": "CRV", "readable": "Circunvalar"},
|
|
39
|
+
"AUTOP": {"igac": "AUTOP", "dian": "AUT", "readable": "Autopista"},
|
|
40
|
+
"VIA": {"igac": "VIA", "dian": "VIA", "readable": "Vía"},
|
|
41
|
+
"KM": {"igac": "KM", "dian": "KM", "readable": "Kilómetro"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
QUADRANT_ALIASES: dict[str, Quadrant] = {
|
|
45
|
+
"sur": "SUR",
|
|
46
|
+
"este": "ESTE",
|
|
47
|
+
"norte": "NORTE",
|
|
48
|
+
"oeste": "OESTE",
|
|
49
|
+
"occidente": "OESTE",
|
|
50
|
+
"occ": "OESTE",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Single-letter quadrants, only when standing alone.
|
|
54
|
+
SINGLE_LETTER_QUADRANTS: dict[str, Quadrant] = {"s": "SUR", "e": "ESTE"}
|
|
55
|
+
|
|
56
|
+
UNCOMMON_QUADRANTS: tuple[Quadrant, ...] = ("NORTE", "OESTE")
|
|
57
|
+
|
|
58
|
+
# Words that stand for "número". Dropped.
|
|
59
|
+
NUMBER_MARKERS: tuple[str, ...] = ("no", "n", "nro", "num", "numero")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ComplementCode:
|
|
64
|
+
type: ComplementType
|
|
65
|
+
igac: str
|
|
66
|
+
dian: str
|
|
67
|
+
readable: str
|
|
68
|
+
aliases: tuple[str, ...]
|
|
69
|
+
name_valued: bool = False
|
|
70
|
+
"""Value runs to the next keyword, a comma or the end, instead of one token."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _c(
|
|
74
|
+
type: ComplementType,
|
|
75
|
+
igac: str,
|
|
76
|
+
dian: str,
|
|
77
|
+
readable: str,
|
|
78
|
+
aliases: str,
|
|
79
|
+
name_valued: bool = False,
|
|
80
|
+
) -> ComplementCode:
|
|
81
|
+
return ComplementCode(type, igac, dian, readable, tuple(aliases.split(",")), name_valued)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
COMPLEMENTS: list[ComplementCode] = [
|
|
85
|
+
_c("APARTAMENTO", "APTO", "AP", "Apartamento", "apartamento,aparta,apto,apt,ap"),
|
|
86
|
+
_c("TORRE", "TO", "TO", "Torre", "torre,to,tr"),
|
|
87
|
+
_c("LOCAL", "LC", "LC", "Local", "local,loc,lc,lo"),
|
|
88
|
+
_c("OFICINA", "OF", "OF", "Oficina", "oficina,ofi,ofc,of"),
|
|
89
|
+
_c("PISO", "PI", "P", "Piso", "piso,pi,p"),
|
|
90
|
+
_c("INTERIOR", "IN", "IN", "Interior", "interior,int,in"),
|
|
91
|
+
_c("BLOQUE", "BL", "BL", "Bloque", "bloque,blq,bl"),
|
|
92
|
+
_c("MANZANA", "MZ", "MZ", "Manzana", "manzana,mza,mz"),
|
|
93
|
+
_c("CASA", "CA", "CA", "Casa", "casa,ca"),
|
|
94
|
+
_c("ETAPA", "ET", "ET", "Etapa", "etapa,et"),
|
|
95
|
+
_c("CONJUNTO", "CONJ", "CONJ", "Conjunto", "conjunto,conj,cj", name_valued=True),
|
|
96
|
+
_c("EDIFICIO", "ED", "ED", "Edificio", "edificio,edif,ed", name_valued=True),
|
|
97
|
+
_c("BODEGA", "BG", "BG", "Bodega", "bodega,bod,bg"),
|
|
98
|
+
_c("LOTE", "LT", "LT", "Lote", "lote,lt"),
|
|
99
|
+
_c("BARRIO", "BR", "BRR", "Barrio", "barrio,brr,br,bo", name_valued=True),
|
|
100
|
+
# OTRO codes (section 9)
|
|
101
|
+
_c("OTRO", "PH", "PH", "Penthouse", "penthouse,ph"),
|
|
102
|
+
_c("OTRO", "GJ", "GJ", "Garaje", "garaje,gj"),
|
|
103
|
+
_c("OTRO", "SS", "SS", "Semisótano", "semisotano,ss"),
|
|
104
|
+
_c("OTRO", "CS", "CS", "Consultorio", "consultorio,cs"),
|
|
105
|
+
_c("OTRO", "UN", "UN", "Unidad", "unidad,un"),
|
|
106
|
+
_c("OTRO", "URB", "URB", "Urbanización", "urbanizacion,urb", name_valued=True),
|
|
107
|
+
_c("OTRO", "SEC", "SEC", "Sector", "sector,sec", name_valued=True),
|
|
108
|
+
_c("OTRO", "LM", "LM", "Local mezzanine", "local mezzanine,lm"),
|
|
109
|
+
_c("OTRO", "MN", "MN", "Mezzanine", "mezzanine,mn"),
|
|
110
|
+
_c("OTRO", "TZ", "TZ", "Terraza", "terraza,tz"),
|
|
111
|
+
_c("OTRO", "CECO", "CC", "Centro comercial", "centro comercial,ceco,cc", name_valued=True),
|
|
112
|
+
_c("OTRO", "SU", "SUITE", "Suite", "suite,su"),
|
|
113
|
+
_c("OTRO", "AGN", "AGP", "Agrupación", "agrupacion,agn,agp", name_valued=True),
|
|
114
|
+
_c("OTRO", "VDA", "VRD", "Vereda", "vereda,vda,vrd", name_valued=True),
|
|
115
|
+
_c("OTRO", "SMZ", "SM", "Supermanzana", "supermanzana,smz,sm"),
|
|
116
|
+
_c("OTRO", "PSJ", "PJ", "Pasaje", "pasaje,psj,pj"),
|
|
117
|
+
_c("OTRO", "PT", "POR", "Portería", "porteria,pt,por"),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
COMPLEMENT_BY_IGAC: dict[str, ComplementCode] = {c.igac: c for c in COMPLEMENTS}
|
|
121
|
+
|
|
122
|
+
# Confidence penalties (section 11).
|
|
123
|
+
PENALTIES: dict[str, float] = {
|
|
124
|
+
"UNRECOGNIZED_STREET_TYPE": 0.5,
|
|
125
|
+
"MISSING_CROSS_NUMBER": 0.4,
|
|
126
|
+
"AMBIGUOUS_PLATE": 0.2,
|
|
127
|
+
"UNKNOWN_TOKEN": 0.15,
|
|
128
|
+
"RURAL_ADDRESS": 0.1,
|
|
129
|
+
"MISSING_PLATE_NUMBER": 0.2,
|
|
130
|
+
"MISSING_STREET_NUMBER": 0.4,
|
|
131
|
+
"NAMED_STREET": 0,
|
|
132
|
+
"AMBIGUOUS_STREET_TYPE": 0.1,
|
|
133
|
+
"AMBIGUOUS_QUADRANT": 0.05,
|
|
134
|
+
"UNCOMMON_QUADRANT": 0,
|
|
135
|
+
"LOCALITY_UNVERIFIED": 0,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Warnings whose penalty applies once, however often they are raised.
|
|
139
|
+
ONCE_ONLY: tuple[str, ...] = ("AMBIGUOUS_STREET_TYPE", "LOCALITY_UNVERIFIED")
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""The parsing procedure from docs/nomenclature.md section 12, step by step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .codes import (
|
|
6
|
+
COMPLEMENTS,
|
|
7
|
+
NUMBER_MARKERS,
|
|
8
|
+
ONCE_ONLY,
|
|
9
|
+
PENALTIES,
|
|
10
|
+
QUADRANT_ALIASES,
|
|
11
|
+
SINGLE_LETTER_QUADRANTS,
|
|
12
|
+
STREET_ALIASES,
|
|
13
|
+
UNCOMMON_QUADRANTS,
|
|
14
|
+
ComplementCode,
|
|
15
|
+
)
|
|
16
|
+
from .render import render
|
|
17
|
+
from .tokenize import Token, tokenize
|
|
18
|
+
from .types import Complement, ParsedAddress, Quadrant, StreetType, Style
|
|
19
|
+
|
|
20
|
+
# Street aliases as token lists, longest first so "avenida carrera" wins over "avenida".
|
|
21
|
+
_STREET_MATCHERS: list[tuple[StreetType, list[str]]] = sorted(
|
|
22
|
+
(
|
|
23
|
+
(street_type, alias.split(" "))
|
|
24
|
+
for street_type, aliases in STREET_ALIASES.items()
|
|
25
|
+
for alias in aliases
|
|
26
|
+
),
|
|
27
|
+
key=lambda m: -len(m[1]),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_COMPLEMENT_MATCHERS: list[tuple[ComplementCode, list[str]]] = sorted(
|
|
31
|
+
((code, alias.split(" ")) for code in COMPLEMENTS for alias in code.aliases),
|
|
32
|
+
key=lambda m: -len(m[1]),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _AddressParser:
|
|
37
|
+
def __init__(self, raw: str) -> None:
|
|
38
|
+
self.raw = raw
|
|
39
|
+
self.tokens: list[Token] = []
|
|
40
|
+
self.i = 0
|
|
41
|
+
self.a = ParsedAddress(raw=raw)
|
|
42
|
+
self.raised: list[str] = []
|
|
43
|
+
"""Every warning occurrence, in order. Penalties are counted from this list."""
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
segments = self.raw.split(",")
|
|
47
|
+
self._parse_address_segment(segments[0])
|
|
48
|
+
for segment in segments[1:]:
|
|
49
|
+
self._parse_extra_segment(segment)
|
|
50
|
+
|
|
51
|
+
if self.a.locality is not None or self.a.department is not None:
|
|
52
|
+
self._warn("LOCALITY_UNVERIFIED")
|
|
53
|
+
|
|
54
|
+
def _warn(self, code: str) -> None:
|
|
55
|
+
self.raised.append(code)
|
|
56
|
+
|
|
57
|
+
def _peek(self, offset: int = 0) -> Token | None:
|
|
58
|
+
index = self.i + offset
|
|
59
|
+
return self.tokens[index] if index < len(self.tokens) else None
|
|
60
|
+
|
|
61
|
+
# Steps 5 to 9, on the text before the first comma.
|
|
62
|
+
def _parse_address_segment(self, text: str) -> None:
|
|
63
|
+
self.tokens = tokenize(text)
|
|
64
|
+
self.i = 0
|
|
65
|
+
a = self.a
|
|
66
|
+
|
|
67
|
+
a.street_type = self._parse_street_type()
|
|
68
|
+
if a.street_type is None:
|
|
69
|
+
self._warn("UNRECOGNIZED_STREET_TYPE")
|
|
70
|
+
|
|
71
|
+
if a.street_type is not None and self._is_named_street():
|
|
72
|
+
self._skip_street_name()
|
|
73
|
+
self._warn("NAMED_STREET")
|
|
74
|
+
else:
|
|
75
|
+
a.street_number = self._parse_number(3)
|
|
76
|
+
if a.street_number is not None:
|
|
77
|
+
a.street_letter = self._parse_suffix()
|
|
78
|
+
a.street_quadrant = self._parse_quadrant()
|
|
79
|
+
if a.street_number is None:
|
|
80
|
+
self._warn("MISSING_STREET_NUMBER")
|
|
81
|
+
|
|
82
|
+
if a.street_type == "KM":
|
|
83
|
+
self._warn("RURAL_ADDRESS")
|
|
84
|
+
# A KM address is a road location: the rest is the road description.
|
|
85
|
+
rest = self._peek()
|
|
86
|
+
if rest:
|
|
87
|
+
a.locality = text[rest.start :].strip()
|
|
88
|
+
self.i = len(self.tokens)
|
|
89
|
+
else:
|
|
90
|
+
self._parse_placa()
|
|
91
|
+
|
|
92
|
+
if a.cross_number is None:
|
|
93
|
+
self._warn("MISSING_CROSS_NUMBER")
|
|
94
|
+
elif a.plate_number is None:
|
|
95
|
+
self._warn("MISSING_PLATE_NUMBER")
|
|
96
|
+
|
|
97
|
+
self._parse_complements_and_leftovers(text, locality_allowed=True)
|
|
98
|
+
|
|
99
|
+
# Step 3: a later comma segment is complements, or else locality, then department.
|
|
100
|
+
def _parse_extra_segment(self, text: str) -> None:
|
|
101
|
+
self.tokens = tokenize(text)
|
|
102
|
+
self.i = 0
|
|
103
|
+
if not self.tokens:
|
|
104
|
+
return
|
|
105
|
+
if self._match_complement():
|
|
106
|
+
self._parse_complements_and_leftovers(text, locality_allowed=False)
|
|
107
|
+
return
|
|
108
|
+
value = text.strip()
|
|
109
|
+
if self.a.locality is None:
|
|
110
|
+
self.a.locality = value
|
|
111
|
+
elif self.a.department is None:
|
|
112
|
+
self.a.department = value
|
|
113
|
+
else:
|
|
114
|
+
for _ in self.tokens:
|
|
115
|
+
self._warn("UNKNOWN_TOKEN")
|
|
116
|
+
|
|
117
|
+
# Step 5.
|
|
118
|
+
def _parse_street_type(self) -> StreetType | None:
|
|
119
|
+
for street_type, words in _STREET_MATCHERS:
|
|
120
|
+
if self._matches_words(words):
|
|
121
|
+
self.i += len(words)
|
|
122
|
+
if len(words) == 1 and len(words[0]) == 1:
|
|
123
|
+
self._warn("AMBIGUOUS_STREET_TYPE")
|
|
124
|
+
return street_type
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _matches_words(self, words: list[str]) -> bool:
|
|
128
|
+
for k, word in enumerate(words):
|
|
129
|
+
token = self._peek(k)
|
|
130
|
+
if token is None or token.is_num or token.norm != word:
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _is_number_marker(token: Token | None) -> bool:
|
|
136
|
+
return token is not None and not token.is_num and token.norm in NUMBER_MARKERS
|
|
137
|
+
|
|
138
|
+
def _is_named_street(self) -> bool:
|
|
139
|
+
"""A word right after the street type, other than a number marker, starts a street name."""
|
|
140
|
+
token = self._peek()
|
|
141
|
+
return token is not None and not token.is_num and not self._is_number_marker(token)
|
|
142
|
+
|
|
143
|
+
def _skip_street_name(self) -> None:
|
|
144
|
+
while (token := self._peek()) and not token.is_num and not self._is_number_marker(token):
|
|
145
|
+
self.i += 1
|
|
146
|
+
|
|
147
|
+
def _parse_number(self, max_digits: int) -> int | None:
|
|
148
|
+
token = self._peek()
|
|
149
|
+
if token is None or not token.is_num or len(token.norm) > max_digits:
|
|
150
|
+
return None
|
|
151
|
+
self.i += 1
|
|
152
|
+
return int(token.norm)
|
|
153
|
+
|
|
154
|
+
# Letters and BIS after a number (section 4).
|
|
155
|
+
def _parse_suffix(self) -> str | None:
|
|
156
|
+
parts: list[str] = []
|
|
157
|
+
while (token := self._peek()) and not token.is_num:
|
|
158
|
+
if token.norm == "bis":
|
|
159
|
+
parts.append("BIS")
|
|
160
|
+
elif len(token.norm) == 1 and "a" <= token.norm <= "z":
|
|
161
|
+
# Standing alone, S and E are quadrants and N means "número".
|
|
162
|
+
if not token.attached and (
|
|
163
|
+
token.norm in SINGLE_LETTER_QUADRANTS or token.norm == "n"
|
|
164
|
+
):
|
|
165
|
+
break
|
|
166
|
+
parts.append(token.norm.upper())
|
|
167
|
+
else:
|
|
168
|
+
break
|
|
169
|
+
self.i += 1
|
|
170
|
+
return " ".join(parts) if parts else None
|
|
171
|
+
|
|
172
|
+
# Section 5.
|
|
173
|
+
def _parse_quadrant(self) -> Quadrant | None:
|
|
174
|
+
token = self._peek()
|
|
175
|
+
if token is None or token.is_num:
|
|
176
|
+
return None
|
|
177
|
+
quadrant = QUADRANT_ALIASES.get(token.norm)
|
|
178
|
+
if quadrant is None and not token.attached:
|
|
179
|
+
quadrant = SINGLE_LETTER_QUADRANTS.get(token.norm)
|
|
180
|
+
if quadrant:
|
|
181
|
+
self._warn("AMBIGUOUS_QUADRANT")
|
|
182
|
+
if quadrant is None:
|
|
183
|
+
return None
|
|
184
|
+
if quadrant in UNCOMMON_QUADRANTS:
|
|
185
|
+
self._warn("UNCOMMON_QUADRANT")
|
|
186
|
+
self.i += 1
|
|
187
|
+
return quadrant
|
|
188
|
+
|
|
189
|
+
# Steps 6-7: number markers, cross street, plate (section 6).
|
|
190
|
+
def _parse_placa(self) -> None:
|
|
191
|
+
a = self.a
|
|
192
|
+
while self._is_number_marker(self._peek()):
|
|
193
|
+
self.i += 1
|
|
194
|
+
|
|
195
|
+
block = self._peek()
|
|
196
|
+
if block is None or not block.is_num:
|
|
197
|
+
return
|
|
198
|
+
if len(block.norm) >= 5:
|
|
199
|
+
return # left for the leftovers step: UNKNOWN_TOKEN
|
|
200
|
+
self.i += 1
|
|
201
|
+
a.cross_letter = self._parse_suffix()
|
|
202
|
+
a.cross_quadrant = self._parse_quadrant()
|
|
203
|
+
|
|
204
|
+
plate = self._peek()
|
|
205
|
+
plate_follows = plate is not None and plate.is_num and len(plate.norm) <= 3
|
|
206
|
+
if (
|
|
207
|
+
not plate_follows
|
|
208
|
+
and a.cross_letter is None
|
|
209
|
+
and a.cross_quadrant is None
|
|
210
|
+
and len(block.norm) >= 3
|
|
211
|
+
):
|
|
212
|
+
# Glued cross and plate: "1230" -> 12 and 30.
|
|
213
|
+
a.cross_number = int(block.norm[:-2])
|
|
214
|
+
a.plate_number = int(block.norm[-2:])
|
|
215
|
+
self._warn("AMBIGUOUS_PLATE")
|
|
216
|
+
else:
|
|
217
|
+
a.cross_number = int(block.norm)
|
|
218
|
+
if plate_follows and plate is not None:
|
|
219
|
+
self.i += 1
|
|
220
|
+
a.plate_number = int(plate.norm)
|
|
221
|
+
if a.cross_quadrant is None:
|
|
222
|
+
a.cross_quadrant = self._parse_quadrant()
|
|
223
|
+
|
|
224
|
+
def _match_complement(self) -> tuple[ComplementCode, int] | None:
|
|
225
|
+
for code, words in _COMPLEMENT_MATCHERS:
|
|
226
|
+
if not self._matches_words(words):
|
|
227
|
+
continue
|
|
228
|
+
alias = " ".join(words)
|
|
229
|
+
following = self._peek(len(words))
|
|
230
|
+
# "tr" is Torre only once the placa is parsed; "p" is Piso only before a number.
|
|
231
|
+
if alias == "tr" and self.a.cross_number is None:
|
|
232
|
+
continue
|
|
233
|
+
if alias == "p" and (following is None or not following.is_num):
|
|
234
|
+
continue
|
|
235
|
+
return code, len(words)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
# Steps 8-9.
|
|
239
|
+
def _parse_complements_and_leftovers(self, text: str, locality_allowed: bool) -> None:
|
|
240
|
+
while self._peek():
|
|
241
|
+
match = self._match_complement()
|
|
242
|
+
if match:
|
|
243
|
+
code, length = match
|
|
244
|
+
self.i += length
|
|
245
|
+
value = self._read_name_value() if code.name_valued else self._read_token_value()
|
|
246
|
+
if value is None:
|
|
247
|
+
self._warn("UNKNOWN_TOKEN")
|
|
248
|
+
continue
|
|
249
|
+
self.a.complements.append(Complement(type=code.type, code=code.igac, value=value))
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
rest = self.tokens[self.i :]
|
|
253
|
+
all_words = all(not token.is_num for token in rest)
|
|
254
|
+
if (
|
|
255
|
+
locality_allowed
|
|
256
|
+
and all_words
|
|
257
|
+
and self.a.cross_number is not None
|
|
258
|
+
and self.a.locality is None
|
|
259
|
+
):
|
|
260
|
+
self.a.locality = text[rest[0].start :].strip()
|
|
261
|
+
self.i = len(self.tokens)
|
|
262
|
+
return
|
|
263
|
+
self._warn("UNKNOWN_TOKEN")
|
|
264
|
+
self.i += 1
|
|
265
|
+
|
|
266
|
+
def _read_token_value(self) -> str | None:
|
|
267
|
+
"""One token, plus any tokens attached to it: "501B"."""
|
|
268
|
+
first = self._peek()
|
|
269
|
+
if first is None or self._match_complement():
|
|
270
|
+
return None
|
|
271
|
+
value = first.orig
|
|
272
|
+
self.i += 1
|
|
273
|
+
while (token := self._peek()) and token.attached:
|
|
274
|
+
value += token.orig
|
|
275
|
+
self.i += 1
|
|
276
|
+
return value.upper()
|
|
277
|
+
|
|
278
|
+
def _read_name_value(self) -> str | None:
|
|
279
|
+
"""Words up to the next complement keyword or the end: "SAN FERNANDO"."""
|
|
280
|
+
words: list[str] = []
|
|
281
|
+
while (token := self._peek()) and not self._match_complement():
|
|
282
|
+
words.append(token.orig)
|
|
283
|
+
self.i += 1
|
|
284
|
+
return " ".join(words).upper() if words else None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _score(raised: list[str], strict: bool) -> float:
|
|
288
|
+
penalty = 0.0
|
|
289
|
+
counted: set[str] = set()
|
|
290
|
+
for code in raised:
|
|
291
|
+
if code in ONCE_ONLY and code in counted:
|
|
292
|
+
continue
|
|
293
|
+
counted.add(code)
|
|
294
|
+
penalty += PENALTIES.get(code, 0)
|
|
295
|
+
if strict:
|
|
296
|
+
penalty *= 2
|
|
297
|
+
return min(1.0, max(0.0, 1 - penalty))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse(input: str, style: Style = "igac", strict: bool = False) -> ParsedAddress:
|
|
301
|
+
"""Parses a Colombian address.
|
|
302
|
+
|
|
303
|
+
Never raises: unparseable input gets confidence 0 and warnings instead.
|
|
304
|
+
"""
|
|
305
|
+
if input.strip() == "":
|
|
306
|
+
return ParsedAddress(warnings=["EMPTY_INPUT"], raw=input)
|
|
307
|
+
|
|
308
|
+
parser = _AddressParser(input)
|
|
309
|
+
parser.run()
|
|
310
|
+
address = parser.a
|
|
311
|
+
address.canonical = render(address, style)
|
|
312
|
+
address.normalized = render(address, "readable")
|
|
313
|
+
address.confidence = _score(parser.raised, strict)
|
|
314
|
+
address.warnings = list(dict.fromkeys(parser.raised))
|
|
315
|
+
return address
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Output styles (docs/nomenclature.md section 10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .codes import COMPLEMENT_BY_IGAC, STREET_OUTPUT
|
|
6
|
+
from .types import ParsedAddress, Style
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _number_with_suffix(n: int, suffix: str | None, readable: bool) -> str:
|
|
10
|
+
""" "38" + "A BIS" -> "38A BIS": a leading single letter attaches to the number."""
|
|
11
|
+
if not suffix:
|
|
12
|
+
return str(n)
|
|
13
|
+
parts = suffix.split(" ")
|
|
14
|
+
if readable:
|
|
15
|
+
parts = ["Bis" if p == "BIS" else p for p in parts]
|
|
16
|
+
out = str(n)
|
|
17
|
+
if len(parts[0]) == 1:
|
|
18
|
+
out += parts[0]
|
|
19
|
+
parts = parts[1:]
|
|
20
|
+
return " ".join([out, *parts])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _title_case(word: str) -> str:
|
|
24
|
+
return word[0] + word[1:].lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _render_codes(a: ParsedAddress, style: str) -> str:
|
|
28
|
+
parts: list[str] = []
|
|
29
|
+
if a.street_type:
|
|
30
|
+
parts.append(STREET_OUTPUT[a.street_type][style])
|
|
31
|
+
if a.street_number is not None:
|
|
32
|
+
parts.append(_number_with_suffix(a.street_number, a.street_letter, False))
|
|
33
|
+
if a.street_quadrant:
|
|
34
|
+
parts.append(a.street_quadrant)
|
|
35
|
+
if a.cross_number is not None:
|
|
36
|
+
parts.append(_number_with_suffix(a.cross_number, a.cross_letter, False))
|
|
37
|
+
if a.plate_number is not None:
|
|
38
|
+
parts.append(str(a.plate_number))
|
|
39
|
+
if a.cross_quadrant:
|
|
40
|
+
parts.append(a.cross_quadrant)
|
|
41
|
+
for c in a.complements:
|
|
42
|
+
code = COMPLEMENT_BY_IGAC.get(c.code)
|
|
43
|
+
parts += [getattr(code, style) if code else c.code, c.value]
|
|
44
|
+
return " ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _render_readable(a: ParsedAddress) -> str:
|
|
48
|
+
head: list[str] = []
|
|
49
|
+
if a.street_type:
|
|
50
|
+
head.append(STREET_OUTPUT[a.street_type]["readable"])
|
|
51
|
+
if a.street_number is not None:
|
|
52
|
+
head.append(_number_with_suffix(a.street_number, a.street_letter, True))
|
|
53
|
+
if a.street_quadrant:
|
|
54
|
+
head.append(_title_case(a.street_quadrant))
|
|
55
|
+
out = " ".join(head)
|
|
56
|
+
|
|
57
|
+
if a.cross_number is not None:
|
|
58
|
+
cross = _number_with_suffix(a.cross_number, a.cross_letter, True)
|
|
59
|
+
if a.plate_number is not None:
|
|
60
|
+
cross += f"-{a.plate_number}"
|
|
61
|
+
if a.cross_quadrant:
|
|
62
|
+
cross += f" {_title_case(a.cross_quadrant)}"
|
|
63
|
+
out = f"{out} # {cross}" if out else f"# {cross}"
|
|
64
|
+
for c in a.complements:
|
|
65
|
+
code = COMPLEMENT_BY_IGAC.get(c.code)
|
|
66
|
+
out += f", {code.readable if code else c.code} {c.value}"
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render(address: ParsedAddress, style: Style) -> str:
|
|
71
|
+
return _render_readable(address) if style == "readable" else _render_codes(address, style)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Step 4 of the parsing procedure (docs/nomenclature.md section 12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
CharClass = Literal["letter", "digit", "separator"]
|
|
10
|
+
|
|
11
|
+
_LETTERS = set("abcdefghijklmnopqrstuvwxyzñ")
|
|
12
|
+
_DIGITS = set("0123456789")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Token:
|
|
17
|
+
norm: str
|
|
18
|
+
"""Lowercase, accents stripped (ñ kept). Used for matching."""
|
|
19
|
+
orig: str
|
|
20
|
+
"""The same characters as they appear in the input."""
|
|
21
|
+
is_num: bool
|
|
22
|
+
attached: bool
|
|
23
|
+
"""True when no separator stands between this token and the previous one: the "A" in "45A"."""
|
|
24
|
+
start: int
|
|
25
|
+
end: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_char(char: str) -> str:
|
|
29
|
+
"""Normalizes one character for matching."""
|
|
30
|
+
if char in ("ñ", "Ñ"):
|
|
31
|
+
return "ñ"
|
|
32
|
+
if char in ("º", "°"):
|
|
33
|
+
return "o"
|
|
34
|
+
decomposed = unicodedata.normalize("NFD", char)
|
|
35
|
+
# Drop every combining mark (Unicode category M), matching /\p{M}/u in the Node library.
|
|
36
|
+
return "".join(c for c in decomposed if not unicodedata.category(c).startswith("M")).lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _classify(norm: str) -> CharClass:
|
|
40
|
+
if norm in _LETTERS:
|
|
41
|
+
return "letter"
|
|
42
|
+
if norm in _DIGITS:
|
|
43
|
+
return "digit"
|
|
44
|
+
return "separator"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tokenize(text: str) -> list[Token]:
|
|
48
|
+
"""Splits on spaces, "#", hyphens, dashes, periods and any other punctuation,
|
|
49
|
+
and on letter-digit boundaries: "KR45" -> "KR", "45" and "45A" -> "45", "A".
|
|
50
|
+
"""
|
|
51
|
+
tokens: list[Token] = []
|
|
52
|
+
cls: CharClass | None = None
|
|
53
|
+
start = 0
|
|
54
|
+
norm = ""
|
|
55
|
+
last_end = -1
|
|
56
|
+
|
|
57
|
+
def flush(end: int) -> None:
|
|
58
|
+
nonlocal cls, last_end
|
|
59
|
+
if cls is None:
|
|
60
|
+
return
|
|
61
|
+
tokens.append(
|
|
62
|
+
Token(
|
|
63
|
+
norm=norm,
|
|
64
|
+
orig=text[start:end],
|
|
65
|
+
is_num=cls == "digit",
|
|
66
|
+
attached=bool(tokens) and start == last_end,
|
|
67
|
+
start=start,
|
|
68
|
+
end=end,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
last_end = end
|
|
72
|
+
cls = None
|
|
73
|
+
|
|
74
|
+
for offset, char in enumerate(text):
|
|
75
|
+
char_norm = normalize_char(char)
|
|
76
|
+
char_cls = _classify(char_norm)
|
|
77
|
+
if char_cls == "separator":
|
|
78
|
+
flush(offset)
|
|
79
|
+
elif cls == char_cls:
|
|
80
|
+
norm += char_norm
|
|
81
|
+
else:
|
|
82
|
+
flush(offset)
|
|
83
|
+
cls, start, norm = char_cls, offset, char_norm
|
|
84
|
+
flush(len(text))
|
|
85
|
+
return tokens
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
StreetType = Literal["CL", "KR", "AV", "AK", "AC", "DG", "TV", "CIR", "CCV", "AUTOP", "VIA", "KM"]
|
|
7
|
+
Quadrant = Literal["SUR", "ESTE", "NORTE", "OESTE"]
|
|
8
|
+
ComplementType = Literal[
|
|
9
|
+
"APARTAMENTO",
|
|
10
|
+
"TORRE",
|
|
11
|
+
"LOCAL",
|
|
12
|
+
"OFICINA",
|
|
13
|
+
"PISO",
|
|
14
|
+
"INTERIOR",
|
|
15
|
+
"BLOQUE",
|
|
16
|
+
"MANZANA",
|
|
17
|
+
"CASA",
|
|
18
|
+
"ETAPA",
|
|
19
|
+
"CONJUNTO",
|
|
20
|
+
"EDIFICIO",
|
|
21
|
+
"BODEGA",
|
|
22
|
+
"LOTE",
|
|
23
|
+
"BARRIO",
|
|
24
|
+
"OTRO",
|
|
25
|
+
]
|
|
26
|
+
Style = Literal["igac", "dian", "readable"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Complement:
|
|
31
|
+
type: ComplementType
|
|
32
|
+
code: str
|
|
33
|
+
"""IGAC code, whatever the output style: "APTO", "TO", "PH", ..."""
|
|
34
|
+
value: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ParsedAddress:
|
|
39
|
+
# Vía principal
|
|
40
|
+
street_type: StreetType | None = None
|
|
41
|
+
street_number: int | None = None
|
|
42
|
+
street_letter: str | None = None
|
|
43
|
+
street_quadrant: Quadrant | None = None
|
|
44
|
+
|
|
45
|
+
# Placa
|
|
46
|
+
cross_number: int | None = None
|
|
47
|
+
cross_letter: str | None = None
|
|
48
|
+
cross_quadrant: Quadrant | None = None
|
|
49
|
+
plate_number: int | None = None
|
|
50
|
+
|
|
51
|
+
complements: list[Complement] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
locality: str | None = None
|
|
54
|
+
department: str | None = None
|
|
55
|
+
|
|
56
|
+
canonical: str = ""
|
|
57
|
+
normalized: str = ""
|
|
58
|
+
confidence: float = 0.0
|
|
59
|
+
warnings: list[str] = field(default_factory=list)
|
|
60
|
+
raw: str = ""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from geographicol import parse
|
|
10
|
+
|
|
11
|
+
FIXTURES_PATH = Path(__file__).resolve().parents[3] / "fixtures" / "addresses.json"
|
|
12
|
+
FIXTURES: list[dict[str, Any]] = json.loads(FIXTURES_PATH.read_text(encoding="utf-8"))
|
|
13
|
+
|
|
14
|
+
TOLERANCE = 1e-9
|
|
15
|
+
STRING_KEYS = {"canonical", "canonicalDian", "normalized", "warnings"}
|
|
16
|
+
CASES = [
|
|
17
|
+
pytest.param(group, input, id=f"{group['id']}[{index}]")
|
|
18
|
+
for group in FIXTURES
|
|
19
|
+
for index, input in enumerate(group["inputs"])
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def snake_case(name: str) -> str:
|
|
24
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_at_least_60_groups_with_unique_ids() -> None:
|
|
28
|
+
assert len(FIXTURES) >= 60
|
|
29
|
+
assert len({group["id"] for group in FIXTURES}) == len(FIXTURES)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize(("group", "input"), CASES)
|
|
33
|
+
def test_fixture(group: dict[str, Any], input: str) -> None:
|
|
34
|
+
result = parse(input)
|
|
35
|
+
fields = asdict(result)
|
|
36
|
+
expected = group["expected"]
|
|
37
|
+
|
|
38
|
+
for key, value in expected.items():
|
|
39
|
+
if key in STRING_KEYS:
|
|
40
|
+
continue
|
|
41
|
+
assert fields[snake_case(key)] == value, key
|
|
42
|
+
if "canonical" in expected:
|
|
43
|
+
assert result.canonical == expected["canonical"]
|
|
44
|
+
if "canonicalDian" in expected:
|
|
45
|
+
assert parse(input, style="dian").canonical == expected["canonicalDian"]
|
|
46
|
+
if "normalized" in expected:
|
|
47
|
+
assert result.normalized == expected["normalized"]
|
|
48
|
+
if "warnings" in expected:
|
|
49
|
+
assert sorted(result.warnings) == sorted(expected["warnings"])
|
|
50
|
+
if "minConfidence" in group:
|
|
51
|
+
assert result.confidence >= group["minConfidence"] - TOLERANCE
|
|
52
|
+
if "maxConfidence" in group:
|
|
53
|
+
assert result.confidence <= group["maxConfidence"] + TOLERANCE
|