Metadata-Version: 2.4
Name: py-uk-postcode
Version: 1.0.1
Summary: UK postcode validation, parsing, formatting, and text utilities
Author: Area360
License-Expression: MIT
Project-URL: Homepage, https://github.com/area360-uk/py-postcode
Project-URL: Documentation, https://github.com/area360-uk/py-postcode#readme
Project-URL: Repository, https://github.com/area360-uk/py-postcode
Project-URL: Issues, https://github.com/area360-uk/py-postcode/issues
Project-URL: Changelog, https://github.com/area360-uk/py-postcode/blob/main/CHANGELOG.md
Keywords: uk,postcode,validation,parsing
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

<p align="center">
  <img src="https://raw.githubusercontent.com/area360-uk/py-postcode/main/assets/py-postcode.png" alt="Py UK Postcode" width="100%">
</p>

# Py UK Postcode

> Validate and parse UK postcodes in Python

[![CI](https://github.com/area360-uk/py-postcode/actions/workflows/ci.yml/badge.svg)](https://github.com/area360-uk/py-postcode/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/py-uk-postcode.svg)](https://pypi.org/project/py-uk-postcode/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)

Utility methods for UK postcodes, including validating the shape of a postcode
and extracting postcode elements such as incodes, outcodes, areas, and
[more](#definitions).

The implementation is a close Python port of [ideal-postcodes/postcode](https://github.com/ideal-postcodes/postcode), whose format rules
were tested against roughly 1.7 million postcodes from the ONS Postcode
Directory.

## Features

- [Check](#validate) whether a postcode conforms to the
  [correct format](https://en.wikipedia.org/wiki/Postcodes_in_the_United_Kingdom#formatting)
- Small, single-purpose functions with no runtime dependencies
- [Extract](#parse) postcode elements such as the incode, outcode, and sector
- Search for and replace postcodes in larger bodies of text
- Correct common `O`/`0` and `I`/`1` input mistakes
- Fully typed Python API

## Acknowledgments and credits

`py-uk-postcode` is a Python port of the original [ideal-postcodes/postcode](https://github.com/ideal-postcodes/postcode), created
by Ideal Postcodes and released under the MIT License. The original project's
API design, implementation, documentation, and test fixtures formed the basis
of this port.

## Links

- [GitHub repository](https://github.com/area360-uk/py-postcode)
- [Package on PyPI](https://pypi.org/project/py-uk-postcode/)
- [Issue tracker](https://github.com/area360-uk/py-postcode/issues)
- [Area360](https://area360.uk/)
- [Postcode element definitions](#definitions)
- [Notes](#notes)

## Getting started

### Installation

With `pip`:

```bash
python -m pip install py-uk-postcode
```

With `uv`:

```bash
uv add py-uk-postcode
```

With Poetry:

```bash
poetry add py-uk-postcode
```

With PDM:

```bash
pdm add py-uk-postcode
```

With Conda, create or activate an environment and install the package from
PyPI:

```bash
conda create --name postcode python=3.14 pip
conda activate postcode
python -m pip install py-uk-postcode
```

The distribution is named `py-uk-postcode`; import it in Python as `postcode`.

### Validate

```python
from postcode import is_valid

is_valid("AA1 1AB")  # => True
```

### Parse

Pass a string to `parse()`. It returns either a `ValidPostcode` or an
`InvalidPostcode`, both of which expose their values as attributes.

#### Valid postcode

```python
from postcode import parse

result = parse("Sw1A     2aa")

result.postcode       # => "SW1A 2AA"
result.outcode        # => "SW1A"
result.incode         # => "2AA"
result.area           # => "SW"
result.district       # => "SW1"
result.unit           # => "AA"
result.sector         # => "SW1A 2"
result.sub_district   # => "SW1A"
result.valid          # => True
```

#### Invalid postcode

```python
result = parse("    Oh no, ):   ")

result.postcode       # => None
result.outcode        # => None
result.incode         # => None
result.area           # => None
result.district       # => None
result.unit           # => None
result.sector         # => None
result.sub_district   # => None
result.valid          # => False
```

#### Type narrowing

`parse()` returns a typed union. Type checkers can narrow it by checking the
literal `valid` attribute:

```python
from postcode import parse

result = parse("SW1A 2AA")

if result.valid:
    print(result.outcode.lower())
    if result.sub_district is not None:
        print(result.sub_district.lower())
else:
    print("Invalid postcode")
```

#### Valid postcode object

| Postcode | `.outcode` | `.incode` | `.area` | `.district` | `.sub_district` | `.sector` | `.unit` |
|----------|------------|-----------|---------|-------------|-----------------|-----------|---------|
| AA9A 9AA | AA9A       | 9AA       | AA      | AA9         | AA9A            | AA9A 9    | AA      |
| A9A 9AA  | A9A        | 9AA       | A       | A9          | A9A             | A9A 9     | AA      |
| A9 9AA   | A9         | 9AA       | A       | A9          | `None`          | A9 9      | AA      |
| A99 9AA  | A99        | 9AA       | A       | A99         | `None`          | A99 9     | AA      |
| AA9 9AA  | AA9        | 9AA       | AA      | AA9         | `None`          | AA9 9     | AA      |
| AA99 9AA | AA99       | 9AA       | AA      | AA99        | `None`          | AA99 9    | AA      |

### Exported functions

If you need a single value, import the corresponding function directly.

#### Validation

```python
from postcode import is_valid, valid_outcode

is_valid("Sw1A 2aa")  # => True
valid_outcode("SW1A")  # => True
```

#### Formatting

```python
from postcode import (
    to_area,
    to_district,
    to_incode,
    to_normalised,
    to_outcode,
    to_sector,
    to_sub_district,
    to_unit,
)

to_normalised("Sw1A 2aa")   # => "SW1A 2AA"
to_outcode("Sw1A 2aa")      # => "SW1A"
to_incode("Sw1A 2aa")       # => "2AA"
to_area("Sw1A 2aa")         # => "SW"
to_district("Sw1A 2aa")     # => "SW1"
to_sub_district("Sw1A 2aa") # => "SW1A"
to_sector("Sw1A 2aa")       # => "SW1A 2"
to_unit("Sw1A 2aa")         # => "AA"
```

All formatting functions return `None` when given an invalid postcode.

#### Fix

`fix()` attempts to clean up a postcode without validating it. It replaces
commonly confused characters (`O`/`0` and `I`/`1`), uppercases the value, and
corrects its spacing. If the input cannot be reliably fixed, the original
string is returned.

```python
from postcode import fix, parse

fix("SWIA 2AA")   # => "SW1A 2AA"
fix("SW1A 21A")  # => "SW1A 2IA"
fix("SW1A OAA")  # => "SW1A 0AA"
fix("SW1A 20A")  # => "SW1A 2OA"
fix(" SW1A  2AO")  # => "SW1A 2AO"
fix("sw1a 2aa")    # => "SW1A 2AA"

result = parse(fix("SW1A 2A0"))
result.incode  # => "2AO"

fix("12a")  # => "12a"
```

#### Extract and replace

`match()` retrieves postcode-shaped values from a body of text. Matches retain
their original casing and spacing.

```python
from postcode import match, to_normalised, to_outcode

matches = match("The two addresses are SW1A2aa and SW1A 2AB")
# => ["SW1A2aa", "SW1A 2AB"]

[to_normalised(value) for value in matches]
# => ["SW1A 2AA", "SW1A 2AB"]

[to_outcode(value) for value in matches]
# => ["SW1A", "SW1A"]

match("Some London outward codes are SW1A, NW1 and E1")  # => []
```

`replace()` replaces postcode-shaped values and returns a `ReplaceResult`
containing the matches and resulting text:

```python
from postcode import replace

replacement = replace("The two addresses are SW1A2AA and SW1A 2AB")
replacement.match
# => ["SW1A2AA", "SW1A 2AB"]
replacement.result
# => "The two addresses are  and "

replace("The address is SW1A 2AA", "Downing Street").result
# => "The address is Downing Street"
```

### Regular expressions

The compiled regular expressions used by the package are public:

```python
from postcode import POSTCODE_REGEX

bool(POSTCODE_REGEX.match("SW1A 2AA"))  # => True
```

The other exports are `AREA_REGEX`, `DISTRICT_SPLIT_REGEX`, `FIXABLE_REGEX`,
`INCODE_REGEX`, `OUTCODE_REGEX`, `POSTCODE_CORPUS_REGEX`, and `UNIT_REGEX`.

## Definitions

A UK postcode is made up of an outward code and an inward code. The outward
code contains the area and district, and can include a sub-district. The inward
code contains the sector digit and unit letters. For example, in `SW1A 2AA`:

- Area: `SW`
- District: `SW1`
- Sub-district: `SW1A`
- Outcode: `SW1A`
- Incode: `2AA`
- Sector: `SW1A 2`
- Unit: `AA`

## Notes

Postcodes cannot be authoritatively validated with a regular expression,
however complex. True validation requires checking against a current postcode
dataset. This package validates the *shape* of a postcode and can therefore
produce false positives or negatives when used as an existence check.

## Development

```bash
python -m pip install -e ".[dev]"
pytest
ruff check .
mypy
python -m build
```

## License

MIT

Contains Ordnance Survey Data © Crown Copyright & Database Right.
