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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(.venv/bin/python -m pytest:*)"
5
+ ]
6
+ }
7
+ }
@@ -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