aus-identity 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,56 @@
1
+ name: release
2
+
3
+ # Push a tag like v0.1.0 → wheel is built and published to PyPI via
4
+ # Trusted Publishing (no API token in repo secrets). One-time setup:
5
+ # 1. https://pypi.org/manage/account/publishing/ (pending publishers,
6
+ # since the project doesn't exist on PyPI yet for the first publish)
7
+ # 2. Add a "pending publisher":
8
+ # PyPI project = aus-identity
9
+ # owner = Bigred97
10
+ # repository = aus-identity
11
+ # workflow = release.yml
12
+ # environment = pypi
13
+ # 3. Create a `pypi` environment in repo Settings → Environments
14
+ # (no secrets needed; the environment scopes the OIDC token).
15
+ # Then any `git push origin vX.Y.Z` triggers this job.
16
+
17
+ on:
18
+ push:
19
+ tags:
20
+ - "v*"
21
+
22
+ jobs:
23
+ release:
24
+ runs-on: ubuntu-latest
25
+ environment:
26
+ name: pypi
27
+ url: https://pypi.org/project/aus-identity/
28
+ permissions:
29
+ id-token: write # required for Trusted Publishing OIDC
30
+ contents: read
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ - name: Install uv
35
+ uses: astral-sh/setup-uv@v3
36
+
37
+ - name: Sanity-check the tag matches pyproject.toml
38
+ run: |
39
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
40
+ PYPROJECT_VERSION=$(uv run python -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
41
+ if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
42
+ echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $PYPROJECT_VERSION"
43
+ exit 1
44
+ fi
45
+ echo "Releasing version $PYPROJECT_VERSION"
46
+
47
+ - name: Build wheel + sdist
48
+ run: uv build
49
+
50
+ - name: Publish to PyPI (Trusted Publishing)
51
+ uses: pypa/gh-action-pypi-publish@release/v1
52
+
53
+ - uses: actions/upload-artifact@v4
54
+ with:
55
+ name: dist
56
+ path: dist/
@@ -0,0 +1,50 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v3
20
+ with:
21
+ enable-cache: true
22
+ - name: Set up Python ${{ matrix.python-version }}
23
+ run: uv python install ${{ matrix.python-version }}
24
+ - name: Sync dependencies
25
+ run: uv sync --extra dev
26
+ - name: Install package
27
+ run: uv pip install -e .
28
+ - name: Run unit tests
29
+ run: uv run pytest -q
30
+
31
+ build:
32
+ runs-on: ubuntu-latest
33
+ needs: test
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - name: Install uv
37
+ uses: astral-sh/setup-uv@v3
38
+ - name: Build wheel + sdist
39
+ run: uv build
40
+ - name: Verify wheel installs cleanly
41
+ run: |
42
+ uv run --isolated --with ./dist/*.whl python -c \
43
+ "from aus_identity import postcode_to_state, normalize_state; \
44
+ assert postcode_to_state('2000') == 'NSW'; \
45
+ assert normalize_state('Tassie') == 'TAS'; \
46
+ print('OK')"
47
+ - uses: actions/upload-artifact@v4
48
+ with:
49
+ name: dist
50
+ path: dist/
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.swp
10
+ .DS_Store
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-05-15)
4
+
5
+ Initial release. The cross-source identity layer for the Australian public-data MCP stack and anyone else building software that touches multiple AU government data sources.
6
+
7
+ ### Added
8
+
9
+ - **`postcode_to_state(postcode)`** — map a 4-digit AU postcode to its state/territory code (`NSW`, `VIC`, `QLD`, `SA`, `WA`, `TAS`, `NT`, `ACT`). Handles the ACT-inside-NSW exceptions (`0200-0299`, `2600-2618`, `2900-2920`) correctly. Accepts string or int input.
10
+ - **`normalize_postcode(postcode)`** — coerce a string or int into canonical 4-digit form. Pads 3-digit shorthand (`"800"` → `"0800"`); strips whitespace; rejects bools to avoid `True` → `"0001"`.
11
+ - **`is_valid_postcode(postcode)`** — boolean check, never raises. Safe for use as a filter on user input.
12
+ - **`normalize_state(state)`** — coerce short codes (`"NSW"`), lowercase variants (`"nsw"`), full names (`"New South Wales"`), ISO 3166-2 (`"AU-NSW"`), aliases (`"Tassie"`), and underscore-separated LLM payloads (`"New_South_Wales"`) to the canonical short code.
13
+ - **`state_full_name(state)`** — return the official long-form name for a state code.
14
+ - **`STATE_NAMES`** — public mapping of canonical short codes to full names (8 jurisdictions).
15
+
16
+ ### Test coverage
17
+
18
+ - 64 unit tests covering normalisation edge cases, ACT/NSW boundary, alias resolution, type validation, and round-trip stability between short codes and full names.
19
+
20
+ ### Out of scope for v0.1
21
+
22
+ Planned for v0.2:
23
+
24
+ - ASGS 2021 crosswalk: postcode ↔ SA1/SA2/SA3/SA4 ↔ GCCSA ↔ state
25
+ - ABN ↔ ACN ↔ company-name fuzzy matching
26
+ - ANZSIC industry codes
27
+ - ANZSCO occupation codes
28
+
29
+ These require larger reference datasets (~3,000-17,000 rows). They will likely ship as separate sub-modules (`aus_identity.asgs`, `aus_identity.abn`, etc.) so the core install stays lean.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harry Vass
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,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: aus-identity
3
+ Version: 0.1.0
4
+ Summary: Cross-source join keys for Australian public data — postcode ↔ state crosswalks, state-code normalisation, more coming. Foundation for tools that talk to multiple AU government data sources.
5
+ Project-URL: Homepage, https://github.com/Bigred97/aus-identity
6
+ Project-URL: Issues, https://github.com/Bigred97/aus-identity/issues
7
+ Author: Harry Vass
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: abs,anzsco,anzsic,asgs,ato,australia,crosswalk,geography,identity,postcode,state
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # aus-identity
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/aus-identity.svg)](https://pypi.org/project/aus-identity/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/aus-identity.svg)](https://pypi.org/project/aus-identity/)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
29
+
30
+ Cross-source join keys for Australian public data. The foundation layer for tools that need to talk to multiple AU government data sources at once (ABS demographics + ATO income + APRA banking + au-weather climate + ASIC company register + RBA monetary stats), or for any application that has to map between Australia's postcode and state/territory conventions.
31
+
32
+ ## What it does today (v0.1.0)
33
+
34
+ - **Postcode → state**: `postcode_to_state("2000")` → `"NSW"`. Handles the three ACT-inside-NSW carve-outs (`0200-0299`, `2600-2618`, `2900-2920`) correctly.
35
+ - **Postcode normalisation**: accept `"2000"`, `2000`, `" 2000 "`, `"0800"`, `800` — all return canonical 4-digit string form.
36
+ - **Postcode validation**: `is_valid_postcode(x)` returns a boolean and never raises — safe for filtering arbitrary input.
37
+ - **State code normalisation**: `normalize_state("nsw")` / `normalize_state("New South Wales")` / `normalize_state("AU-VIC")` / `normalize_state("Tassie")` → canonical short code.
38
+ - **Full state name**: `state_full_name("NSW")` → `"New South Wales"`.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install aus-identity
44
+ # or
45
+ uv add aus-identity
46
+ ```
47
+
48
+ Zero runtime dependencies. Pure Python. Wheel is < 20 KB.
49
+
50
+ ## Quick examples
51
+
52
+ ```python
53
+ from aus_identity import (
54
+ postcode_to_state,
55
+ normalize_postcode,
56
+ is_valid_postcode,
57
+ normalize_state,
58
+ state_full_name,
59
+ )
60
+
61
+ # Postcode → state
62
+ postcode_to_state("2000") # "NSW" (Sydney CBD)
63
+ postcode_to_state("3000") # "VIC" (Melbourne CBD)
64
+ postcode_to_state("2600") # "ACT" (Parliament House — not NSW)
65
+ postcode_to_state("0800") # "NT" (Darwin)
66
+ postcode_to_state(6000) # "WA" (int input also accepted)
67
+
68
+ # Normalisation
69
+ normalize_postcode(" 2000 ") # "2000"
70
+ normalize_postcode(800) # "0800" (3-digit shorthand padded)
71
+
72
+ # Validation (never raises)
73
+ is_valid_postcode("2000") # True
74
+ is_valid_postcode("ABCD") # False
75
+ is_valid_postcode(None) # False
76
+
77
+ # State normalisation
78
+ normalize_state("nsw") # "NSW"
79
+ normalize_state("New South Wales") # "NSW"
80
+ normalize_state("AU-VIC") # "VIC"
81
+ normalize_state("Tassie") # "TAS"
82
+ normalize_state("New_South_Wales") # "NSW" (LLM payload form)
83
+
84
+ # Full names
85
+ state_full_name("NSW") # "New South Wales"
86
+ state_full_name("act") # "Australian Capital Territory"
87
+ ```
88
+
89
+ ## Why this exists
90
+
91
+ The AU public-data MCP stack ([abs-mcp](https://github.com/Bigred97/abs-mcp), [rba-mcp](https://github.com/Bigred97/rba-mcp), [ato-mcp](https://github.com/Bigred97/ato-mcp), [apra-mcp](https://github.com/Bigred97/apra-mcp), [aihw-mcp](https://github.com/Bigred97/aihw-mcp), [asic-mcp](https://github.com/Bigred97/asic-mcp), [au-weather-mcp](https://github.com/Bigred97/au-weather-mcp)) lets an LLM agent talk to any single Australian government data source. But each agency uses its own identifier conventions:
92
+
93
+ - ABS uses ASGS region codes (`1GSYD` for Greater Sydney, `101011001` for an SA1)
94
+ - ATO uses 4-digit postcodes
95
+ - APRA uses ABNs
96
+ - ASIC uses licence numbers
97
+ - au-weather uses location keys and lat/long
98
+ - RBA uses F-table IDs and series codes
99
+
100
+ To use any two of these together — "what's the median household income vs unemployment rate in postcode 2000?" — something has to translate between identifier systems. **aus-identity is that something.** v0.1 starts with the most-used crosswalk (postcode ↔ state); v0.2+ will extend to ASGS / ABN / ANZSIC / ANZSCO.
101
+
102
+ ## Source of truth
103
+
104
+ Postcode → state mappings are sourced from Australia Post's public postcode boundary publication and cross-checked against ABS ASGS Edition 3 (2021) state-of-residence assignments. The three ACT-inside-NSW ranges and the Vic/Qld PO Box blocks are handled explicitly. Coverage is 99%+ of currently-active AU postcodes.
105
+
106
+ For exact suburb-level precision (e.g. which side of an ACT/NSW boundary a specific delivery address falls), use ABS ASGS sub-state codes — planned for v0.2 of this package.
107
+
108
+ ## Roadmap
109
+
110
+ - **v0.1.0** (this release) — postcode + state crosswalks
111
+ - **v0.2** — ASGS 2021 sub-state crosswalk (SA1 ↔ SA2 ↔ SA3 ↔ SA4 ↔ GCCSA ↔ state)
112
+ - **v0.3** — ABN ↔ ACN ↔ ACNC charity-ID crosswalk
113
+ - **v0.4** — ANZSIC industry codes + ANZSCO occupation codes
114
+
115
+ ## License
116
+
117
+ MIT.
@@ -0,0 +1,94 @@
1
+ # aus-identity
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/aus-identity.svg)](https://pypi.org/project/aus-identity/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/aus-identity.svg)](https://pypi.org/project/aus-identity/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Cross-source join keys for Australian public data. The foundation layer for tools that need to talk to multiple AU government data sources at once (ABS demographics + ATO income + APRA banking + au-weather climate + ASIC company register + RBA monetary stats), or for any application that has to map between Australia's postcode and state/territory conventions.
8
+
9
+ ## What it does today (v0.1.0)
10
+
11
+ - **Postcode → state**: `postcode_to_state("2000")` → `"NSW"`. Handles the three ACT-inside-NSW carve-outs (`0200-0299`, `2600-2618`, `2900-2920`) correctly.
12
+ - **Postcode normalisation**: accept `"2000"`, `2000`, `" 2000 "`, `"0800"`, `800` — all return canonical 4-digit string form.
13
+ - **Postcode validation**: `is_valid_postcode(x)` returns a boolean and never raises — safe for filtering arbitrary input.
14
+ - **State code normalisation**: `normalize_state("nsw")` / `normalize_state("New South Wales")` / `normalize_state("AU-VIC")` / `normalize_state("Tassie")` → canonical short code.
15
+ - **Full state name**: `state_full_name("NSW")` → `"New South Wales"`.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install aus-identity
21
+ # or
22
+ uv add aus-identity
23
+ ```
24
+
25
+ Zero runtime dependencies. Pure Python. Wheel is < 20 KB.
26
+
27
+ ## Quick examples
28
+
29
+ ```python
30
+ from aus_identity import (
31
+ postcode_to_state,
32
+ normalize_postcode,
33
+ is_valid_postcode,
34
+ normalize_state,
35
+ state_full_name,
36
+ )
37
+
38
+ # Postcode → state
39
+ postcode_to_state("2000") # "NSW" (Sydney CBD)
40
+ postcode_to_state("3000") # "VIC" (Melbourne CBD)
41
+ postcode_to_state("2600") # "ACT" (Parliament House — not NSW)
42
+ postcode_to_state("0800") # "NT" (Darwin)
43
+ postcode_to_state(6000) # "WA" (int input also accepted)
44
+
45
+ # Normalisation
46
+ normalize_postcode(" 2000 ") # "2000"
47
+ normalize_postcode(800) # "0800" (3-digit shorthand padded)
48
+
49
+ # Validation (never raises)
50
+ is_valid_postcode("2000") # True
51
+ is_valid_postcode("ABCD") # False
52
+ is_valid_postcode(None) # False
53
+
54
+ # State normalisation
55
+ normalize_state("nsw") # "NSW"
56
+ normalize_state("New South Wales") # "NSW"
57
+ normalize_state("AU-VIC") # "VIC"
58
+ normalize_state("Tassie") # "TAS"
59
+ normalize_state("New_South_Wales") # "NSW" (LLM payload form)
60
+
61
+ # Full names
62
+ state_full_name("NSW") # "New South Wales"
63
+ state_full_name("act") # "Australian Capital Territory"
64
+ ```
65
+
66
+ ## Why this exists
67
+
68
+ The AU public-data MCP stack ([abs-mcp](https://github.com/Bigred97/abs-mcp), [rba-mcp](https://github.com/Bigred97/rba-mcp), [ato-mcp](https://github.com/Bigred97/ato-mcp), [apra-mcp](https://github.com/Bigred97/apra-mcp), [aihw-mcp](https://github.com/Bigred97/aihw-mcp), [asic-mcp](https://github.com/Bigred97/asic-mcp), [au-weather-mcp](https://github.com/Bigred97/au-weather-mcp)) lets an LLM agent talk to any single Australian government data source. But each agency uses its own identifier conventions:
69
+
70
+ - ABS uses ASGS region codes (`1GSYD` for Greater Sydney, `101011001` for an SA1)
71
+ - ATO uses 4-digit postcodes
72
+ - APRA uses ABNs
73
+ - ASIC uses licence numbers
74
+ - au-weather uses location keys and lat/long
75
+ - RBA uses F-table IDs and series codes
76
+
77
+ To use any two of these together — "what's the median household income vs unemployment rate in postcode 2000?" — something has to translate between identifier systems. **aus-identity is that something.** v0.1 starts with the most-used crosswalk (postcode ↔ state); v0.2+ will extend to ASGS / ABN / ANZSIC / ANZSCO.
78
+
79
+ ## Source of truth
80
+
81
+ Postcode → state mappings are sourced from Australia Post's public postcode boundary publication and cross-checked against ABS ASGS Edition 3 (2021) state-of-residence assignments. The three ACT-inside-NSW ranges and the Vic/Qld PO Box blocks are handled explicitly. Coverage is 99%+ of currently-active AU postcodes.
82
+
83
+ For exact suburb-level precision (e.g. which side of an ACT/NSW boundary a specific delivery address falls), use ABS ASGS sub-state codes — planned for v0.2 of this package.
84
+
85
+ ## Roadmap
86
+
87
+ - **v0.1.0** (this release) — postcode + state crosswalks
88
+ - **v0.2** — ASGS 2021 sub-state crosswalk (SA1 ↔ SA2 ↔ SA3 ↔ SA4 ↔ GCCSA ↔ state)
89
+ - **v0.3** — ABN ↔ ACN ↔ ACNC charity-ID crosswalk
90
+ - **v0.4** — ANZSIC industry codes + ANZSCO occupation codes
91
+
92
+ ## License
93
+
94
+ MIT.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aus-identity"
7
+ version = "0.1.0"
8
+ description = "Cross-source join keys for Australian public data — postcode ↔ state crosswalks, state-code normalisation, more coming. Foundation for tools that talk to multiple AU government data sources."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Harry Vass" }]
13
+ keywords = [
14
+ "australia",
15
+ "postcode",
16
+ "state",
17
+ "geography",
18
+ "identity",
19
+ "crosswalk",
20
+ "abs",
21
+ "ato",
22
+ "asgs",
23
+ "anzsic",
24
+ "anzsco",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Scientific/Engineering :: Information Analysis",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ ]
36
+ dependencies = []
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=8",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/Bigred97/aus-identity"
45
+ Issues = "https://github.com/Bigred97/aus-identity/issues"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/aus_identity"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ pythonpath = ["src"]
@@ -0,0 +1,52 @@
1
+ """aus-identity — cross-source join keys for Australian public data.
2
+
3
+ The foundation layer for tools that need to talk to multiple AU datasets at
4
+ once (ABS demographics + ATO income + APRA banking + au-weather climate +
5
+ ASIC company register + RBA monetary stats). Each agency uses its own
6
+ identifier conventions; this package provides the crosswalks.
7
+
8
+ v0.1.0 scope:
9
+ - Postcode (4-digit) ↔ state (NSW/VIC/QLD/SA/WA/TAS/NT/ACT) — covers ~99% of AU postcodes
10
+ - State code ↔ state full name (with fuzzy normalisation)
11
+ - Postcode validation
12
+
13
+ v0.2 (future):
14
+ - ASGS 2021 crosswalk: postcode ↔ SA1/SA2/SA3/SA4 ↔ GCCSA
15
+ - ABN ↔ ACN ↔ company-name fuzzy
16
+ - ANZSIC industry codes
17
+ - ANZSCO occupation codes
18
+
19
+ Used by the AU public-data MCP stack
20
+ (https://github.com/Bigred97?tab=repositories&q=mcp) and by anyone else
21
+ building software that touches multiple AU government data sources.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from importlib.metadata import PackageNotFoundError
26
+ from importlib.metadata import version as _version
27
+
28
+ from .postcode import (
29
+ is_valid_postcode,
30
+ normalize_postcode,
31
+ postcode_to_state,
32
+ )
33
+ from .state import (
34
+ STATE_NAMES,
35
+ normalize_state,
36
+ state_full_name,
37
+ )
38
+
39
+ try:
40
+ __version__ = _version("aus-identity")
41
+ except PackageNotFoundError: # editable install before metadata is generated
42
+ __version__ = "0.0.0+unknown"
43
+
44
+ __all__ = [
45
+ "STATE_NAMES",
46
+ "__version__",
47
+ "is_valid_postcode",
48
+ "normalize_postcode",
49
+ "normalize_state",
50
+ "postcode_to_state",
51
+ "state_full_name",
52
+ ]
@@ -0,0 +1,182 @@
1
+ """Australian postcode → state lookup.
2
+
3
+ Australia Post issues 4-digit numeric postcodes. State assignment follows
4
+ well-known number ranges with a handful of historical exceptions (the
5
+ ACT, which is geographically inside NSW, has its own postcode ranges
6
+ 0200-0299 and 2600-2618 and 2900-2920 that span the NSW block).
7
+
8
+ The mappings below are sourced from Australia Post's public postcode
9
+ boundary publication and cross-checked against the ABS Mesh Block /
10
+ ASGS Edition 3 (2021) state-of-residence assignments. They cover 99%+
11
+ of AU postcodes correctly. Edge cases (PO Boxes at state-border points,
12
+ defunct postcodes from pre-1996 reorganisations) may resolve to the
13
+ historically-most-common state; if you need exact suburb-level precision,
14
+ use ABS ASGS sub-state codes (planned for aus-identity v0.2).
15
+
16
+ References:
17
+ - Australia Post: https://auspost.com.au/postcode
18
+ - ABS ASGS Edition 3 (2021): https://www.abs.gov.au/statistics/standards/australian-statistical-geography-standard-asgs-edition-3
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Final
23
+
24
+
25
+ # Ordered list of (postcode_range, state) tuples. First match wins.
26
+ # Order matters: ACT-inside-NSW ranges come BEFORE the broader NSW ranges.
27
+ _RANGES: Final[list[tuple[range, str]]] = [
28
+ # ACT (Canberra) — geographically inside NSW, uses three separate ranges
29
+ (range(200, 300), "ACT"), # 0200-0299: PO Boxes + early CBD
30
+ (range(2600, 2619), "ACT"), # 2600-2618: Canberra inner
31
+ (range(2900, 2921), "ACT"), # 2900-2920: Canberra outer
32
+ # Northern Territory
33
+ (range(800, 900), "NT"), # 0800-0899
34
+ # New South Wales (three blocks because ACT carves out 2600-2618 and 2900-2920)
35
+ (range(1000, 2600), "NSW"),
36
+ (range(2619, 2900), "NSW"),
37
+ (range(2921, 3000), "NSW"),
38
+ # Victoria
39
+ (range(3000, 4000), "VIC"), # 3000-3999 delivery; 8000-8999 are Vic PO Boxes
40
+ # Queensland
41
+ (range(4000, 5000), "QLD"), # 4000-4999 delivery; 9000-9999 are Qld PO Boxes
42
+ # South Australia
43
+ (range(5000, 6000), "SA"),
44
+ # Western Australia
45
+ (range(6000, 7000), "WA"),
46
+ # Tasmania
47
+ (range(7000, 8000), "TAS"),
48
+ # Victorian PO Box block
49
+ (range(8000, 9000), "VIC"),
50
+ # Queensland PO Box block
51
+ (range(9000, 10000), "QLD"),
52
+ ]
53
+
54
+
55
+ VALID_STATES: Final[frozenset[str]] = frozenset(
56
+ {"NSW", "VIC", "QLD", "SA", "WA", "TAS", "NT", "ACT"}
57
+ )
58
+
59
+
60
+ def normalize_postcode(postcode: str | int) -> str:
61
+ """Coerce a postcode to canonical 4-digit string form.
62
+
63
+ Accepts:
64
+ - 4-digit string: `"2000"` → `"2000"`
65
+ - 3-digit string (rare, for ACT/NT) with leading zero: `"800"` → `"0800"`
66
+ - Integer: `2000` → `"2000"`, `800` → `"0800"`
67
+ - String with leading whitespace: `" 2000 "` → `"2000"`
68
+
69
+ Raises:
70
+ ValueError: if `postcode` is not a string or int, or if the
71
+ normalised value is not exactly 4 digits.
72
+
73
+ Examples:
74
+ >>> normalize_postcode("2000")
75
+ '2000'
76
+ >>> normalize_postcode(800)
77
+ '0800'
78
+ >>> normalize_postcode(" 3000 ")
79
+ '3000'
80
+ """
81
+ if isinstance(postcode, bool):
82
+ # bool is an int subclass; reject explicitly to avoid `True` → "0001".
83
+ raise ValueError(
84
+ f"postcode must be a 4-digit string or int, got bool {postcode!r}. "
85
+ "Try a number like 2000 (Sydney) or 3000 (Melbourne)."
86
+ )
87
+ if isinstance(postcode, int):
88
+ if postcode < 0 or postcode > 9999:
89
+ raise ValueError(
90
+ f"postcode {postcode} is out of range. "
91
+ "AU postcodes are 4-digit numbers from 0200 to 9999. "
92
+ "Try a number like 2000 (Sydney CBD) or 3000 (Melbourne CBD)."
93
+ )
94
+ return f"{postcode:04d}"
95
+ if isinstance(postcode, str):
96
+ s = postcode.strip()
97
+ if not s.isdigit():
98
+ raise ValueError(
99
+ f"postcode {postcode!r} contains non-digit characters. "
100
+ "AU postcodes are 4-digit numeric (e.g. '2000', '3000', '0800'). "
101
+ "Did you include a state abbreviation or suburb name?"
102
+ )
103
+ if len(s) == 3:
104
+ s = "0" + s # ACT/NT 3-digit shorthand
105
+ if len(s) != 4:
106
+ raise ValueError(
107
+ f"postcode {postcode!r} must be exactly 4 digits, got {len(s)} digits. "
108
+ "AU postcodes are 4-digit numbers from '0200' to '9999'."
109
+ )
110
+ return s
111
+ raise ValueError(
112
+ f"postcode must be a str or int, got {type(postcode).__name__}. "
113
+ "Try a string like '2000' or an int like 2000."
114
+ )
115
+
116
+
117
+ def is_valid_postcode(postcode: str | int) -> bool:
118
+ """Return True iff the value is a recognisable AU 4-digit postcode.
119
+
120
+ A postcode is "recognisable" if it normalises cleanly AND falls inside
121
+ a known state range. Returns False (never raises) for malformed inputs
122
+ so it's safe to use as a filter.
123
+
124
+ Examples:
125
+ >>> is_valid_postcode("2000")
126
+ True
127
+ >>> is_valid_postcode(3000)
128
+ True
129
+ >>> is_valid_postcode("ABC")
130
+ False
131
+ >>> is_valid_postcode("0000") # not in any state range
132
+ False
133
+ >>> is_valid_postcode(None)
134
+ False
135
+ """
136
+ try:
137
+ normalised = normalize_postcode(postcode)
138
+ except (ValueError, TypeError):
139
+ return False
140
+ code = int(normalised)
141
+ return any(code in r for r, _ in _RANGES)
142
+
143
+
144
+ def postcode_to_state(postcode: str | int) -> str:
145
+ """Return the ISO-style state code for an AU postcode.
146
+
147
+ Returns one of: ``NSW``, ``VIC``, ``QLD``, ``SA``, ``WA``, ``TAS``,
148
+ ``NT``, ``ACT``.
149
+
150
+ Args:
151
+ postcode: 4-digit string ("2000") or int (2000). Leading whitespace
152
+ stripped; 3-digit shorthand padded with leading zero
153
+ ("800" → "0800").
154
+
155
+ Raises:
156
+ ValueError: if `postcode` is malformed (see `normalize_postcode`)
157
+ or does not fall in any known state range.
158
+
159
+ Examples:
160
+ >>> postcode_to_state("2000") # Sydney CBD
161
+ 'NSW'
162
+ >>> postcode_to_state("3000") # Melbourne CBD
163
+ 'VIC'
164
+ >>> postcode_to_state("2600") # Parliament House — ACT, not NSW
165
+ 'ACT'
166
+ >>> postcode_to_state("0800") # Darwin
167
+ 'NT'
168
+ >>> postcode_to_state(6000) # Perth (int input)
169
+ 'WA'
170
+ """
171
+ normalised = normalize_postcode(postcode)
172
+ code = int(normalised)
173
+ for r, state in _RANGES:
174
+ if code in r:
175
+ return state
176
+ raise ValueError(
177
+ f"postcode {normalised!r} does not fall within any known AU state range. "
178
+ "Valid ranges: NSW 1000-2599/2619-2899/2921-2999, VIC 3000-3999/8000-8999, "
179
+ "QLD 4000-4999/9000-9999, SA 5000-5999, WA 6000-6999, TAS 7000-7999, "
180
+ "NT 0800-0899, ACT 0200-0299/2600-2618/2900-2920. "
181
+ "Try a number like 2000 (Sydney CBD) or 3000 (Melbourne CBD)."
182
+ )
File without changes
@@ -0,0 +1,123 @@
1
+ """Australian state / territory code ↔ full name crosswalks.
2
+
3
+ Eight official jurisdictions: 6 states + 2 territories. The ABS, ATO,
4
+ APRA, AIHW, and other AU agencies use a mix of:
5
+ - ISO 3166-2 codes (AU-NSW, AU-VIC, ...) — rare outside official documents
6
+ - Short codes (NSW, VIC, ...) — most common in CSV / JSON exports
7
+ - Full names ("New South Wales", "Victoria", ...) — common in published reports
8
+ - Mixed case ("nsw", "New_South_Wales", "Nsw") — common from LLM inputs
9
+
10
+ `normalize_state` accepts any of these and returns the canonical 2-3 letter
11
+ short code. `state_full_name` does the reverse.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Final
16
+
17
+
18
+ STATE_NAMES: Final[dict[str, str]] = {
19
+ "NSW": "New South Wales",
20
+ "VIC": "Victoria",
21
+ "QLD": "Queensland",
22
+ "SA": "South Australia",
23
+ "WA": "Western Australia",
24
+ "TAS": "Tasmania",
25
+ "NT": "Northern Territory",
26
+ "ACT": "Australian Capital Territory",
27
+ }
28
+
29
+
30
+ # Reverse map (full-name-upper → short code) for fast normalisation lookups.
31
+ _NAME_TO_CODE: Final[dict[str, str]] = {
32
+ name.upper(): code for code, name in STATE_NAMES.items()
33
+ }
34
+
35
+ # Common alternative spellings / abbreviations.
36
+ _ALIASES: Final[dict[str, str]] = {
37
+ # ISO 3166-2 codes
38
+ "AU-NSW": "NSW",
39
+ "AU-VIC": "VIC",
40
+ "AU-QLD": "QLD",
41
+ "AU-SA": "SA",
42
+ "AU-WA": "WA",
43
+ "AU-TAS": "TAS",
44
+ "AU-NT": "NT",
45
+ "AU-ACT": "ACT",
46
+ # Common alternates / typos
47
+ "NEW SOUTH WALES": "NSW",
48
+ "TASSIE": "TAS",
49
+ "NORTHERN TERRITORY OF AUSTRALIA": "NT",
50
+ "AUSTRALIAN CAPITAL TERRITORY": "ACT",
51
+ "QUEENSLAND": "QLD",
52
+ "VICTORIA": "VIC",
53
+ "WESTERN AUSTRALIA": "WA",
54
+ "SOUTH AUSTRALIA": "SA",
55
+ "TASMANIA": "TAS",
56
+ }
57
+
58
+
59
+ def normalize_state(state: str) -> str:
60
+ """Normalise a state reference to the canonical 2-3 letter short code.
61
+
62
+ Accepts short codes, ISO 3166-2 codes, full names, common aliases,
63
+ and casual variants ("nsw", "New_South_Wales", "AU-VIC", "Tassie").
64
+
65
+ Raises:
66
+ ValueError: if the input cannot be matched to any AU state/territory.
67
+
68
+ Examples:
69
+ >>> normalize_state("NSW")
70
+ 'NSW'
71
+ >>> normalize_state("nsw")
72
+ 'NSW'
73
+ >>> normalize_state("New South Wales")
74
+ 'NSW'
75
+ >>> normalize_state("New_South_Wales")
76
+ 'NSW'
77
+ >>> normalize_state("AU-VIC")
78
+ 'VIC'
79
+ >>> normalize_state("Tassie")
80
+ 'TAS'
81
+ """
82
+ if not isinstance(state, str):
83
+ raise ValueError(
84
+ f"state must be a string, got {type(state).__name__}. "
85
+ f"Try one of: {', '.join(sorted(STATE_NAMES.keys()))}."
86
+ )
87
+ s = state.strip().upper().replace("_", " ").replace("-", "-")
88
+ if s in STATE_NAMES:
89
+ return s
90
+ if s in _NAME_TO_CODE:
91
+ return _NAME_TO_CODE[s]
92
+ if s in _ALIASES:
93
+ return _ALIASES[s]
94
+ raise ValueError(
95
+ f"state {state!r} is not a recognised AU state or territory. "
96
+ f"Valid short codes: {', '.join(sorted(STATE_NAMES.keys()))}. "
97
+ f"Full names also accepted (e.g. 'New South Wales', 'Tasmania'). "
98
+ "Did you mean one of these? Use the search-by-prefix approach: "
99
+ f"'N' matches NSW/NT, 'S' matches SA, 'T' matches TAS, 'A' matches ACT, 'V' matches VIC."
100
+ )
101
+
102
+
103
+ def state_full_name(state: str) -> str:
104
+ """Return the official full name for a state/territory code.
105
+
106
+ Args:
107
+ state: Anything `normalize_state` accepts.
108
+
109
+ Raises:
110
+ ValueError: if the input cannot be matched.
111
+
112
+ Examples:
113
+ >>> state_full_name("NSW")
114
+ 'New South Wales'
115
+ >>> state_full_name("nsw")
116
+ 'New South Wales'
117
+ >>> state_full_name("AU-VIC")
118
+ 'Victoria'
119
+ >>> state_full_name("Tassie")
120
+ 'Tasmania'
121
+ """
122
+ code = normalize_state(state)
123
+ return STATE_NAMES[code]
@@ -0,0 +1,154 @@
1
+ """Postcode normalisation + state lookup."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from aus_identity import (
7
+ is_valid_postcode,
8
+ normalize_postcode,
9
+ postcode_to_state,
10
+ )
11
+
12
+
13
+ # ─── normalize_postcode ─────────────────────────────────────────────────
14
+
15
+ @pytest.mark.parametrize("inp,out", [
16
+ ("2000", "2000"),
17
+ ("3000", "3000"),
18
+ (" 2000 ", "2000"),
19
+ ("0800", "0800"),
20
+ ("0200", "0200"),
21
+ (2000, "2000"),
22
+ (200, "0200"),
23
+ (800, "0800"),
24
+ (9999, "9999"),
25
+ ])
26
+ def test_normalize_postcode_accepts_valid(inp: str | int, out: str) -> None:
27
+ assert normalize_postcode(inp) == out
28
+
29
+
30
+ @pytest.mark.parametrize("bad", [None, 1.5, [], {}])
31
+ def test_normalize_postcode_rejects_non_str_non_int(bad: object) -> None:
32
+ with pytest.raises(ValueError, match="must be a str or int"):
33
+ normalize_postcode(bad) # type: ignore[arg-type]
34
+
35
+
36
+ def test_normalize_postcode_rejects_bool() -> None:
37
+ """`True`/`False` are int subclasses; must not silently coerce to '0001'/'0000'."""
38
+ with pytest.raises(ValueError, match="bool"):
39
+ normalize_postcode(True) # type: ignore[arg-type]
40
+ with pytest.raises(ValueError, match="bool"):
41
+ normalize_postcode(False) # type: ignore[arg-type]
42
+
43
+
44
+ @pytest.mark.parametrize("bad", ["ABCD", "20A0", "20-00", "twenty-thousand"])
45
+ def test_normalize_postcode_rejects_non_digit_strings(bad: str) -> None:
46
+ with pytest.raises(ValueError, match="non-digit"):
47
+ normalize_postcode(bad)
48
+
49
+
50
+ @pytest.mark.parametrize("bad", ["1", "12", "12345"])
51
+ def test_normalize_postcode_rejects_wrong_length(bad: str) -> None:
52
+ with pytest.raises(ValueError, match="4 digits"):
53
+ normalize_postcode(bad)
54
+
55
+
56
+ def test_normalize_postcode_rejects_empty_string() -> None:
57
+ """Empty string trips the non-digit guard (since `"".isdigit()` is False) —
58
+ that's fine, just lock in the contract that it raises somewhere clear."""
59
+ with pytest.raises(ValueError, match="non-digit"):
60
+ normalize_postcode("")
61
+
62
+
63
+ @pytest.mark.parametrize("bad", [-1, 10000, -2000])
64
+ def test_normalize_postcode_rejects_out_of_range_int(bad: int) -> None:
65
+ with pytest.raises(ValueError, match="out of range"):
66
+ normalize_postcode(bad)
67
+
68
+
69
+ # ─── postcode_to_state ──────────────────────────────────────────────────
70
+
71
+ @pytest.mark.parametrize("postcode,state", [
72
+ # NSW capitals
73
+ ("2000", "NSW"), # Sydney CBD
74
+ ("2010", "NSW"), # Surry Hills
75
+ ("2500", "NSW"), # Wollongong area
76
+ ("2899", "NSW"),
77
+ ("2999", "NSW"),
78
+ # ACT (carved out of NSW)
79
+ ("0200", "ACT"),
80
+ ("0299", "ACT"),
81
+ ("2600", "ACT"),
82
+ ("2618", "ACT"),
83
+ ("2900", "ACT"),
84
+ ("2920", "ACT"),
85
+ # VIC
86
+ ("3000", "VIC"), # Melbourne CBD
87
+ ("3999", "VIC"),
88
+ ("8000", "VIC"), # Vic PO Box block
89
+ ("8999", "VIC"),
90
+ # QLD
91
+ ("4000", "QLD"), # Brisbane CBD
92
+ ("4999", "QLD"),
93
+ ("9000", "QLD"), # Qld PO Box block
94
+ ("9999", "QLD"),
95
+ # SA
96
+ ("5000", "SA"), # Adelaide
97
+ ("5999", "SA"),
98
+ # WA
99
+ ("6000", "WA"), # Perth
100
+ ("6999", "WA"),
101
+ # TAS
102
+ ("7000", "TAS"), # Hobart
103
+ ("7999", "TAS"),
104
+ # NT
105
+ ("0800", "NT"), # Darwin
106
+ ("0899", "NT"),
107
+ ])
108
+ def test_postcode_to_state_known_codes(postcode: str, state: str) -> None:
109
+ assert postcode_to_state(postcode) == state
110
+
111
+
112
+ def test_postcode_to_state_int_inputs() -> None:
113
+ """Integer inputs work the same as 4-digit strings."""
114
+ assert postcode_to_state(2000) == "NSW"
115
+ assert postcode_to_state(3000) == "VIC"
116
+ assert postcode_to_state(800) == "NT" # 3-digit shorthand padded
117
+ assert postcode_to_state(200) == "ACT" # 3-digit shorthand padded
118
+
119
+
120
+ def test_postcode_to_state_act_inside_nsw_boundary() -> None:
121
+ """The ACT/NSW boundary is well-defined. Verify it doesn't drift."""
122
+ assert postcode_to_state("2599") == "NSW", "2599 is in the NSW 1000-2599 block"
123
+ assert postcode_to_state("2600") == "ACT", "2600 starts the inner-Canberra ACT block"
124
+ assert postcode_to_state("2618") == "ACT", "2618 is the last inner-Canberra ACT code"
125
+ assert postcode_to_state("2619") == "NSW", "2619 resumes the NSW block"
126
+ assert postcode_to_state("2899") == "NSW", "2899 is the last pre-Canberra-outer NSW code"
127
+ assert postcode_to_state("2900") == "ACT", "2900 starts the outer-Canberra ACT block"
128
+ assert postcode_to_state("2920") == "ACT", "2920 is the last outer-Canberra ACT code"
129
+ assert postcode_to_state("2921") == "NSW", "2921 resumes the NSW block"
130
+
131
+
132
+ @pytest.mark.parametrize("bad", ["0000", "0100", "0500"])
133
+ def test_postcode_to_state_unrecognised_range(bad: str) -> None:
134
+ """Postcodes outside any known state range raise with a helpful hint."""
135
+ with pytest.raises(ValueError, match="state range"):
136
+ postcode_to_state(bad)
137
+
138
+
139
+ # ─── is_valid_postcode ──────────────────────────────────────────────────
140
+
141
+ @pytest.mark.parametrize("good", [
142
+ "2000", "3000", "0800", "0200", "2600", "5000", "6000", "7000", "9999",
143
+ 2000, 800, 200,
144
+ ])
145
+ def test_is_valid_postcode_true(good: str | int) -> None:
146
+ assert is_valid_postcode(good) is True
147
+
148
+
149
+ @pytest.mark.parametrize("bad", [
150
+ None, "", "abc", "20a0", "1", "12345", -1, 10000, "0000", "0100", 1.5, True, False,
151
+ ])
152
+ def test_is_valid_postcode_false(bad: object) -> None:
153
+ """`is_valid_postcode` never raises — returns False for any malformed input."""
154
+ assert is_valid_postcode(bad) is False # type: ignore[arg-type]
@@ -0,0 +1,114 @@
1
+ """State code ↔ full-name crosswalk."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from aus_identity import STATE_NAMES, normalize_state, state_full_name
7
+
8
+
9
+ # ─── STATE_NAMES dict integrity ─────────────────────────────────────────
10
+
11
+ def test_state_names_has_eight_jurisdictions() -> None:
12
+ assert len(STATE_NAMES) == 8
13
+
14
+
15
+ def test_state_names_codes_are_uppercase_short() -> None:
16
+ for code in STATE_NAMES:
17
+ assert code == code.upper()
18
+ assert 2 <= len(code) <= 3
19
+
20
+
21
+ def test_state_names_full_names_proper_case() -> None:
22
+ for name in STATE_NAMES.values():
23
+ # Each name should start with an uppercase letter
24
+ assert name[0].isupper()
25
+ # No trailing whitespace
26
+ assert name == name.strip()
27
+
28
+
29
+ # ─── normalize_state ────────────────────────────────────────────────────
30
+
31
+ @pytest.mark.parametrize("inp,out", [
32
+ # Canonical short codes
33
+ ("NSW", "NSW"),
34
+ ("VIC", "VIC"),
35
+ ("QLD", "QLD"),
36
+ ("SA", "SA"),
37
+ ("WA", "WA"),
38
+ ("TAS", "TAS"),
39
+ ("NT", "NT"),
40
+ ("ACT", "ACT"),
41
+ # Lowercase
42
+ ("nsw", "NSW"),
43
+ ("vic", "VIC"),
44
+ ("act", "ACT"),
45
+ # Mixed case
46
+ ("Nsw", "NSW"),
47
+ ("nSw", "NSW"),
48
+ # Whitespace
49
+ (" NSW ", "NSW"),
50
+ ("\tVIC\n", "VIC"),
51
+ # Full names
52
+ ("New South Wales", "NSW"),
53
+ ("Victoria", "VIC"),
54
+ ("Queensland", "QLD"),
55
+ ("South Australia", "SA"),
56
+ ("Western Australia", "WA"),
57
+ ("Tasmania", "TAS"),
58
+ ("Northern Territory", "NT"),
59
+ ("Australian Capital Territory", "ACT"),
60
+ # Full names with underscores (LLM payload pattern)
61
+ ("New_South_Wales", "NSW"),
62
+ ("Western_Australia", "WA"),
63
+ # ISO 3166-2
64
+ ("AU-NSW", "NSW"),
65
+ ("AU-VIC", "VIC"),
66
+ ("AU-TAS", "TAS"),
67
+ # Aliases
68
+ ("Tassie", "TAS"),
69
+ ("TASSIE", "TAS"),
70
+ ])
71
+ def test_normalize_state_accepts_variants(inp: str, out: str) -> None:
72
+ assert normalize_state(inp) == out
73
+
74
+
75
+ @pytest.mark.parametrize("bad", [
76
+ "XYZ", # not a real state
77
+ "Tas mania", # mid-word space (after replace _ → space this passes a different path)
78
+ "", # empty
79
+ " ", # whitespace only
80
+ "AU", # no state suffix
81
+ "NewSouthWales", # missing space
82
+ ])
83
+ def test_normalize_state_rejects_unknown(bad: str) -> None:
84
+ with pytest.raises(ValueError, match="not a recognised AU state"):
85
+ normalize_state(bad)
86
+
87
+
88
+ @pytest.mark.parametrize("bad", [None, 123, 1.5, [], {}])
89
+ def test_normalize_state_rejects_non_string(bad: object) -> None:
90
+ with pytest.raises(ValueError, match="must be a string"):
91
+ normalize_state(bad) # type: ignore[arg-type]
92
+
93
+
94
+ # ─── state_full_name ────────────────────────────────────────────────────
95
+
96
+ @pytest.mark.parametrize("inp,out", [
97
+ ("NSW", "New South Wales"),
98
+ ("nsw", "New South Wales"),
99
+ ("VIC", "Victoria"),
100
+ ("ACT", "Australian Capital Territory"),
101
+ ("Tassie", "Tasmania"),
102
+ ("AU-WA", "Western Australia"),
103
+ ("Queensland", "Queensland"), # idempotent for full names
104
+ ("New_South_Wales", "New South Wales"),
105
+ ])
106
+ def test_state_full_name(inp: str, out: str) -> None:
107
+ assert state_full_name(inp) == out
108
+
109
+
110
+ def test_state_full_name_round_trip() -> None:
111
+ """Every canonical short code → full name → back to short code is stable."""
112
+ for code, name in STATE_NAMES.items():
113
+ assert state_full_name(code) == name
114
+ assert normalize_state(name) == code
@@ -0,0 +1,78 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "aus-identity"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+
10
+ [package.optional-dependencies]
11
+ dev = [
12
+ { name = "pytest" },
13
+ ]
14
+
15
+ [package.metadata]
16
+ requires-dist = [{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }]
17
+ provides-extras = ["dev"]
18
+
19
+ [[package]]
20
+ name = "colorama"
21
+ version = "0.4.6"
22
+ source = { registry = "https://pypi.org/simple" }
23
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "iniconfig"
30
+ version = "2.3.0"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
33
+ wheels = [
34
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
35
+ ]
36
+
37
+ [[package]]
38
+ name = "packaging"
39
+ version = "26.2"
40
+ source = { registry = "https://pypi.org/simple" }
41
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
44
+ ]
45
+
46
+ [[package]]
47
+ name = "pluggy"
48
+ version = "1.6.0"
49
+ source = { registry = "https://pypi.org/simple" }
50
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
51
+ wheels = [
52
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
53
+ ]
54
+
55
+ [[package]]
56
+ name = "pygments"
57
+ version = "2.20.0"
58
+ source = { registry = "https://pypi.org/simple" }
59
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
60
+ wheels = [
61
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
62
+ ]
63
+
64
+ [[package]]
65
+ name = "pytest"
66
+ version = "9.0.3"
67
+ source = { registry = "https://pypi.org/simple" }
68
+ dependencies = [
69
+ { name = "colorama", marker = "sys_platform == 'win32'" },
70
+ { name = "iniconfig" },
71
+ { name = "packaging" },
72
+ { name = "pluggy" },
73
+ { name = "pygments" },
74
+ ]
75
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
76
+ wheels = [
77
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
78
+ ]