ng-postcode-mcp 0.2.4__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ng-postcode-mcp
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up, reverse-geocode and resolve addresses to postcodes.
5
5
  Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
6
6
  Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
@@ -104,11 +104,25 @@ Most clients take this entry in their MCP settings:
104
104
  | `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
105
105
  | `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
106
106
  | `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
107
+ | `NG_POSTCODE_TRANSPORT` | `stdio` | `http` serves streamable HTTP at `/mcp` instead. |
108
+ | `NG_POSTCODE_HOST`, `NG_POSTCODE_PORT` | `127.0.0.1`, `8000` | Where the HTTP transport listens. |
107
109
 
108
110
  The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only; a service whose main job is geocoding must run its own instance. Map data © OpenStreetMap contributors.
109
111
 
112
+ ## HTTP and Docker
113
+
114
+ ```sh
115
+ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mcp
116
+ docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
117
+ ```
118
+
119
+ Over HTTP a caller can send its own NIPOST key in the `X-NIPOST-API-Key` header, and that key is used for that caller's requests only. A caller that sends none uses the server's key, if `NG_POSTCODE_API_KEY` is set.
120
+
121
+ To host the server for other people, leave `NG_POSTCODE_API_KEY` unset so every caller brings a key, and serve it over HTTPS so the header is encrypted. Callers are trusting the host with their key, and all of them share the host's geocoder, which answers one search a second. Validation still works without any key. If you do set a server key, anyone who can reach the server spends its credits, so keep it on loopback or behind your own authentication. `NG_POSTCODE_MAX_LEVEL` caps every caller either way.
122
+
110
123
  ## Safety
111
124
 
125
+ - The key is read from the environment or the `X-NIPOST-API-Key` header, never from tool arguments, and never appears in results or logs.
112
126
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
113
127
  - A mistyped code is never corrected and sent to the API silently. The server returns the suggestion and asks the model to confirm it with the user.
114
128
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
@@ -76,11 +76,25 @@ Most clients take this entry in their MCP settings:
76
76
  | `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
77
77
  | `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
78
78
  | `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
79
+ | `NG_POSTCODE_TRANSPORT` | `stdio` | `http` serves streamable HTTP at `/mcp` instead. |
80
+ | `NG_POSTCODE_HOST`, `NG_POSTCODE_PORT` | `127.0.0.1`, `8000` | Where the HTTP transport listens. |
79
81
 
80
82
  The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only; a service whose main job is geocoding must run its own instance. Map data © OpenStreetMap contributors.
81
83
 
84
+ ## HTTP and Docker
85
+
86
+ ```sh
87
+ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mcp
88
+ docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
89
+ ```
90
+
91
+ Over HTTP a caller can send its own NIPOST key in the `X-NIPOST-API-Key` header, and that key is used for that caller's requests only. A caller that sends none uses the server's key, if `NG_POSTCODE_API_KEY` is set.
92
+
93
+ To host the server for other people, leave `NG_POSTCODE_API_KEY` unset so every caller brings a key, and serve it over HTTPS so the header is encrypted. Callers are trusting the host with their key, and all of them share the host's geocoder, which answers one search a second. Validation still works without any key. If you do set a server key, anyone who can reach the server spends its credits, so keep it on loopback or behind your own authentication. `NG_POSTCODE_MAX_LEVEL` caps every caller either way.
94
+
82
95
  ## Safety
83
96
 
97
+ - The key is read from the environment or the `X-NIPOST-API-Key` header, never from tool arguments, and never appears in results or logs.
84
98
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
85
99
  - A mistyped code is never corrected and sent to the API silently. The server returns the suggestion and asks the model to confirm it with the user.
86
100
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ng-postcode-mcp"
7
- version = "0.2.4"
7
+ version = "0.4.0"
8
8
  description = "MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up, reverse-geocode and resolve addresses to postcodes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,7 +36,7 @@ Repository = "https://github.com/Adeniyikayodee/ng-postcode"
