uk-gp-practices 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.
- uk_gp_practices-0.1.0/.claude/settings.local.json +7 -0
- uk_gp_practices-0.1.0/.gitignore +24 -0
- uk_gp_practices-0.1.0/PKG-INFO +74 -0
- uk_gp_practices-0.1.0/README.md +60 -0
- uk_gp_practices-0.1.0/pyproject.toml +29 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/__init__.py +13 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/cli.py +60 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/db.py +90 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/download.py +81 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/index.py +226 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/models.py +24 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/normalise.py +36 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/paths.py +32 -0
- uk_gp_practices-0.1.0/src/uk_gp_practices/py.typed +0 -0
- uk_gp_practices-0.1.0/tests/fixtures/epraccur_sample.csv +5 -0
- uk_gp_practices-0.1.0/tests/test_cli.py +52 -0
- uk_gp_practices-0.1.0/tests/test_download.py +83 -0
- uk_gp_practices-0.1.0/tests/test_epraccur_ingest.py +30 -0
- uk_gp_practices-0.1.0/tests/test_normalise.py +44 -0
- uk_gp_practices-0.1.0/tests/test_paths.py +18 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.coverage
|
|
8
|
+
htmlcov/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Tools
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# Local data/cache
|
|
20
|
+
.cache/
|
|
21
|
+
data/
|
|
22
|
+
*.db
|
|
23
|
+
*.sqlite
|
|
24
|
+
*.sqlite3
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uk-gp-practices
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Query UK GP practices (surgeries) via NHS ODS Data Search & Export CSV reports.
|
|
5
|
+
Author: Joshua Evans
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: platformdirs>=4.0
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: fuzzy
|
|
12
|
+
Requires-Dist: rapidfuzz>=3.0; extra == 'fuzzy'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# uk-gp-practices
|
|
16
|
+
|
|
17
|
+
Query UK GP practices ("surgeries") locally using NHS ODS Data Search & Export (DSE) CSV reports.
|
|
18
|
+
|
|
19
|
+
This package downloads a predefined ODS report (default: `epraccur`), stores it in a local SQLite database, and provides a simple Python API + CLI to query it quickly.
|
|
20
|
+
|
|
21
|
+
> **Note:** On first use the package will automatically download the latest report from the NHS ODS endpoint. Subsequent queries use the local cache (refreshed daily by default).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install uk-gp-practices
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For fuzzy name matching (optional):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install uk-gp-practices[fuzzy]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Python API
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from uk_gp_practices import PracticeIndex
|
|
39
|
+
|
|
40
|
+
# Auto-download the latest data (cached for 24 h)
|
|
41
|
+
idx = PracticeIndex.auto_update()
|
|
42
|
+
|
|
43
|
+
# Look up a single practice by ODS code
|
|
44
|
+
practice = idx.get("A81001")
|
|
45
|
+
print(practice.name, practice.postcode)
|
|
46
|
+
|
|
47
|
+
# Search by name / postcode / town / status
|
|
48
|
+
results = idx.search(name="castle", status="ACTIVE", limit=10)
|
|
49
|
+
for r in results:
|
|
50
|
+
print(r.organisation_code, r.name, r.postcode)
|
|
51
|
+
|
|
52
|
+
# Context-manager usage
|
|
53
|
+
with PracticeIndex.auto_update() as idx:
|
|
54
|
+
print(idx.get("A81001"))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Update the local database
|
|
61
|
+
uk-gp update
|
|
62
|
+
|
|
63
|
+
# Get a single practice (JSON output)
|
|
64
|
+
uk-gp get A81001
|
|
65
|
+
|
|
66
|
+
# Search practices
|
|
67
|
+
uk-gp search --name "castle" --status ACTIVE --limit 5
|
|
68
|
+
uk-gp search --postcode "SW1A 1AA"
|
|
69
|
+
uk-gp search --town "Swansea"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# uk-gp-practices
|
|
2
|
+
|
|
3
|
+
Query UK GP practices ("surgeries") locally using NHS ODS Data Search & Export (DSE) CSV reports.
|
|
4
|
+
|
|
5
|
+
This package downloads a predefined ODS report (default: `epraccur`), stores it in a local SQLite database, and provides a simple Python API + CLI to query it quickly.
|
|
6
|
+
|
|
7
|
+
> **Note:** On first use the package will automatically download the latest report from the NHS ODS endpoint. Subsequent queries use the local cache (refreshed daily by default).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install uk-gp-practices
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For fuzzy name matching (optional):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install uk-gp-practices[fuzzy]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Python API
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from uk_gp_practices import PracticeIndex
|
|
25
|
+
|
|
26
|
+
# Auto-download the latest data (cached for 24 h)
|
|
27
|
+
idx = PracticeIndex.auto_update()
|
|
28
|
+
|
|
29
|
+
# Look up a single practice by ODS code
|
|
30
|
+
practice = idx.get("A81001")
|
|
31
|
+
print(practice.name, practice.postcode)
|
|
32
|
+
|
|
33
|
+
# Search by name / postcode / town / status
|
|
34
|
+
results = idx.search(name="castle", status="ACTIVE", limit=10)
|
|
35
|
+
for r in results:
|
|
36
|
+
print(r.organisation_code, r.name, r.postcode)
|
|
37
|
+
|
|
38
|
+
# Context-manager usage
|
|
39
|
+
with PracticeIndex.auto_update() as idx:
|
|
40
|
+
print(idx.get("A81001"))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Update the local database
|
|
47
|
+
uk-gp update
|
|
48
|
+
|
|
49
|
+
# Get a single practice (JSON output)
|
|
50
|
+
uk-gp get A81001
|
|
51
|
+
|
|
52
|
+
# Search practices
|
|
53
|
+
uk-gp search --name "castle" --status ACTIVE --limit 5
|
|
54
|
+
uk-gp search --postcode "SW1A 1AA"
|
|
55
|
+
uk-gp search --town "Swansea"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uk-gp-practices"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Query UK GP practices (surgeries) via NHS ODS Data Search & Export CSV reports."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Joshua Evans" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
"platformdirs>=4.0",
|
|
16
|
+
"typer>=0.12",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
fuzzy = ["rapidfuzz>=3.0"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
uk-gp = "uk_gp_practices.cli:app"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/uk_gp_practices"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""UK GP Practices ODS data handling."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
from .index import PracticeIndex
|
|
6
|
+
from .models import Practice
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("uk-gp-practices")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["Practice", "PracticeIndex", "__version__"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typer CLI for querying UK GP practices (surgeries) via NHS ODS DSE CSV reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .index import PracticeIndex
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Query UK GP practices (surgeries) via NHS ODS DSE CSV reports.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def update(report: str = "epraccur") -> None:
|
|
16
|
+
"""
|
|
17
|
+
Download the latest report and update the local database.
|
|
18
|
+
"""
|
|
19
|
+
idx = PracticeIndex.auto_update(report=report)
|
|
20
|
+
typer.echo(f"DB ready at: {idx.db_file}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def get(code: str, report: str = "epraccur") -> None:
|
|
25
|
+
"""
|
|
26
|
+
Get a single practice by organisation code (ODS code).
|
|
27
|
+
"""
|
|
28
|
+
idx = PracticeIndex.auto_update(report=report)
|
|
29
|
+
p = idx.get(code)
|
|
30
|
+
if not p:
|
|
31
|
+
typer.echo(f"Practice not found: {code}", err=True)
|
|
32
|
+
raise typer.Exit(code=1)
|
|
33
|
+
typer.echo(json.dumps(p.raw or {}, indent=2))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def search(
|
|
38
|
+
name: str = typer.Option("", help="Search by practice name (substring match)."),
|
|
39
|
+
postcode: str = typer.Option(
|
|
40
|
+
"", help="Search by postcode (exact after normalization)."
|
|
41
|
+
),
|
|
42
|
+
town: str = typer.Option("", help="Search by town (substring match)."),
|
|
43
|
+
status: str = typer.Option(
|
|
44
|
+
"", help="Filter by status (e.g. ACTIVE). Leave blank for any."
|
|
45
|
+
),
|
|
46
|
+
limit: int = typer.Option(10, help="Max results."),
|
|
47
|
+
report: str = typer.Option("epraccur", help="ODS report code."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Search practices by name/postcode/town.
|
|
51
|
+
"""
|
|
52
|
+
idx = PracticeIndex.auto_update(report=report)
|
|
53
|
+
res = idx.search(
|
|
54
|
+
name=name or None,
|
|
55
|
+
postcode=postcode or None,
|
|
56
|
+
town=town or None,
|
|
57
|
+
status=status or None,
|
|
58
|
+
limit=limit,
|
|
59
|
+
)
|
|
60
|
+
typer.echo(json.dumps([r.raw for r in res], indent=2))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Database access and manipulation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import sqlite3
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PRACTICES_TABLE_SQL = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS practices (
|
|
13
|
+
organisation_code TEXT PRIMARY KEY,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
name_norm TEXT,
|
|
16
|
+
postcode TEXT,
|
|
17
|
+
postcode_norm TEXT,
|
|
18
|
+
town TEXT,
|
|
19
|
+
status TEXT
|
|
20
|
+
);
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
INDEXES_SQL = [
|
|
24
|
+
"CREATE INDEX IF NOT EXISTS idx_practices_postcode_norm ON practices(postcode_norm);",
|
|
25
|
+
"CREATE INDEX IF NOT EXISTS idx_practices_name_norm ON practices(name_norm);",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
30
|
+
con = sqlite3.connect(str(db_path))
|
|
31
|
+
con.execute("PRAGMA journal_mode=WAL;")
|
|
32
|
+
con.execute("PRAGMA synchronous=NORMAL;")
|
|
33
|
+
return con
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def init_db(con: sqlite3.Connection) -> None:
|
|
37
|
+
con.execute(PRACTICES_TABLE_SQL)
|
|
38
|
+
for stmt in INDEXES_SQL:
|
|
39
|
+
con.execute(stmt)
|
|
40
|
+
con.commit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def upsert_practices(
|
|
44
|
+
con: sqlite3.Connection, rows: Iterable[dict[str, str | None]]
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Upsert practice rows into sqlite.
|
|
48
|
+
|
|
49
|
+
Expected keys in each row:
|
|
50
|
+
organisation_code, name, name_norm, postcode, postcode_norm, town, status
|
|
51
|
+
"""
|
|
52
|
+
con.executemany(
|
|
53
|
+
"""
|
|
54
|
+
INSERT INTO practices (
|
|
55
|
+
organisation_code, name, name_norm, postcode, postcode_norm, town, status
|
|
56
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
ON CONFLICT(organisation_code) DO UPDATE SET
|
|
58
|
+
name=excluded.name,
|
|
59
|
+
name_norm=excluded.name_norm,
|
|
60
|
+
postcode=excluded.postcode,
|
|
61
|
+
postcode_norm=excluded.postcode_norm,
|
|
62
|
+
town=excluded.town,
|
|
63
|
+
status=excluded.status
|
|
64
|
+
""",
|
|
65
|
+
[
|
|
66
|
+
(
|
|
67
|
+
r.get("organisation_code"),
|
|
68
|
+
r.get("name"),
|
|
69
|
+
r.get("name_norm"),
|
|
70
|
+
r.get("postcode"),
|
|
71
|
+
r.get("postcode_norm"),
|
|
72
|
+
r.get("town"),
|
|
73
|
+
r.get("status"),
|
|
74
|
+
)
|
|
75
|
+
for r in rows
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
con.commit()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_csv_dicts(path: Path) -> list[dict[str, str]]:
|
|
82
|
+
"""Read a normal headered CSV into dict rows."""
|
|
83
|
+
with path.open("r", newline="", encoding="utf-8-sig") as f:
|
|
84
|
+
return list(csv.DictReader(f))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_csv_rows(path: Path) -> list[list[str]]:
|
|
88
|
+
"""Read a CSV into raw rows (positional), for headerless exports."""
|
|
89
|
+
with path.open("r", newline="", encoding="utf-8-sig") as f:
|
|
90
|
+
return list(csv.reader(f))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Prefer the www host. Some networks/DNS resolvers fail on the non-www host.
|
|
12
|
+
DEFAULT_DSE_REPORT_URLS = [
|
|
13
|
+
"https://www.odsdatasearchandexport.nhs.uk/api/getReport",
|
|
14
|
+
"https://odsdatasearchandexport.nhs.uk/api/getReport",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class DownloadResult:
|
|
20
|
+
report: str
|
|
21
|
+
path: Path
|
|
22
|
+
bytes_written: int
|
|
23
|
+
url: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def download_report(
|
|
27
|
+
report: str,
|
|
28
|
+
dest: Path,
|
|
29
|
+
timeout: float = 60.0,
|
|
30
|
+
retries: int = 3,
|
|
31
|
+
backoff_seconds: float = 0.6,
|
|
32
|
+
) -> DownloadResult:
|
|
33
|
+
"""
|
|
34
|
+
Download an NHS ODS Data Search & Export predefined report as a CSV.
|
|
35
|
+
|
|
36
|
+
Override endpoint (single URL) with:
|
|
37
|
+
UK_GP_PRACTICES_DSE_URL
|
|
38
|
+
"""
|
|
39
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
override = os.getenv("UK_GP_PRACTICES_DSE_URL")
|
|
42
|
+
candidate_urls = [override] if override else DEFAULT_DSE_REPORT_URLS
|
|
43
|
+
|
|
44
|
+
params = {"report": report}
|
|
45
|
+
last_exc: Exception | None = None
|
|
46
|
+
|
|
47
|
+
for base_url in candidate_urls:
|
|
48
|
+
if not base_url:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
52
|
+
for attempt in range(1, retries + 2): # retries + 1 initial attempt
|
|
53
|
+
try:
|
|
54
|
+
resp = client.get(base_url, params=params)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
data = resp.content
|
|
57
|
+
dest.write_bytes(data)
|
|
58
|
+
return DownloadResult(
|
|
59
|
+
report=report,
|
|
60
|
+
path=dest,
|
|
61
|
+
bytes_written=len(data),
|
|
62
|
+
url=str(resp.url),
|
|
63
|
+
)
|
|
64
|
+
except (
|
|
65
|
+
httpx.ConnectError,
|
|
66
|
+
httpx.ReadTimeout,
|
|
67
|
+
httpx.RemoteProtocolError,
|
|
68
|
+
httpx.NetworkError,
|
|
69
|
+
) as exc:
|
|
70
|
+
last_exc = exc
|
|
71
|
+
time.sleep(backoff_seconds * (2 ** (attempt - 1)))
|
|
72
|
+
except httpx.HTTPStatusError as exc:
|
|
73
|
+
# If we reached the server but got a real HTTP error, don't keep retrying other hosts forever.
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"Failed to download report '{report}' from {base_url}: {exc.response.status_code}"
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Failed to download report '{report}'. Tried: {', '.join([u for u in candidate_urls if u])}. "
|
|
80
|
+
f"Last error: {last_exc!r}"
|
|
81
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Main interface for querying GP practice data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sqlite3
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .db import connect, init_db, read_csv_rows, upsert_practices
|
|
12
|
+
from .download import download_report
|
|
13
|
+
from .models import Practice
|
|
14
|
+
from .normalise import normalize_name, normalize_postcode
|
|
15
|
+
from .paths import csv_path, db_path as default_db_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_REPORT = "epraccur"
|
|
19
|
+
DEFAULT_MAX_AGE = timedelta(days=1)
|
|
20
|
+
|
|
21
|
+
_INTERNAL_COLUMNS = frozenset({"name_norm", "postcode_norm"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _row_to_practice(row: sqlite3.Row) -> Practice:
|
|
25
|
+
raw = {k: row[k] for k in row.keys() if k not in _INTERNAL_COLUMNS}
|
|
26
|
+
return Practice(
|
|
27
|
+
organisation_code=row["organisation_code"],
|
|
28
|
+
name=row["name"],
|
|
29
|
+
postcode=row["postcode"],
|
|
30
|
+
town=row["town"],
|
|
31
|
+
status=row["status"],
|
|
32
|
+
raw=raw,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PracticeIndex:
|
|
38
|
+
"""
|
|
39
|
+
Main query interface for GP practice data.
|
|
40
|
+
|
|
41
|
+
Backed by:
|
|
42
|
+
- downloaded NHS ODS DSE CSV report(s)
|
|
43
|
+
- local SQLite database for fast lookups
|
|
44
|
+
|
|
45
|
+
Can be used as a context manager::
|
|
46
|
+
|
|
47
|
+
with PracticeIndex.auto_update() as idx:
|
|
48
|
+
practice = idx.get("A81001")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
db_file: Path
|
|
52
|
+
_con_cache: sqlite3.Connection | None = field(default=None, repr=False)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def auto_update(
|
|
56
|
+
cls,
|
|
57
|
+
report: str = DEFAULT_REPORT,
|
|
58
|
+
max_age: timedelta = DEFAULT_MAX_AGE,
|
|
59
|
+
) -> "PracticeIndex":
|
|
60
|
+
idx = cls(db_file=default_db_path())
|
|
61
|
+
idx._ensure_schema()
|
|
62
|
+
idx.update_if_needed(report=report, max_age=max_age)
|
|
63
|
+
return idx
|
|
64
|
+
|
|
65
|
+
def _ensure_schema(self) -> None:
|
|
66
|
+
con = self._con()
|
|
67
|
+
init_db(con)
|
|
68
|
+
|
|
69
|
+
def _con(self) -> sqlite3.Connection:
|
|
70
|
+
if self._con_cache is None:
|
|
71
|
+
con = connect(self.db_file)
|
|
72
|
+
con.row_factory = sqlite3.Row
|
|
73
|
+
self._con_cache = con
|
|
74
|
+
return self._con_cache
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
"""Close the underlying database connection."""
|
|
78
|
+
if self._con_cache is not None:
|
|
79
|
+
self._con_cache.close()
|
|
80
|
+
self._con_cache = None
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> "PracticeIndex":
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *exc: object) -> None:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
def update_if_needed(
|
|
89
|
+
self, report: str = DEFAULT_REPORT, max_age: timedelta = DEFAULT_MAX_AGE
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Download + rebuild if the CSV is missing or older than max_age.
|
|
93
|
+
Returns True if an update happened.
|
|
94
|
+
"""
|
|
95
|
+
csvf = csv_path(report)
|
|
96
|
+
if csvf.exists():
|
|
97
|
+
mtime = datetime.fromtimestamp(csvf.stat().st_mtime, tz=timezone.utc)
|
|
98
|
+
if datetime.now(tz=timezone.utc) - mtime < max_age:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
self.update(report=report)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def update(self, report: str = DEFAULT_REPORT) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Download a report CSV and upsert into SQLite.
|
|
107
|
+
"""
|
|
108
|
+
csvf = csv_path(report)
|
|
109
|
+
download_report(report=report, dest=csvf)
|
|
110
|
+
self.load_csv(csvf, report=report)
|
|
111
|
+
|
|
112
|
+
def get(self, organisation_code: str) -> Practice | None:
|
|
113
|
+
"""
|
|
114
|
+
Fetch a single practice by its ODS organisation code.
|
|
115
|
+
"""
|
|
116
|
+
code = organisation_code.strip()
|
|
117
|
+
con = self._con()
|
|
118
|
+
row = con.execute(
|
|
119
|
+
"SELECT * FROM practices WHERE organisation_code = ?",
|
|
120
|
+
(code,),
|
|
121
|
+
).fetchone()
|
|
122
|
+
if not row:
|
|
123
|
+
return None
|
|
124
|
+
return _row_to_practice(row)
|
|
125
|
+
|
|
126
|
+
def search(
|
|
127
|
+
self,
|
|
128
|
+
name: str | None = None,
|
|
129
|
+
postcode: str | None = None,
|
|
130
|
+
town: str | None = None,
|
|
131
|
+
status: str | None = None,
|
|
132
|
+
limit: int = 25,
|
|
133
|
+
) -> list[Practice]:
|
|
134
|
+
"""
|
|
135
|
+
Search practices by name and/or postcode and/or town.
|
|
136
|
+
|
|
137
|
+
Note: this is simple LIKE matching for v0.1.
|
|
138
|
+
Fuzzy matching can come later as an optional dependency.
|
|
139
|
+
"""
|
|
140
|
+
clauses: list[str] = []
|
|
141
|
+
params: list[object] = []
|
|
142
|
+
|
|
143
|
+
if status:
|
|
144
|
+
clauses.append("status = ?")
|
|
145
|
+
params.append(status)
|
|
146
|
+
|
|
147
|
+
if postcode:
|
|
148
|
+
pn = normalize_postcode(postcode)
|
|
149
|
+
clauses.append("postcode_norm = ?")
|
|
150
|
+
params.append(pn)
|
|
151
|
+
|
|
152
|
+
if town:
|
|
153
|
+
clauses.append("LOWER(town) LIKE ?")
|
|
154
|
+
params.append(f"%{town.strip().lower()}%")
|
|
155
|
+
|
|
156
|
+
if name:
|
|
157
|
+
nn = normalize_name(name)
|
|
158
|
+
clauses.append("name_norm LIKE ?")
|
|
159
|
+
params.append(f"%{nn}%")
|
|
160
|
+
|
|
161
|
+
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
162
|
+
sql = f"SELECT * FROM practices{where} LIMIT ?"
|
|
163
|
+
params.append(int(limit))
|
|
164
|
+
|
|
165
|
+
con = self._con()
|
|
166
|
+
rows = con.execute(sql, params).fetchall()
|
|
167
|
+
return [_row_to_practice(r) for r in rows]
|
|
168
|
+
|
|
169
|
+
def _prepare_rows_epraccur(self, rows: list[list[str]]) -> list[dict[str, Any]]:
|
|
170
|
+
"""
|
|
171
|
+
Parse headerless epraccur CSV by positional columns.
|
|
172
|
+
|
|
173
|
+
Observed column mapping:
|
|
174
|
+
0 = organisation_code
|
|
175
|
+
1 = name
|
|
176
|
+
7 = town
|
|
177
|
+
9 = postcode
|
|
178
|
+
12 = status
|
|
179
|
+
"""
|
|
180
|
+
prepared: list[dict[str, Any]] = []
|
|
181
|
+
|
|
182
|
+
for r in rows:
|
|
183
|
+
if not r or len(r) < 13:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
code = (r[0] or "").strip()
|
|
187
|
+
name = (r[1] or "").strip()
|
|
188
|
+
town = (r[7] or "").strip() or None
|
|
189
|
+
postcode = (r[9] or "").strip() or None
|
|
190
|
+
status = (r[12] or "").strip() or None
|
|
191
|
+
|
|
192
|
+
if not code or not name:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
prepared.append(
|
|
196
|
+
{
|
|
197
|
+
"organisation_code": code,
|
|
198
|
+
"name": name,
|
|
199
|
+
"name_norm": normalize_name(name),
|
|
200
|
+
"postcode": postcode,
|
|
201
|
+
"postcode_norm": normalize_postcode(postcode),
|
|
202
|
+
"town": town,
|
|
203
|
+
"status": status,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return prepared
|
|
208
|
+
|
|
209
|
+
def load_csv(self, csv_file: str | Path, report: str = DEFAULT_REPORT) -> int:
|
|
210
|
+
"""
|
|
211
|
+
Load a local CSV file into the SQLite database.
|
|
212
|
+
|
|
213
|
+
Returns the number of rows ingested.
|
|
214
|
+
"""
|
|
215
|
+
csvf = Path(csv_file)
|
|
216
|
+
con = self._con()
|
|
217
|
+
init_db(con)
|
|
218
|
+
|
|
219
|
+
if report.lower() == "epraccur":
|
|
220
|
+
rows = read_csv_rows(csvf)
|
|
221
|
+
prepared = self._prepare_rows_epraccur(rows)
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError(f"Unsupported report for local CSV load: {report}")
|
|
224
|
+
|
|
225
|
+
upsert_practices(con, prepared)
|
|
226
|
+
return len(prepared)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Models representing GP practice data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class Practice:
|
|
11
|
+
"""
|
|
12
|
+
Represents a UK GP Practice (surgery) record.
|
|
13
|
+
|
|
14
|
+
This is based on NHS ODS Data Search & Export (DSE) CSV reports such as `epraccur`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
organisation_code: str
|
|
18
|
+
name: str
|
|
19
|
+
|
|
20
|
+
postcode: str | None
|
|
21
|
+
town: str | None
|
|
22
|
+
status: str | None
|
|
23
|
+
|
|
24
|
+
raw: Optional[dict[str, Any]] = None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tools for normalizing data for searching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_ws = re.compile(r"\s+")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_postcode(value: str | None) -> str | None:
|
|
12
|
+
"""
|
|
13
|
+
Normalize a UK postcode into a consistent format for searching.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
" sw1a 1aa " -> "SW1A1AA"
|
|
17
|
+
"""
|
|
18
|
+
if not value:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
v = _ws.sub("", value.strip().upper())
|
|
22
|
+
return v or None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_name(value: str | None) -> str | None:
|
|
26
|
+
"""
|
|
27
|
+
Normalize a name for case-insensitive searching.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
" Castle Medical " -> "castle medical"
|
|
31
|
+
"""
|
|
32
|
+
if not value:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
v = _ws.sub(" ", value.strip())
|
|
36
|
+
return v.lower() or None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Paths for storing cached data and reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from platformdirs import user_cache_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
APP_NAME = "uk-gp-practices"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cache_dir() -> Path:
|
|
13
|
+
"""
|
|
14
|
+
Returns the cache directory for the package.
|
|
15
|
+
|
|
16
|
+
Example (Linux):
|
|
17
|
+
~/.cache/uk-gp-practices/
|
|
18
|
+
"""
|
|
19
|
+
p = Path(user_cache_dir(APP_NAME))
|
|
20
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return p
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def db_path() -> Path:
|
|
25
|
+
"""Default SQLite database path."""
|
|
26
|
+
return cache_dir() / "practices.sqlite3"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def csv_path(report: str) -> Path:
|
|
30
|
+
"""Path for storing a downloaded report CSV file."""
|
|
31
|
+
safe = report.replace("/", "_").replace("\\", "_").replace("..", "_")
|
|
32
|
+
return cache_dir() / f"{safe}.csv"
|
|
File without changes
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
A81001,THE DENSHAM SURGERY,Y63,QHM,HEALTH CENTRE,LAWSON STREET,,STOCKTON-ON-TEES,,TS18 1HU,19740401,,ACTIVE,B,16C,20200401,,01642 672351,,,,1,,16C,,RO76,
|
|
2
|
+
A81002,QUEENS PARK MEDICAL CENTRE,Y63,QHM,FARRER STREET,,,STOCKTON ON TEES,,TS18 2AW,19740401,,ACTIVE,B,16C,20200401,,01642 618170,,,,1,,16C,,RO76,
|
|
3
|
+
W96001,CASTLE MEDICAL PRACTICE,Y59,QHM,CASTLE STREET,,,SWANSEA,,SA1 1AA,19740401,,ACTIVE,B,16C,20200401,,01792 123456,,,,1,,16C,,RO76,
|
|
4
|
+
W96002,RIVERSIDE FAMILY PRACTICE,Y59,QHM,RIVER ROAD,,,SWANSEA,,SA1 2BB,19740401,,INACTIVE,B,16C,20200401,,01792 654321,,,,1,,16C,,RO76,
|
|
5
|
+
E12345,SOME HEALTH CENTRE,Y60,QHM,MAIN ROAD,,,LONDON,,SW1A 1AA,19740401,,ACTIVE,B,16C,20200401,,020 7946 0000,,,,1,,16C,,RO76,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from uk_gp_practices.cli import app
|
|
9
|
+
from uk_gp_practices.index import PracticeIndex
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_index(tmp_path: Path) -> PracticeIndex:
|
|
15
|
+
"""Create a PracticeIndex with sample data loaded."""
|
|
16
|
+
db_file = tmp_path / "practices.sqlite3"
|
|
17
|
+
csv_file = Path(__file__).parent / "fixtures" / "epraccur_sample.csv"
|
|
18
|
+
idx = PracticeIndex(db_file=db_file)
|
|
19
|
+
idx.load_csv(csv_file)
|
|
20
|
+
return idx
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestGetCommand:
|
|
24
|
+
def test_found(self, tmp_path: Path):
|
|
25
|
+
idx = _make_index(tmp_path)
|
|
26
|
+
with patch.object(PracticeIndex, "auto_update", return_value=idx):
|
|
27
|
+
result = runner.invoke(app, ["get", "W96001"])
|
|
28
|
+
assert result.exit_code == 0
|
|
29
|
+
assert "CASTLE MEDICAL PRACTICE" in result.output
|
|
30
|
+
|
|
31
|
+
def test_not_found(self, tmp_path: Path):
|
|
32
|
+
idx = _make_index(tmp_path)
|
|
33
|
+
with patch.object(PracticeIndex, "auto_update", return_value=idx):
|
|
34
|
+
result = runner.invoke(app, ["get", "ZZZZZ"])
|
|
35
|
+
assert result.exit_code == 1
|
|
36
|
+
assert "Practice not found" in result.output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestSearchCommand:
|
|
40
|
+
def test_search_by_name(self, tmp_path: Path):
|
|
41
|
+
idx = _make_index(tmp_path)
|
|
42
|
+
with patch.object(PracticeIndex, "auto_update", return_value=idx):
|
|
43
|
+
result = runner.invoke(app, ["search", "--name", "castle"])
|
|
44
|
+
assert result.exit_code == 0
|
|
45
|
+
assert "CASTLE MEDICAL PRACTICE" in result.output
|
|
46
|
+
|
|
47
|
+
def test_search_no_results(self, tmp_path: Path):
|
|
48
|
+
idx = _make_index(tmp_path)
|
|
49
|
+
with patch.object(PracticeIndex, "auto_update", return_value=idx):
|
|
50
|
+
result = runner.invoke(app, ["search", "--name", "nonexistent999"])
|
|
51
|
+
assert result.exit_code == 0
|
|
52
|
+
assert result.output.strip() == "[]"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from uk_gp_practices.download import download_report
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _mock_response(content: bytes = b"code,name\nA1,Test", status_code: int = 200):
|
|
13
|
+
resp = MagicMock(spec=httpx.Response)
|
|
14
|
+
resp.content = content
|
|
15
|
+
resp.status_code = status_code
|
|
16
|
+
resp.url = "https://example.com/api/getReport?report=epraccur"
|
|
17
|
+
resp.raise_for_status = MagicMock()
|
|
18
|
+
if status_code >= 400:
|
|
19
|
+
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
20
|
+
"error", request=MagicMock(), response=resp
|
|
21
|
+
)
|
|
22
|
+
return resp
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestDownloadReport:
|
|
26
|
+
def test_success(self, tmp_path: Path):
|
|
27
|
+
dest = tmp_path / "report.csv"
|
|
28
|
+
mock_resp = _mock_response()
|
|
29
|
+
|
|
30
|
+
with patch("uk_gp_practices.download.httpx.Client") as mock_client_cls:
|
|
31
|
+
client = MagicMock()
|
|
32
|
+
client.get.return_value = mock_resp
|
|
33
|
+
mock_client_cls.return_value.__enter__ = MagicMock(return_value=client)
|
|
34
|
+
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
35
|
+
|
|
36
|
+
result = download_report(report="epraccur", dest=dest)
|
|
37
|
+
|
|
38
|
+
assert result.report == "epraccur"
|
|
39
|
+
assert result.path == dest
|
|
40
|
+
assert result.bytes_written > 0
|
|
41
|
+
assert dest.exists()
|
|
42
|
+
|
|
43
|
+
def test_http_error_raises(self, tmp_path: Path):
|
|
44
|
+
dest = tmp_path / "report.csv"
|
|
45
|
+
mock_resp = _mock_response(status_code=500)
|
|
46
|
+
|
|
47
|
+
with patch("uk_gp_practices.download.httpx.Client") as mock_client_cls:
|
|
48
|
+
client = MagicMock()
|
|
49
|
+
client.get.return_value = mock_resp
|
|
50
|
+
mock_client_cls.return_value.__enter__ = MagicMock(return_value=client)
|
|
51
|
+
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
52
|
+
|
|
53
|
+
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
54
|
+
download_report(report="epraccur", dest=dest)
|
|
55
|
+
|
|
56
|
+
def test_all_retries_exhausted(self, tmp_path: Path):
|
|
57
|
+
dest = tmp_path / "report.csv"
|
|
58
|
+
|
|
59
|
+
with patch("uk_gp_practices.download.httpx.Client") as mock_client_cls:
|
|
60
|
+
client = MagicMock()
|
|
61
|
+
client.get.side_effect = httpx.ConnectError("refused")
|
|
62
|
+
mock_client_cls.return_value.__enter__ = MagicMock(return_value=client)
|
|
63
|
+
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
64
|
+
|
|
65
|
+
with patch("uk_gp_practices.download.time.sleep"):
|
|
66
|
+
with pytest.raises(RuntimeError, match="Failed to download"):
|
|
67
|
+
download_report(report="epraccur", dest=dest, retries=1, backoff_seconds=0)
|
|
68
|
+
|
|
69
|
+
def test_env_override(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
70
|
+
dest = tmp_path / "report.csv"
|
|
71
|
+
monkeypatch.setenv("UK_GP_PRACTICES_DSE_URL", "https://custom.example.com/api")
|
|
72
|
+
mock_resp = _mock_response()
|
|
73
|
+
|
|
74
|
+
with patch("uk_gp_practices.download.httpx.Client") as mock_client_cls:
|
|
75
|
+
client = MagicMock()
|
|
76
|
+
client.get.return_value = mock_resp
|
|
77
|
+
mock_client_cls.return_value.__enter__ = MagicMock(return_value=client)
|
|
78
|
+
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
79
|
+
|
|
80
|
+
result = download_report(report="epraccur", dest=dest)
|
|
81
|
+
|
|
82
|
+
client.get.assert_called_once_with("https://custom.example.com/api", params={"report": "epraccur"})
|
|
83
|
+
assert result.bytes_written > 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from uk_gp_practices.index import PracticeIndex
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_epraccur_ingest_and_search(tmp_path: Path) -> None:
|
|
9
|
+
db_file = tmp_path / "practices.sqlite3"
|
|
10
|
+
csv_file = Path(__file__).parent / "fixtures" / "epraccur_sample.csv"
|
|
11
|
+
|
|
12
|
+
idx = PracticeIndex(db_file=db_file)
|
|
13
|
+
idx.load_csv(csv_file) # this must ingest the headerless CSV correctly
|
|
14
|
+
|
|
15
|
+
# get() by code
|
|
16
|
+
p = idx.get("W96001")
|
|
17
|
+
assert p is not None
|
|
18
|
+
assert p.name == "CASTLE MEDICAL PRACTICE"
|
|
19
|
+
if p.town:
|
|
20
|
+
assert p.town.lower().startswith("swansea")
|
|
21
|
+
if p.postcode:
|
|
22
|
+
assert p.postcode.replace(" ", "") == "SA11AA"
|
|
23
|
+
|
|
24
|
+
# search() by name
|
|
25
|
+
res = idx.search(name="castle", status=None, limit=10)
|
|
26
|
+
assert any(r.organisation_code == "W96001" for r in res)
|
|
27
|
+
|
|
28
|
+
# status filtering should work when requested
|
|
29
|
+
active = idx.search(name="practice", status="ACTIVE", limit=50)
|
|
30
|
+
assert all((r.status or "").upper() == "ACTIVE" for r in active)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from uk_gp_practices.normalise import normalize_name, normalize_postcode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestNormalizePostcode:
|
|
8
|
+
def test_basic(self):
|
|
9
|
+
assert normalize_postcode("SW1A 1AA") == "SW1A1AA"
|
|
10
|
+
|
|
11
|
+
def test_extra_whitespace(self):
|
|
12
|
+
assert normalize_postcode(" sw1a 1aa ") == "SW1A1AA"
|
|
13
|
+
|
|
14
|
+
def test_lowercase(self):
|
|
15
|
+
assert normalize_postcode("sw1a1aa") == "SW1A1AA"
|
|
16
|
+
|
|
17
|
+
def test_none(self):
|
|
18
|
+
assert normalize_postcode(None) is None
|
|
19
|
+
|
|
20
|
+
def test_empty_string(self):
|
|
21
|
+
assert normalize_postcode("") is None
|
|
22
|
+
|
|
23
|
+
def test_whitespace_only(self):
|
|
24
|
+
assert normalize_postcode(" ") is None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestNormalizeName:
|
|
28
|
+
def test_basic(self):
|
|
29
|
+
assert normalize_name("Castle Medical") == "castle medical"
|
|
30
|
+
|
|
31
|
+
def test_extra_whitespace(self):
|
|
32
|
+
assert normalize_name(" Castle Medical ") == "castle medical"
|
|
33
|
+
|
|
34
|
+
def test_none(self):
|
|
35
|
+
assert normalize_name(None) is None
|
|
36
|
+
|
|
37
|
+
def test_empty_string(self):
|
|
38
|
+
assert normalize_name("") is None
|
|
39
|
+
|
|
40
|
+
def test_whitespace_only(self):
|
|
41
|
+
assert normalize_name(" ") is None
|
|
42
|
+
|
|
43
|
+
def test_already_lowercase(self):
|
|
44
|
+
assert normalize_name("castle medical") == "castle medical"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from uk_gp_practices.paths import csv_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestCsvPathSanitization:
|
|
7
|
+
def test_normal_report(self):
|
|
8
|
+
p = csv_path("epraccur")
|
|
9
|
+
assert p.name == "epraccur.csv"
|
|
10
|
+
|
|
11
|
+
def test_path_traversal_slashes(self):
|
|
12
|
+
p = csv_path("../../etc/passwd")
|
|
13
|
+
assert "/" not in p.name.replace(".csv", "")
|
|
14
|
+
assert "\\" not in p.name.replace(".csv", "")
|
|
15
|
+
|
|
16
|
+
def test_dotdot(self):
|
|
17
|
+
p = csv_path("../secret")
|
|
18
|
+
assert ".." not in p.name
|