py-uk-postcode 1.0.0__py3-none-any.whl
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.
postcode/__init__.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Validate, parse, format, and search UK postcodes.
|
|
2
|
+
|
|
3
|
+
This package is a Python port of ``postcode-js``. Validation intentionally
|
|
4
|
+
checks the shape of a postcode, not whether the postcode currently exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
__version__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
DISTRICT_SPLIT_REGEX = re.compile(
|
|
16
|
+
r"^([A-Za-z]{1,2}[0-9])([A-Za-z])$"
|
|
17
|
+
)
|
|
18
|
+
UNIT_REGEX = re.compile(r"[A-Za-z]{2}$")
|
|
19
|
+
INCODE_REGEX = re.compile(r"[0-9][A-Za-z]{2}$")
|
|
20
|
+
OUTCODE_REGEX = re.compile(r"^[A-Za-z]{1,2}[0-9][A-Za-z0-9]?$")
|
|
21
|
+
POSTCODE_REGEX = re.compile(
|
|
22
|
+
r"^[A-Za-z]{1,2}[0-9][A-Za-z0-9]?\s*[0-9][A-Za-z]{2}$"
|
|
23
|
+
)
|
|
24
|
+
POSTCODE_CORPUS_REGEX = re.compile(
|
|
25
|
+
r"[A-Za-z]{1,2}[0-9][A-Za-z0-9]?\s*[0-9][A-Za-z]{2}"
|
|
26
|
+
)
|
|
27
|
+
AREA_REGEX = re.compile(r"^[A-Za-z]{1,2}")
|
|
28
|
+
FIXABLE_REGEX = re.compile(
|
|
29
|
+
r"^\s*[A-Za-z01]{1,2}[0-9OoIi][A-Za-z0-9]?"
|
|
30
|
+
r"\s*[0-9OoIi][A-Za-z01]{2}\s*$"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SPACE_REGEX = re.compile(r"\s+")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _PostcodeResult:
|
|
37
|
+
"""Shared conveniences for parsed postcode result objects."""
|
|
38
|
+
|
|
39
|
+
valid: bool
|
|
40
|
+
postcode: str | None
|
|
41
|
+
incode: str | None
|
|
42
|
+
outcode: str | None
|
|
43
|
+
area: str | None
|
|
44
|
+
district: str | None
|
|
45
|
+
sub_district: str | None
|
|
46
|
+
sector: str | None
|
|
47
|
+
unit: str | None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def subDistrict(self) -> str | None: # noqa: N802
|
|
51
|
+
"""JavaScript-compatible alias for :attr:`sub_district`."""
|
|
52
|
+
|
|
53
|
+
return self.sub_district
|
|
54
|
+
|
|
55
|
+
def __getitem__(self, key: str) -> object:
|
|
56
|
+
"""Allow dictionary-style access, as with the JavaScript result object."""
|
|
57
|
+
|
|
58
|
+
if key == "subDistrict":
|
|
59
|
+
key = "sub_district"
|
|
60
|
+
try:
|
|
61
|
+
return getattr(self, key)
|
|
62
|
+
except AttributeError as error:
|
|
63
|
+
raise KeyError(key) from error
|
|
64
|
+
|
|
65
|
+
def as_dict(self, *, camel_case: bool = False) -> dict[str, object]:
|
|
66
|
+
"""Return the parsed result as a dictionary."""
|
|
67
|
+
|
|
68
|
+
result: dict[str, object] = {
|
|
69
|
+
"valid": self.valid,
|
|
70
|
+
"postcode": self.postcode,
|
|
71
|
+
"incode": self.incode,
|
|
72
|
+
"outcode": self.outcode,
|
|
73
|
+
"area": self.area,
|
|
74
|
+
"district": self.district,
|
|
75
|
+
"sub_district": self.sub_district,
|
|
76
|
+
"sector": self.sector,
|
|
77
|
+
"unit": self.unit,
|
|
78
|
+
}
|
|
79
|
+
if camel_case:
|
|
80
|
+
result["subDistrict"] = result.pop("sub_district")
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ValidPostcode(_PostcodeResult):
|
|
86
|
+
"""The normalised components of a valid-looking postcode."""
|
|
87
|
+
|
|
88
|
+
valid: Literal[True]
|
|
89
|
+
postcode: str
|
|
90
|
+
incode: str
|
|
91
|
+
outcode: str
|
|
92
|
+
area: str
|
|
93
|
+
district: str
|
|
94
|
+
sub_district: str | None
|
|
95
|
+
sector: str
|
|
96
|
+
unit: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class InvalidPostcode(_PostcodeResult):
|
|
101
|
+
"""The result returned when a postcode does not have a valid shape."""
|
|
102
|
+
|
|
103
|
+
valid: Literal[False] = False
|
|
104
|
+
postcode: None = None
|
|
105
|
+
incode: None = None
|
|
106
|
+
outcode: None = None
|
|
107
|
+
area: None = None
|
|
108
|
+
district: None = None
|
|
109
|
+
sub_district: None = None
|
|
110
|
+
sector: None = None
|
|
111
|
+
unit: None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class ReplaceResult:
|
|
116
|
+
"""Postcodes found in a corpus and the text after replacement."""
|
|
117
|
+
|
|
118
|
+
match: list[str]
|
|
119
|
+
result: str
|
|
120
|
+
|
|
121
|
+
def __getitem__(self, key: str) -> object:
|
|
122
|
+
try:
|
|
123
|
+
return getattr(self, key)
|
|
124
|
+
except AttributeError as error:
|
|
125
|
+
raise KeyError(key) from error
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
ParsedPostcode = ValidPostcode | InvalidPostcode
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sanitize(value: str) -> str:
|
|
132
|
+
"""Remove whitespace and uppercase a string."""
|
|
133
|
+
|
|
134
|
+
return SPACE_REGEX.sub("", value).upper()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_valid(postcode: str) -> bool:
|
|
138
|
+
"""Return whether *postcode* conforms to a supported UK postcode shape."""
|
|
139
|
+
|
|
140
|
+
return POSTCODE_REGEX.match(postcode) is not None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def valid_outcode(outcode: str) -> bool:
|
|
144
|
+
"""Return whether *outcode* has a valid outward-code shape."""
|
|
145
|
+
|
|
146
|
+
return OUTCODE_REGEX.match(outcode) is not None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_normalised(postcode: str) -> str | None:
|
|
150
|
+
"""Return an uppercase, correctly spaced postcode, or ``None`` if invalid."""
|
|
151
|
+
|
|
152
|
+
outcode = to_outcode(postcode)
|
|
153
|
+
if outcode is None:
|
|
154
|
+
return None
|
|
155
|
+
incode = to_incode(postcode)
|
|
156
|
+
assert incode is not None
|
|
157
|
+
return f"{outcode} {incode}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def to_outcode(postcode: str) -> str | None:
|
|
161
|
+
"""Return the correctly formatted outward code, or ``None`` if invalid."""
|
|
162
|
+
|
|
163
|
+
if not is_valid(postcode):
|
|
164
|
+
return None
|
|
165
|
+
return INCODE_REGEX.sub("", _sanitize(postcode))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def to_incode(postcode: str) -> str | None:
|
|
169
|
+
"""Return the correctly formatted inward code, or ``None`` if invalid."""
|
|
170
|
+
|
|
171
|
+
if not is_valid(postcode):
|
|
172
|
+
return None
|
|
173
|
+
match_result = INCODE_REGEX.search(_sanitize(postcode))
|
|
174
|
+
assert match_result is not None
|
|
175
|
+
return match_result.group(0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def to_area(postcode: str) -> str | None:
|
|
179
|
+
"""Return the postcode area, or ``None`` if invalid."""
|
|
180
|
+
|
|
181
|
+
if not is_valid(postcode):
|
|
182
|
+
return None
|
|
183
|
+
match_result = AREA_REGEX.search(_sanitize(postcode))
|
|
184
|
+
assert match_result is not None
|
|
185
|
+
return match_result.group(0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def to_sector(postcode: str) -> str | None:
|
|
189
|
+
"""Return the postcode sector, or ``None`` if invalid."""
|
|
190
|
+
|
|
191
|
+
outcode = to_outcode(postcode)
|
|
192
|
+
incode = to_incode(postcode)
|
|
193
|
+
if outcode is None or incode is None:
|
|
194
|
+
return None
|
|
195
|
+
return f"{outcode} {incode[0]}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def to_unit(postcode: str) -> str | None:
|
|
199
|
+
"""Return the postcode unit, or ``None`` if invalid."""
|
|
200
|
+
|
|
201
|
+
if not is_valid(postcode):
|
|
202
|
+
return None
|
|
203
|
+
match_result = UNIT_REGEX.search(_sanitize(postcode))
|
|
204
|
+
assert match_result is not None
|
|
205
|
+
return match_result.group(0)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def to_district(postcode: str) -> str | None:
|
|
209
|
+
"""Return the postcode district, or ``None`` if invalid."""
|
|
210
|
+
|
|
211
|
+
outcode = to_outcode(postcode)
|
|
212
|
+
if outcode is None:
|
|
213
|
+
return None
|
|
214
|
+
split = DISTRICT_SPLIT_REGEX.match(outcode)
|
|
215
|
+
if split is None:
|
|
216
|
+
return outcode
|
|
217
|
+
return split.group(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def to_sub_district(postcode: str) -> str | None:
|
|
221
|
+
"""Return the sub-district, or ``None`` if absent or the postcode is invalid."""
|
|
222
|
+
|
|
223
|
+
outcode = to_outcode(postcode)
|
|
224
|
+
if outcode is None or DISTRICT_SPLIT_REGEX.match(outcode) is None:
|
|
225
|
+
return None
|
|
226
|
+
return outcode
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse(postcode: str) -> ParsedPostcode:
|
|
230
|
+
"""Parse *postcode* into a typed result containing all postcode components."""
|
|
231
|
+
|
|
232
|
+
if not is_valid(postcode):
|
|
233
|
+
return InvalidPostcode()
|
|
234
|
+
|
|
235
|
+
normalised = to_normalised(postcode)
|
|
236
|
+
incode = to_incode(postcode)
|
|
237
|
+
outcode = to_outcode(postcode)
|
|
238
|
+
area = to_area(postcode)
|
|
239
|
+
district = to_district(postcode)
|
|
240
|
+
sector = to_sector(postcode)
|
|
241
|
+
unit = to_unit(postcode)
|
|
242
|
+
|
|
243
|
+
# The validation above guarantees all of these components are present.
|
|
244
|
+
assert normalised is not None
|
|
245
|
+
assert incode is not None
|
|
246
|
+
assert outcode is not None
|
|
247
|
+
assert area is not None
|
|
248
|
+
assert district is not None
|
|
249
|
+
assert sector is not None
|
|
250
|
+
assert unit is not None
|
|
251
|
+
|
|
252
|
+
return ValidPostcode(
|
|
253
|
+
valid=True,
|
|
254
|
+
postcode=normalised,
|
|
255
|
+
incode=incode,
|
|
256
|
+
outcode=outcode,
|
|
257
|
+
area=area,
|
|
258
|
+
district=district,
|
|
259
|
+
sub_district=to_sub_district(postcode),
|
|
260
|
+
sector=sector,
|
|
261
|
+
unit=unit,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def match(corpus: str) -> list[str]:
|
|
266
|
+
"""Return postcode-shaped matches from a body of text."""
|
|
267
|
+
|
|
268
|
+
return [result.group(0) for result in POSTCODE_CORPUS_REGEX.finditer(corpus)]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def replace(corpus: str, replace_with: str = "") -> ReplaceResult:
|
|
272
|
+
"""Replace postcode-shaped text and return both the matches and new corpus."""
|
|
273
|
+
|
|
274
|
+
return ReplaceResult(
|
|
275
|
+
match=match(corpus),
|
|
276
|
+
result=POSTCODE_CORPUS_REGEX.sub(replace_with, corpus),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_TO_LETTER = {"0": "O", "1": "I"}
|
|
281
|
+
_TO_NUMBER = {"O": "0", "I": "1"}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _coerce(pattern: str, value: str) -> str:
|
|
285
|
+
output = []
|
|
286
|
+
for index, character in enumerate(value):
|
|
287
|
+
target = pattern[index]
|
|
288
|
+
if target == "N":
|
|
289
|
+
output.append(_TO_NUMBER.get(character, character))
|
|
290
|
+
elif target == "L":
|
|
291
|
+
output.append(_TO_LETTER.get(character, character))
|
|
292
|
+
else:
|
|
293
|
+
output.append(character)
|
|
294
|
+
return "".join(output)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _coerce_outcode(value: str) -> str:
|
|
298
|
+
patterns = {2: "LN", 3: "L??", 4: "LLN?"}
|
|
299
|
+
return _coerce(patterns[len(value)], value)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fix(value: str) -> str:
|
|
303
|
+
"""Correct common O/0 and I/1 mistakes and normalise obvious postcodes.
|
|
304
|
+
|
|
305
|
+
If the input cannot be reliably coerced into a postcode shape, it is
|
|
306
|
+
returned unchanged.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
if FIXABLE_REGEX.match(value) is None:
|
|
310
|
+
return value
|
|
311
|
+
sanitized = _sanitize(value.strip())
|
|
312
|
+
inward = sanitized[-3:]
|
|
313
|
+
return f'{_coerce_outcode(sanitized[:-3])} {_coerce("NLL", inward)}'
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Migration aliases matching the original JavaScript named exports.
|
|
317
|
+
isValid = is_valid
|
|
318
|
+
validOutcode = valid_outcode
|
|
319
|
+
toNormalised = to_normalised
|
|
320
|
+
toOutcode = to_outcode
|
|
321
|
+
toIncode = to_incode
|
|
322
|
+
toArea = to_area
|
|
323
|
+
toDistrict = to_district
|
|
324
|
+
toSubDistrict = to_sub_district
|
|
325
|
+
toSector = to_sector
|
|
326
|
+
toUnit = to_unit
|
|
327
|
+
|
|
328
|
+
__all__ = [
|
|
329
|
+
"AREA_REGEX",
|
|
330
|
+
"DISTRICT_SPLIT_REGEX",
|
|
331
|
+
"FIXABLE_REGEX",
|
|
332
|
+
"INCODE_REGEX",
|
|
333
|
+
"InvalidPostcode",
|
|
334
|
+
"OUTCODE_REGEX",
|
|
335
|
+
"POSTCODE_CORPUS_REGEX",
|
|
336
|
+
"POSTCODE_REGEX",
|
|
337
|
+
"ParsedPostcode",
|
|
338
|
+
"ReplaceResult",
|
|
339
|
+
"UNIT_REGEX",
|
|
340
|
+
"ValidPostcode",
|
|
341
|
+
"fix",
|
|
342
|
+
"isValid",
|
|
343
|
+
"is_valid",
|
|
344
|
+
"match",
|
|
345
|
+
"parse",
|
|
346
|
+
"replace",
|
|
347
|
+
"toArea",
|
|
348
|
+
"toDistrict",
|
|
349
|
+
"toIncode",
|
|
350
|
+
"toNormalised",
|
|
351
|
+
"toOutcode",
|
|
352
|
+
"toSector",
|
|
353
|
+
"toSubDistrict",
|
|
354
|
+
"toUnit",
|
|
355
|
+
"to_area",
|
|
356
|
+
"to_district",
|
|
357
|
+
"to_incode",
|
|
358
|
+
"to_normalised",
|
|
359
|
+
"to_outcode",
|
|
360
|
+
"to_sector",
|
|
361
|
+
"to_sub_district",
|
|
362
|
+
"to_unit",
|
|
363
|
+
"validOutcode",
|
|
364
|
+
"valid_outcode",
|
|
365
|
+
]
|
postcode/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-uk-postcode
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: UK postcode validation, parsing, formatting, and text utilities
|
|
5
|
+
Author: Area360
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/area360-uk/py-postcode
|
|
8
|
+
Project-URL: Documentation, https://github.com/area360-uk/py-postcode#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/area360-uk/py-postcode
|
|
10
|
+
Project-URL: Issues, https://github.com/area360-uk/py-postcode/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/area360-uk/py-postcode/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: uk,postcode,validation,parsing
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://raw.githubusercontent.com/area360-uk/py-postcode/main/assets/py-postcode.png" alt="Py UK Postcode" width="100%">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
# Py UK Postcode
|
|
39
|
+
|
|
40
|
+
> Validate and parse UK postcodes in Python
|
|
41
|
+
|
|
42
|
+
[](https://github.com/area360-uk/py-postcode/actions/workflows/ci.yml)
|
|
43
|
+
[](https://pypi.org/project/py-uk-postcode/)
|
|
44
|
+
[](https://www.python.org/downloads/)
|
|
45
|
+
|
|
46
|
+
Utility methods for UK postcodes, including validating the shape of a postcode
|
|
47
|
+
and extracting postcode elements such as incodes, outcodes, areas, and
|
|
48
|
+
[more](#definitions).
|
|
49
|
+
|
|
50
|
+
The implementation is a close Python port of [ideal-postcodes/postcode](https://github.com/ideal-postcodes/postcode), whose format rules
|
|
51
|
+
were tested against roughly 1.7 million postcodes from the ONS Postcode
|
|
52
|
+
Directory.
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
- [Check](#validate) whether a postcode conforms to the
|
|
57
|
+
[correct format](https://en.wikipedia.org/wiki/Postcodes_in_the_United_Kingdom#formatting)
|
|
58
|
+
- Small, single-purpose functions with no runtime dependencies
|
|
59
|
+
- [Extract](#parse) postcode elements such as the incode, outcode, and sector
|
|
60
|
+
- Search for and replace postcodes in larger bodies of text
|
|
61
|
+
- Correct common `O`/`0` and `I`/`1` input mistakes
|
|
62
|
+
- Fully typed Python API
|
|
63
|
+
|
|
64
|
+
## Acknowledgments and credits
|
|
65
|
+
|
|
66
|
+
`py-uk-postcode` is a Python port of the original [ideal-postcodes/postcode](https://github.com/ideal-postcodes/postcode), created
|
|
67
|
+
by Ideal Postcodes and released under the MIT License. The original project's
|
|
68
|
+
API design, implementation, documentation, and test fixtures formed the basis
|
|
69
|
+
of this port.
|
|
70
|
+
|
|
71
|
+
## Links
|
|
72
|
+
|
|
73
|
+
- [GitHub repository](https://github.com/area360-uk/py-postcode)
|
|
74
|
+
- [Package on PyPI](https://pypi.org/project/py-uk-postcode/)
|
|
75
|
+
- [Issue tracker](https://github.com/area360-uk/py-postcode/issues)
|
|
76
|
+
- [Postcode element definitions](#definitions)
|
|
77
|
+
- [Notes](#notes)
|
|
78
|
+
|
|
79
|
+
## Getting started
|
|
80
|
+
|
|
81
|
+
### Installation
|
|
82
|
+
|
|
83
|
+
With `pip`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python -m pip install py-uk-postcode
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With `uv`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv add py-uk-postcode
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With Poetry:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
poetry add py-uk-postcode
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
With PDM:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pdm add py-uk-postcode
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
With Conda, create or activate an environment and install the package from
|
|
108
|
+
PyPI:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
conda create --name postcode python=3.14 pip
|
|
112
|
+
conda activate postcode
|
|
113
|
+
python -m pip install py-uk-postcode
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The distribution is named `py-uk-postcode`; import it in Python as `postcode`.
|
|
117
|
+
|
|
118
|
+
### Validate
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from postcode import is_valid
|
|
122
|
+
|
|
123
|
+
is_valid("AA1 1AB") # => True
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Parse
|
|
127
|
+
|
|
128
|
+
Pass a string to `parse()`. It returns either a `ValidPostcode` or an
|
|
129
|
+
`InvalidPostcode`, both of which expose their values as attributes.
|
|
130
|
+
|
|
131
|
+
#### Valid postcode
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from postcode import parse
|
|
135
|
+
|
|
136
|
+
result = parse("Sw1A 2aa")
|
|
137
|
+
|
|
138
|
+
result.postcode # => "SW1A 2AA"
|
|
139
|
+
result.outcode # => "SW1A"
|
|
140
|
+
result.incode # => "2AA"
|
|
141
|
+
result.area # => "SW"
|
|
142
|
+
result.district # => "SW1"
|
|
143
|
+
result.unit # => "AA"
|
|
144
|
+
result.sector # => "SW1A 2"
|
|
145
|
+
result.sub_district # => "SW1A"
|
|
146
|
+
result.valid # => True
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### Invalid postcode
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
result = parse(" Oh no, ): ")
|
|
153
|
+
|
|
154
|
+
result.postcode # => None
|
|
155
|
+
result.outcode # => None
|
|
156
|
+
result.incode # => None
|
|
157
|
+
result.area # => None
|
|
158
|
+
result.district # => None
|
|
159
|
+
result.unit # => None
|
|
160
|
+
result.sector # => None
|
|
161
|
+
result.sub_district # => None
|
|
162
|
+
result.valid # => False
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### Type narrowing
|
|
166
|
+
|
|
167
|
+
`parse()` returns a typed union. Type checkers can narrow it by checking the
|
|
168
|
+
literal `valid` attribute:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from postcode import parse
|
|
172
|
+
|
|
173
|
+
result = parse("SW1A 2AA")
|
|
174
|
+
|
|
175
|
+
if result.valid:
|
|
176
|
+
print(result.outcode.lower())
|
|
177
|
+
if result.sub_district is not None:
|
|
178
|
+
print(result.sub_district.lower())
|
|
179
|
+
else:
|
|
180
|
+
print("Invalid postcode")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### Valid postcode object
|
|
184
|
+
|
|
185
|
+
| Postcode | `.outcode` | `.incode` | `.area` | `.district` | `.sub_district` | `.sector` | `.unit` |
|
|
186
|
+
|----------|------------|-----------|---------|-------------|-----------------|-----------|---------|
|
|
187
|
+
| AA9A 9AA | AA9A | 9AA | AA | AA9 | AA9A | AA9A 9 | AA |
|
|
188
|
+
| A9A 9AA | A9A | 9AA | A | A9 | A9A | A9A 9 | AA |
|
|
189
|
+
| A9 9AA | A9 | 9AA | A | A9 | `None` | A9 9 | AA |
|
|
190
|
+
| A99 9AA | A99 | 9AA | A | A99 | `None` | A99 9 | AA |
|
|
191
|
+
| AA9 9AA | AA9 | 9AA | AA | AA9 | `None` | AA9 9 | AA |
|
|
192
|
+
| AA99 9AA | AA99 | 9AA | AA | AA99 | `None` | AA99 9 | AA |
|
|
193
|
+
|
|
194
|
+
### Exported functions
|
|
195
|
+
|
|
196
|
+
If you need a single value, import the corresponding function directly.
|
|
197
|
+
|
|
198
|
+
#### Validation
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from postcode import is_valid, valid_outcode
|
|
202
|
+
|
|
203
|
+
is_valid("Sw1A 2aa") # => True
|
|
204
|
+
valid_outcode("SW1A") # => True
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Formatting
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from postcode import (
|
|
211
|
+
to_area,
|
|
212
|
+
to_district,
|
|
213
|
+
to_incode,
|
|
214
|
+
to_normalised,
|
|
215
|
+
to_outcode,
|
|
216
|
+
to_sector,
|
|
217
|
+
to_sub_district,
|
|
218
|
+
to_unit,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
to_normalised("Sw1A 2aa") # => "SW1A 2AA"
|
|
222
|
+
to_outcode("Sw1A 2aa") # => "SW1A"
|
|
223
|
+
to_incode("Sw1A 2aa") # => "2AA"
|
|
224
|
+
to_area("Sw1A 2aa") # => "SW"
|
|
225
|
+
to_district("Sw1A 2aa") # => "SW1"
|
|
226
|
+
to_sub_district("Sw1A 2aa") # => "SW1A"
|
|
227
|
+
to_sector("Sw1A 2aa") # => "SW1A 2"
|
|
228
|
+
to_unit("Sw1A 2aa") # => "AA"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
All formatting functions return `None` when given an invalid postcode.
|
|
232
|
+
|
|
233
|
+
#### Fix
|
|
234
|
+
|
|
235
|
+
`fix()` attempts to clean up a postcode without validating it. It replaces
|
|
236
|
+
commonly confused characters (`O`/`0` and `I`/`1`), uppercases the value, and
|
|
237
|
+
corrects its spacing. If the input cannot be reliably fixed, the original
|
|
238
|
+
string is returned.
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from postcode import fix, parse
|
|
242
|
+
|
|
243
|
+
fix("SWIA 2AA") # => "SW1A 2AA"
|
|
244
|
+
fix("SW1A 21A") # => "SW1A 2IA"
|
|
245
|
+
fix("SW1A OAA") # => "SW1A 0AA"
|
|
246
|
+
fix("SW1A 20A") # => "SW1A 2OA"
|
|
247
|
+
fix(" SW1A 2AO") # => "SW1A 2AO"
|
|
248
|
+
fix("sw1a 2aa") # => "SW1A 2AA"
|
|
249
|
+
|
|
250
|
+
result = parse(fix("SW1A 2A0"))
|
|
251
|
+
result.incode # => "2AO"
|
|
252
|
+
|
|
253
|
+
fix("12a") # => "12a"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### Extract and replace
|
|
257
|
+
|
|
258
|
+
`match()` retrieves postcode-shaped values from a body of text. Matches retain
|
|
259
|
+
their original casing and spacing.
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from postcode import match, to_normalised, to_outcode
|
|
263
|
+
|
|
264
|
+
matches = match("The two addresses are SW1A2aa and SW1A 2AB")
|
|
265
|
+
# => ["SW1A2aa", "SW1A 2AB"]
|
|
266
|
+
|
|
267
|
+
[to_normalised(value) for value in matches]
|
|
268
|
+
# => ["SW1A 2AA", "SW1A 2AB"]
|
|
269
|
+
|
|
270
|
+
[to_outcode(value) for value in matches]
|
|
271
|
+
# => ["SW1A", "SW1A"]
|
|
272
|
+
|
|
273
|
+
match("Some London outward codes are SW1A, NW1 and E1") # => []
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`replace()` replaces postcode-shaped values and returns a `ReplaceResult`
|
|
277
|
+
containing the matches and resulting text:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
from postcode import replace
|
|
281
|
+
|
|
282
|
+
replacement = replace("The two addresses are SW1A2AA and SW1A 2AB")
|
|
283
|
+
replacement.match
|
|
284
|
+
# => ["SW1A2AA", "SW1A 2AB"]
|
|
285
|
+
replacement.result
|
|
286
|
+
# => "The two addresses are and "
|
|
287
|
+
|
|
288
|
+
replace("The address is SW1A 2AA", "Downing Street").result
|
|
289
|
+
# => "The address is Downing Street"
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Regular expressions
|
|
293
|
+
|
|
294
|
+
The compiled regular expressions used by the package are public:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from postcode import POSTCODE_REGEX
|
|
298
|
+
|
|
299
|
+
bool(POSTCODE_REGEX.match("SW1A 2AA")) # => True
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The other exports are `AREA_REGEX`, `DISTRICT_SPLIT_REGEX`, `FIXABLE_REGEX`,
|
|
303
|
+
`INCODE_REGEX`, `OUTCODE_REGEX`, `POSTCODE_CORPUS_REGEX`, and `UNIT_REGEX`.
|
|
304
|
+
|
|
305
|
+
## Definitions
|
|
306
|
+
|
|
307
|
+
A UK postcode is made up of an outward code and an inward code. The outward
|
|
308
|
+
code contains the area and district, and can include a sub-district. The inward
|
|
309
|
+
code contains the sector digit and unit letters. For example, in `SW1A 2AA`:
|
|
310
|
+
|
|
311
|
+
- Area: `SW`
|
|
312
|
+
- District: `SW1`
|
|
313
|
+
- Sub-district: `SW1A`
|
|
314
|
+
- Outcode: `SW1A`
|
|
315
|
+
- Incode: `2AA`
|
|
316
|
+
- Sector: `SW1A 2`
|
|
317
|
+
- Unit: `AA`
|
|
318
|
+
|
|
319
|
+
## Notes
|
|
320
|
+
|
|
321
|
+
Postcodes cannot be authoritatively validated with a regular expression,
|
|
322
|
+
however complex. True validation requires checking against a current postcode
|
|
323
|
+
dataset. This package validates the *shape* of a postcode and can therefore
|
|
324
|
+
produce false positives or negatives when used as an existence check.
|
|
325
|
+
|
|
326
|
+
## Development
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
python -m pip install -e ".[dev]"
|
|
330
|
+
pytest
|
|
331
|
+
ruff check .
|
|
332
|
+
mypy
|
|
333
|
+
python -m build
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## License
|
|
337
|
+
|
|
338
|
+
MIT
|
|
339
|
+
|
|
340
|
+
Contains Ordnance Survey Data © Crown Copyright & Database Right.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
postcode/__init__.py,sha256=zOPNnFlSxe_cDtFlDnV0R4w-_6M-7M7glRWoKbcvxpc,9638
|
|
2
|
+
postcode/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
3
|
+
py_uk_postcode-1.0.0.dist-info/licenses/LICENSE,sha256=mJHzYC7jnX1lwuCGv6jwZDdZCePqcjgiyH3E4E3HySg,1137
|
|
4
|
+
py_uk_postcode-1.0.0.dist-info/METADATA,sha256=Qs9Rv1YNHk5xl8yXsBNGJv0sbxy7-DUVjHWvk3wKQVA,9784
|
|
5
|
+
py_uk_postcode-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
py_uk_postcode-1.0.0.dist-info/top_level.txt,sha256=yHUOHTtClfsmTkD3sMkdQaTLP7XrR2Mj3NW29Pe2LTY,9
|
|
7
|
+
py_uk_postcode-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Area360 (Python port)
|
|
4
|
+
Copyright (c) 2020 IDDQD Limited (Original implementation)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
postcode
|