36
36
  Issues = "https://github.com/Adeniyikayodee/ng-postcode/issues"
37
37
 
38
38
  [dependency-groups]
39
- dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
39
+ dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8", "types-jsonschema>=4.20"]
40
40
 
41
41
  # Inside the repository, build against the local packages so they never drift.
42
42
  [tool.uv.sources]
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.Adeniyikayodee/ng-postcode",
4
4
  "title": "Nigeria Postcode",
5
5
  "description": "Validate, look up and resolve addresses to Nigeria's NIPOST digital postcodes (NDAPS).",
6
- "version": "0.2.4",
6
+ "version": "0.4.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/Adeniyikayodee/ng-postcode",
9
9
  "source": "github",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "identifier": "ng-postcode-mcp",
16
- "version": "0.2.4",
16
+ "version": "0.4.0",
17
17
  "runtimeHint": "uvx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -44,6 +44,15 @@
44
44
  {
45
45
  "name": "NG_GEOCODER_CONTACT",
46
46
  "description": "URL or email identifying you to the geocoder. Required for the public Nominatim."
47
+ },
48
+ {
49
+ "name": "NG_POSTCODE_TRANSPORT",
50
+ "description": "stdio (default), or http to serve streamable HTTP at /mcp on NG_POSTCODE_HOST and NG_POSTCODE_PORT.",
51
+ "default": "stdio",
52
+ "choices": [
53
+ "stdio",
54
+ "http"
55
+ ]
47
56
  }
48
57
  ]
49
58
  }
@@ -3,7 +3,9 @@
3
3
  Validation runs offline. Lookup, autocomplete and reverse geocoding call the
4
4
  postcode.gov.ng API with the key in NG_POSTCODE_API_KEY, which never appears in
5
5
  tool arguments or results. Address resolution also searches the geocoder in
6
- NG_GEOCODER_URL. stdout carries the protocol, so nothing else may print to it.
6
+ NG_GEOCODER_URL. Over stdio, stdout carries the protocol, so nothing else may
7
+ print to it. Over HTTP, a caller may send its own key in the X-NIPOST-API-Key
8
+ header; without one it uses the server's key, if the server has one.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -33,12 +35,14 @@ from ng_postcode.api import (
33
35
  lookup,
34
36
  reverse,
35
37
  )
36
- from ng_postcode.client import AsyncClient, TransportError
38
+ from ng_postcode.client import TIMEOUT, AsyncClient, TransportError
37
39
  from pydantic import BaseModel, ConfigDict, Field
38
40
 
39
41
  T = TypeVar("T")
40
42
 
41
43
  KEY_URL = "https://dashboard.postcode.gov.ng"
44
+ KEY_HEADER = "x-nipost-api-key"
45
+ MAX_KEY_LENGTH = 256
42
46
 
