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.
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ *.egg-info/
10
+ .DS_Store
@@ -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