43
47
  INSTRUCTIONS = """\
44
48
  Tools for Nigeria's 11-character building postcode, e.g. EK-01-A03-FK-01 \
@@ -59,8 +63,8 @@ READ_ONLY_OFFLINE = ToolAnnotations(
59
63
  READ_ONLY_ONLINE = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True)
60
64
 
61
65
  HINTS = {
62
- "auth_required": f"Set NG_POSTCODE_API_KEY to a key from {KEY_URL}.",
63
- "invalid_api_key": f"NG_POSTCODE_API_KEY is invalid or revoked; create a new key at {KEY_URL}.",
66
+ "auth_required": f"Supply a NIPOST API key from {KEY_URL}.",
67
+ "invalid_api_key": f"The NIPOST API key is invalid or revoked; create a new key at {KEY_URL}.",
64
68
  "insufficient_credits": "The NIPOST account is out of credits; top up or use level 1.",
65
69
  "level_not_granted": "The key is not granted this lookup level; use a lower level or "
66
70
  f"request more access at {KEY_URL}.",
@@ -79,6 +83,9 @@ class Settings:
79
83
  base_url: str = BASE_URL
80
84
  geocoder_url: str | None = None
81
85
  geocoder_contact: str | None = None
86
+ transport: Literal["stdio", "http"] = "stdio"
87
+ host: str = "127.0.0.1"
88
+ port: int = 8000
82
89
 
83
90
 
84
91
  def settings_from_env(env: Mapping[str, str]) -> Settings | str:
@@ -90,12 +97,21 @@ def settings_from_env(env: Mapping[str, str]) -> Settings | str:
90
97
  geocoder_contact = env.get("NG_GEOCODER_CONTACT", "").strip() or None
91
98
  if geocoder_url == PUBLIC_NOMINATIM and geocoder_contact is None:
92
99
  return "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
100
+ transport = env.get("NG_POSTCODE_TRANSPORT", "stdio").strip().lower()
101
+ if transport not in ("stdio", "http"):
102
+ return f"NG_POSTCODE_TRANSPORT must be stdio or http, got {transport!r}"
103
+ raw_port = env.get("NG_POSTCODE_PORT", "8000").strip()
104
+ if not (raw_port.isdecimal() and 0 < int(raw_port) < 65536):
105
+ return f"NG_POSTCODE_PORT must be 1 to 65535, got {raw_port!r}"
93
106
  return Settings(
94
107
  api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
95
108
  max_level=int(raw_level),
96
109
  base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
97
110
  geocoder_url=geocoder_url,
98
111
  geocoder_contact=geocoder_contact,
112
+ transport="http" if transport == "http" else "stdio",
113
+ host=env.get("NG_POSTCODE_HOST", "").strip() or "127.0.0.1",
114
+ port=int(raw_port),
99
115
  )
100
116
 
101
117
 
@@ -188,8 +204,11 @@ class Location(FromLibrary):
188
204
 
189
205
  @dataclass(frozen=True, slots=True)
190
206
  class State:
191
- """What the tools share for the life of the server. Either part may be unconfigured."""
207
+ """What the tools share for the life of the server. `nipost` and `geocoder` may be
208
+ unconfigured."""
192
209
 
210
+ http: httpx.AsyncClient
211
+ base_url: str
193
212
  nipost: AsyncClient | None
194
213
  geocoder: Nominatim | None
195
214
 
@@ -204,17 +223,18 @@ def create_server(
204
223
 
205
224
  @asynccontextmanager
206
225
  async def lifespan(_: MCPServer[State]) -> AsyncIterator[State]:
226
+ shared = http if http is not None else httpx.AsyncClient(timeout=TIMEOUT)
207
227
  nipost = (
208
- AsyncClient(settings.api_key, base_url=settings.base_url, http=http)
228
+ AsyncClient(settings.api_key, base_url=settings.base_url, http=shared)
209
229
  if settings.api_key
210
230
  else None
211
231
  )
212
232
  geocoder = geocoder_for(settings, geocoder_http)
213
233
  try:
214
- yield State(nipost, geocoder)
234
+ yield State(shared, settings.base_url, nipost, geocoder)
215
235
  finally:
216
- if nipost:
217
- await nipost.aclose()
236
+ if http is None:
237
+ await shared.aclose()
218
238
  if geocoder:
219
239
  await geocoder.aclose()
220
240
 
@@ -353,8 +373,8 @@ def create_server(
353
373
  if latitude is not None and longitude is not None
354
374
  else None
355
375
  )
356
- state = ctx.request_context.lifespan_context
357
- resolver = Resolver(nipost=state.nipost, geocoder=state.geocoder)
376
+ geocoder = ctx.request_context.lifespan_context.geocoder
377
+ resolver = Resolver(nipost=nipost_for(ctx), geocoder=geocoder)
358
378
  return await resolver.resolve(address, location, reading(landmarks, geocode_queries))
359
379
 
360
380
  return server
@@ -448,10 +468,25 @@ def checked(text: str) -> Postcode:
448
468
  raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
449
469
 
450
470
 
471
+ def nipost_for(ctx: Context[State, Any]) -> AsyncClient | None:
472
+ """The caller's own client when an HTTP request carries a key, else the server's."""
473
+ state = ctx.request_context.lifespan_context
474
+ sent = (v for k, v in (ctx.headers or {}).items() if k.lower() == KEY_HEADER)
475
+ key = next(sent, "").strip()
476
+ if not key:
477
+ return state.nipost
478
+ if not (key.isascii() and key.isprintable() and " " not in key and len(key) <= MAX_KEY_LENGTH):
479
+ raise ToolError("The X-NIPOST-API-Key header does not hold a usable NIPOST API key.")
480
+ return AsyncClient(key, base_url=state.base_url, http=state.http)
481
+
482
+
451
483
  def api(ctx: Context[State, Any]) -> AsyncClient:
452
- client = ctx.request_context.lifespan_context.nipost
484
+ client = nipost_for(ctx)
453
485
  if client is None:
454
- raise ToolError(f"This tool needs NG_POSTCODE_API_KEY. {HINTS['auth_required']}")
486
+ raise ToolError(
487
+ "This tool needs a NIPOST API key: set NG_POSTCODE_API_KEY on the server, or over "
488
+ f"HTTP send your own in the X-NIPOST-API-Key header. Get one at {KEY_URL}."
489
+ )
455
490
  return client
456
491
 
457
492
 
@@ -470,4 +505,8 @@ def main() -> None:
470
505
  sys.exit(f"ng-postcode-mcp: {settings}")
471
506
  # httpx logs every request URL at INFO, which would copy postcodes into client logs.
472
507
  logging.getLogger("httpx").setLevel(logging.WARNING)
473
- create_server(settings).run()
508
+ server = create_server(settings)
509
+ if settings.transport == "http":
510
+ server.run("streamable-http", host=settings.host, port=settings.port)
511
+ else:
512
+ server.run()
@@ -0,0 +1,94 @@
1
+ """A caller's own NIPOST key over HTTP, against an in-process server with NIPOST mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import threading
7
+ import time
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+
11
+ import httpx
12
+ import httpx2
13
+ import pytest
14
+ import uvicorn
15
+ from mcp import Client
16
+ from mcp.client.streamable_http import streamable_http_client
17
+ from mcp.types import CallToolResult
18
+
19
+ from ng_postcode_mcp import Settings, create_server
20
+
21
+ LOOKUP = {"postcode": "EK-01-A03-FK-01"}
22
+
23
+
24
+ @pytest.fixture
25
+ def anyio_backend() -> str:
26
+ return "asyncio"
27
+
28
+
29
+ @contextmanager
30
+ def serving(server_key: str | None, keys_seen: list[str]) -> Iterator[str]:
31
+ """Run the server over HTTP on a loopback port; yields its URL."""
32
+
33
+ def nipost(request: httpx.Request) -> httpx.Response:
34
+ keys_seen.append(request.headers["X-API-Key"])
35
+ return httpx.Response(200, json={"data": {"postcode": "EK-01-A03-FK-01", "valid": True}})
36
+
37
+ upstream = httpx.AsyncClient(transport=httpx.MockTransport(nipost))
38
+ app = create_server(Settings(api_key=server_key), http=upstream).streamable_http_app()
39
+ with socket.socket() as probe:
40
+ probe.bind(("127.0.0.1", 0))
41
+ port = int(probe.getsockname()[1])
42
+ server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
43
+ thread = threading.Thread(target=server.run, daemon=True)
44
+ thread.start()
45
+ deadline = time.monotonic() + 20
46
+ while not server.started and time.monotonic() < deadline:
47
+ time.sleep(0.05)
48
+ try:
49
+ yield f"http://127.0.0.1:{port}/mcp"
50
+ finally:
51
+ server.should_exit = True
52
+ thread.join(timeout=10)
53
+
54
+
55
+ async def lookup(url: str, caller_key: str | bytes | None) -> CallToolResult:
56
+ sent = caller_key.encode() if isinstance(caller_key, str) else caller_key
57
+ headers = {b"X-NIPOST-API-Key": sent} if sent else {}
58
+ async with (
59
+ httpx2.AsyncClient(headers=headers) as http,
60
+ Client(streamable_http_client(url, http_client=http)) as client,
61
+ ):
62
+ return await client.call_tool("lookup_postcode", LOOKUP)
63
+
64
+
65
+ @pytest.mark.anyio
66
+ async def test_a_callers_key_is_used_for_its_own_requests() -> None:
67
+ seen: list[str] = []
68
+ with serving(None, seen) as url:
69
+ own = await lookup(url, "caller-key")
70
+ none = await lookup(url, None)
71
+ assert not own.is_error
72
+ assert seen == ["caller-key"]
73
+ assert none.is_error
74
+ assert "X-NIPOST-API-Key" in str(none.content)
75
+
76
+
77
+ @pytest.mark.anyio
78
+ async def test_the_servers_key_is_the_fallback() -> None:
79
+ seen: list[str] = []
80
+ with serving("server-key", seen) as url:
81
+ await lookup(url, None)
82
+ await lookup(url, "caller-key")
83
+ assert seen == ["server-key", "caller-key"]
84
+
85
+
86
+ @pytest.mark.anyio
87
+ @pytest.mark.parametrize("key", ["k\xe9y".encode("latin-1"), b"two words", b"k" * 300])
88
+ async def test_an_unusable_key_is_refused_before_nipost(key: bytes) -> None:
89
+ seen: list[str] = []
90
+ with serving("server-key", seen) as url:
91
+ result = await lookup(url, key)
92
+ assert result.is_error
93
+ assert "does not hold a usable" in str(result.content)
94
+ assert seen == []
@@ -0,0 +1,49 @@
1
+ """The real entry point over HTTP, on a loopback port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import time
10
+
11
+ import pytest
12
+ from mcp import Client
13
+
14
+
15
+ @pytest.fixture
16
+ def anyio_backend() -> str:
17
+ return "asyncio"
18
+
19
+
20
+ def free_port() -> int:
21
+ with socket.socket() as probe:
22
+ probe.bind(("127.0.0.1", 0))
23
+ return int(probe.getsockname()[1])
24
+
25
+
26
+ def wait_until_listening(port: int, seconds: float = 20.0) -> None:
27
+ deadline = time.monotonic() + seconds
28
+ while time.monotonic() < deadline:
29
+ with socket.socket() as probe:
30
+ if probe.connect_ex(("127.0.0.1", port)) == 0:
31
+ return
32
+ time.sleep(0.1)
33
+ raise TimeoutError(f"nothing listening on port {port}")
34
+
35
+
36
+ @pytest.mark.anyio
37
+ async def test_serves_over_http() -> None:
38
+ port = free_port()
39
+ env = {k: v for k, v in os.environ.items() if not k.startswith("NG_POSTCODE_")}
40
+ env |= {"NG_POSTCODE_TRANSPORT": "http", "NG_POSTCODE_PORT": str(port)}
41
+ server = subprocess.Popen([sys.executable, "-m", "ng_postcode_mcp"], env=env)
42
+ try:
43
+ wait_until_listening(port)
44
+ async with Client(f"http://127.0.0.1:{port}/mcp") as client:
45
+ result = await client.call_tool("validate_postcode", {"postcode": "LA11W06TC10"})
46
+ finally:
47
+ server.terminate()
48
+ server.wait(timeout=10)
49
+ assert result.structured_content["postcode"] == "LA-11-W06-TC-10"
@@ -0,0 +1,74 @@
1
+ """The shared postcode reference schema, and that resolve_address answers fit it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+ from jsonschema import Draft202012Validator
11
+ from ng_address.models import Resolution
12
+ from ng_postcode import Postcode, Segment
13
+
14
+ SCHEMA_FILE = Path(__file__).resolve().parents[2] / "docs/schemas/postcode-reference.schema.json"
15
+
16
+ pytestmark = pytest.mark.skipif(
17
+ not SCHEMA_FILE.exists(), reason="the schema lives in the repository, not the sdist"
18
+ )
19
+
20
+
21
+ def loaded() -> dict[str, Any]:
22
+ document: dict[str, Any] = json.loads(SCHEMA_FILE.read_text(encoding="utf-8"))
23
+ return document
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def schema() -> Draft202012Validator:
28
+ Draft202012Validator.check_schema(loaded())
29
+ return Draft202012Validator(loaded())
30
+
31
+
32
+ def test_its_own_examples_are_valid(schema: Draft202012Validator) -> None:
33
+ for example in loaded()["examples"]:
34
+ schema.validate(example)
35
+
36
+
37
+ @pytest.mark.parametrize(
38
+ "reference",
39
+ [
40
+ {"code": "EK-01-A03-FK-01"},
41
+ {"code": "EK-01-A03-FK-01", "level": "area"},
42
+ {"code": "EK-01-A03-FK", "level": "building"},
43
+ {"code": "EK01A03FK01", "level": "building"},
44
+ {"code": "ek-01-a03-fk-01", "level": "building"},
45
+ {"code": "EK-00", "level": "lga"},
46
+ {"code": "EK-01", "level": "street"},
47
+ {"code": "EK-01", "level": "lga", "confidence": "certain"},
48
+ ],
49
+ )
50
+ def test_rejects_malformed_references(
51
+ schema: Draft202012Validator, reference: dict[str, str]
52
+ ) -> None:
53
+ assert not schema.is_valid(reference)
54
+
55
+
56
+ def test_every_prefix_of_a_code_fits_its_level(schema: Draft202012Validator) -> None:
57
+ code = Postcode("EK01A03FK01")
58
+ levels = {"state": Segment.STATE, "lga": Segment.LGA, "district": Segment.DISTRICT}
59
+ levels |= {"area": Segment.AREA, "building": Segment.UNIT}
60
+ for level, segment in levels.items():
61
+ schema.validate({"code": code.prefix(segment), "level": level})
62
+
63
+
64
+ def test_a_resolution_is_a_reference(schema: Draft202012Validator) -> None:
65
+ answer = Resolution(
66
+ status="partial",
67
+ code="EK-01-A03-FK",
68
+ level="area",
69
+ confidence="medium",
70
+ method="geocoded",
71
+ question="Which building is it?",
72
+ evidence=[],
73
+ )
74
+ schema.validate(answer.model_dump())
@@ -161,7 +161,7 @@ async def test_lookup_never_autocorrects_before_spending() -> None:
161
161
  async def test_online_tools_explain_a_missing_or_rejected_key() -> None:
162
162
  missing = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=None)
163
163
  assert missing.is_error
164
- assert "needs NG_POSTCODE_API_KEY" in text(missing)
164
+ assert "needs a NIPOST API key" in text(missing)
165
165
 
166
166
  secret = "nipost_test_never_echo_me"
167
167
  rejected = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=secret)
@@ -224,6 +224,14 @@ def test_output_models_only_use_fields_the_library_has(
224
224
  assert set(model.model_fields) <= {field.name for field in dataclasses.fields(source)}
225
225
 
226
226
 
227
+ def test_transport_settings() -> None:
228
+ served = settings_from_env({"NG_POSTCODE_TRANSPORT": "HTTP", "NG_POSTCODE_PORT": "9000"})
229
+ assert isinstance(served, Settings)
230
+ assert (served.transport, served.host, served.port) == ("http", "127.0.0.1", 9000)
231
+ assert isinstance(settings_from_env({"NG_POSTCODE_TRANSPORT": "sse"}), str)
232
+ assert isinstance(settings_from_env({"NG_POSTCODE_PORT": "0"}), str)
233
+
234
+
227
235
  def test_settings_from_env() -> None:
228
236
  assert settings_from_env({}) == Settings(api_key=None)
229
237
  assert settings_from_env({"NG_POSTCODE_API_KEY": " k ", "NG_POSTCODE_MAX_LEVEL": "3"}) == (
File without changes