diff --git a/domain/epc/__init__.py b/domain/epc/__init__.py index e49fea42..e69de29b 100644 --- a/domain/epc/__init__.py +++ b/domain/epc/__init__.py @@ -1,4 +0,0 @@ -from domain.epc.epc_record import EpcRecord -from domain.epc.property_type import PropertyType - -__all__ = ["EpcRecord", "PropertyType"] diff --git a/domain/epc/epc_record.py b/domain/epc/epc_record.py deleted file mode 100644 index 7194d1d6..00000000 --- a/domain/epc/epc_record.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Optional - -from domain.epc.property_type import PropertyType - - -@dataclass(frozen=True) -class EpcRecord: - """A streamlined record of EPC property data. - - A focused subset of the full ``EpcPropertyData``: a property's identity - plus its typed property type. Grow this with further fields as the - domain needs them. - """ - - address_line_1: str - postcode: str - uprn: Optional[int] - property_type: PropertyType diff --git a/infrastructure/epc/__init__.py b/infrastructure/epc/__init__.py deleted file mode 100644 index f99a7cb3..00000000 --- a/infrastructure/epc/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from infrastructure.epc.epc_client import EpcClient -from infrastructure.epc.exceptions import ( - EpcApiError, - EpcNotFoundError, - EpcRateLimitError, -) - -__all__ = [ - "EpcApiError", - "EpcClient", - "EpcNotFoundError", - "EpcRateLimitError", -] diff --git a/infrastructure/epc/epc_client.py b/infrastructure/epc/epc_client.py deleted file mode 100644 index d1f8639c..00000000 --- a/infrastructure/epc/epc_client.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Optional - -from datatypes.epc.domain.epc_property_data import EpcPropertyData -from datatypes.epc.search import EpcSearchResult - - -class EpcClient(ABC): - """Interface for retrieving EPC (Energy Performance Certificate) data. - - Implementations fetch from a data source and return domain objects; - callers depend only on this interface, not on a concrete transport. - """ - - @abstractmethod - def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]: - """Return the EPC certificates registered at ``postcode``. - - Returns an empty list when the postcode has no certificates. - """ - ... - - @abstractmethod - def get_by_certificate_number( - self, certificate_number: str - ) -> EpcPropertyData: - """Return the full EPC record for a certificate number. - - Raises EpcNotFoundError when no such certificate exists. - """ - ... - - @abstractmethod - def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]: - """Return the most recent EPC record for ``uprn``. - - Returns None when the UPRN has no certificates. - """ - ... diff --git a/infrastructure/epc/exceptions.py b/infrastructure/epc/exceptions.py deleted file mode 100644 index 8e2e5165..00000000 --- a/infrastructure/epc/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional - - -class EpcApiError(Exception): - """Base for all EPC client errors.""" - - -class EpcNotFoundError(EpcApiError): - """Raised when the API returns 404 for a resource that must exist.""" - - -class EpcRateLimitError(EpcApiError): - """Raised when the API returns 429 and all retries are exhausted.""" - - def __init__(self, message: str, retry_after: Optional[float] = None) -> None: - super().__init__(message) - self.retry_after = retry_after diff --git a/infrastructure/epc/gov_uk/__init__.py b/infrastructure/epc/gov_uk/__init__.py deleted file mode 100644 index d491a1ef..00000000 --- a/infrastructure/epc/gov_uk/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from infrastructure.epc.gov_uk.gov_uk_epc_client import GovUkEpcClient -from infrastructure.epc.gov_uk.gov_uk_property_type import ( - property_type_from_gov_uk_code, -) - -__all__ = ["GovUkEpcClient", "property_type_from_gov_uk_code"] diff --git a/infrastructure/epc/gov_uk/_retry.py b/infrastructure/epc/gov_uk/_retry.py deleted file mode 100644 index db92b131..00000000 --- a/infrastructure/epc/gov_uk/_retry.py +++ /dev/null @@ -1,34 +0,0 @@ -import time -from typing import Callable, Optional, TypeVar - -from infrastructure.epc.exceptions import EpcRateLimitError - -T = TypeVar("T") - - -def call_with_retry( - fn: Callable[[], T], - max_retries: int = 5, - backoff_base: float = 1.0, - backoff_multiplier: float = 2.0, - max_backoff: float = 60.0, -) -> T: - """Call ``fn``, retrying on EpcRateLimitError with exponential backoff. - - Honours the API's ``Retry-After`` header when present, otherwise backs off - ``backoff_base * backoff_multiplier ** attempt`` (capped at ``max_backoff``). - """ - last_exc: Optional[EpcRateLimitError] = None - for attempt in range(max_retries + 1): - try: - return fn() - except EpcRateLimitError as exc: - last_exc = exc - if attempt < max_retries: - if exc.retry_after is not None: - delay = exc.retry_after - else: - delay = backoff_base * (backoff_multiplier**attempt) - time.sleep(min(delay, max_backoff)) - assert last_exc is not None - raise last_exc diff --git a/infrastructure/epc/gov_uk/gov_uk_epc_client.py b/infrastructure/epc/gov_uk/gov_uk_epc_client.py deleted file mode 100644 index ac0db09f..00000000 --- a/infrastructure/epc/gov_uk/gov_uk_epc_client.py +++ /dev/null @@ -1,132 +0,0 @@ -# Spec: https://raw.githubusercontent.com/communitiesuk/epb-data-warehouse/main/api/api.yml -from __future__ import annotations - -from typing import Any, Optional - -import httpx - -from datatypes.epc.domain.epc_property_data import EpcPropertyData -from datatypes.epc.domain.mapper import EpcPropertyDataMapper -from datatypes.epc.search import EpcSearchResult -from infrastructure.epc.epc_client import EpcClient -from infrastructure.epc.exceptions import ( - EpcApiError, - EpcNotFoundError, - EpcRateLimitError, -) -from infrastructure.epc.gov_uk._retry import call_with_retry - - -class GovUkEpcClient(EpcClient): - """EpcClient backed by the live gov.uk EPC API. - - Endpoint: https://api.get-energy-performance-data.communities.gov.uk - """ - - BASE_URL = "https://api.get-energy-performance-data.communities.gov.uk" - REQUEST_TIMEOUT = 10.0 - - def __init__(self, auth_token: str) -> None: - self._headers = { - "Authorization": f"Bearer {auth_token}", - "Accept": "application/json", - } - - def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]: - normalised = self._normalise_postcode(postcode) - return call_with_retry(lambda: self._search(postcode=normalised)) - - def get_by_certificate_number( - self, certificate_number: str - ) -> EpcPropertyData: - raw = call_with_retry(lambda: self._fetch_certificate(certificate_number)) - return EpcPropertyDataMapper.from_api_response(raw) - - def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]: - results = call_with_retry(lambda: self._search(uprn=uprn)) - if not results: - return None - latest = max(results, key=lambda r: r.registration_date) - return self.get_by_certificate_number(latest.certificate_number) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - @staticmethod - def _normalise_postcode(postcode: str) -> str: - """Return the postcode with all spaces removed and uppercased.""" - return postcode.replace(" ", "").upper() - - @staticmethod - def _parse_retry_after(resp: httpx.Response) -> Optional[float]: - header = resp.headers.get("Retry-After") - if header is None: - return None - try: - return float(header) - except (TypeError, ValueError): - return None - - def _fetch_certificate(self, certificate_number: str) -> dict[str, Any]: - resp = httpx.get( - f"{self.BASE_URL}/api/certificate", - params={"certificate_number": certificate_number}, - headers=self._headers, - timeout=self.REQUEST_TIMEOUT, - ) - if resp.status_code == 404: - raise EpcNotFoundError(certificate_number) - if resp.status_code == 429: - raise EpcRateLimitError( - "Rate limited by EPC API", - retry_after=self._parse_retry_after(resp), - ) - if not resp.is_success: - raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}") - return resp.json()["data"] - - def _search( - self, - postcode: Optional[str] = None, - uprn: Optional[int] = None, - ) -> list[EpcSearchResult]: - params: dict[str, str | int] = {} - if postcode: - params["postcode"] = postcode - if uprn is not None: - params["uprn"] = uprn - - resp = httpx.get( - f"{self.BASE_URL}/api/domestic/search", - params=params, - headers=self._headers, - timeout=self.REQUEST_TIMEOUT, - ) - if resp.status_code == 404: - return [] - if resp.status_code == 429: - raise EpcRateLimitError( - "Rate limited by EPC API", - retry_after=self._parse_retry_after(resp), - ) - if not resp.is_success: - raise EpcApiError(f"EPC API error {resp.status_code}: {resp.text}") - - rows = resp.json().get("data", []) - return [self._parse_search_result(row) for row in rows] - - @staticmethod - def _parse_search_result(row: dict[str, Any]) -> EpcSearchResult: - return EpcSearchResult( - certificate_number=row["certificateNumber"], - address_line_1=row["addressLine1"], - address_line_2=row.get("addressLine2"), - address_line_3=row.get("addressLine3"), - address_line_4=row.get("addressLine4"), - postcode=row["postcode"], - post_town=row["postTown"], - uprn=row.get("uprn"), - current_energy_efficiency_band=row["currentEnergyEfficiencyBand"], - registration_date=row["registrationDate"], - ) diff --git a/infrastructure/epc/gov_uk/gov_uk_property_type.py b/infrastructure/epc/gov_uk/gov_uk_property_type.py deleted file mode 100644 index a0f4a7a3..00000000 --- a/infrastructure/epc/gov_uk/gov_uk_property_type.py +++ /dev/null @@ -1,25 +0,0 @@ -from domain.epc.property_type import PropertyType - -# GOV.UK EPC API ``property_type`` integer codes mapped to the domain type. -# This translation is GOV.UK-specific and lives in the infrastructure layer so -# the domain ``PropertyType`` stays free of any source encoding. -_PROPERTY_TYPE_BY_GOV_UK_CODE: dict[int, PropertyType] = { - 0: PropertyType.HOUSE, - 1: PropertyType.BUNGALOW, - 2: PropertyType.FLAT, - 3: PropertyType.MAISONETTE, - 4: PropertyType.PARK_HOME, -} - - -def property_type_from_gov_uk_code(code: int) -> PropertyType: - """Translate a GOV.UK EPC ``property_type`` code to the domain PropertyType. - - Raises ValueError for a code GOV.UK has not been mapped here yet. - """ - try: - return _PROPERTY_TYPE_BY_GOV_UK_CODE[code] - except KeyError: - raise ValueError( - f"Unknown GOV.UK EPC property type code: {code}" - ) from None diff --git a/infrastructure/epc/historical_open_data_communities/__init__.py b/infrastructure/epc/historical_open_data_communities/__init__.py deleted file mode 100644 index 88a69081..00000000 --- a/infrastructure/epc/historical_open_data_communities/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from infrastructure.epc.historical_open_data_communities.historical_open_data_communities_epc_client import ( - HistoricalOpenDataCommunitiesEpcClient, -) - -__all__ = ["HistoricalOpenDataCommunitiesEpcClient"] diff --git a/infrastructure/epc/historical_open_data_communities/historical_open_data_communities_epc_client.py b/infrastructure/epc/historical_open_data_communities/historical_open_data_communities_epc_client.py deleted file mode 100644 index d8c7f9ac..00000000 --- a/infrastructure/epc/historical_open_data_communities/historical_open_data_communities_epc_client.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from domain.epc.epc_record import EpcRecord - - -class HistoricalOpenDataCommunitiesEpcClient: - """EPC client backed by Open Data Communities' historical EPC data. - - Stub — not yet implemented. Every method raises NotImplementedError for - now. Unlike GovUkEpcClient it returns the domain ``EpcRecord`` directly; - once the ``EpcClient`` port is migrated to return ``EpcRecord``, this - adapter should implement it. - """ - - def search_by_postcode(self, postcode: str) -> list[EpcRecord]: - raise NotImplementedError - - def get_by_certificate_number(self, certificate_number: str) -> EpcRecord: - raise NotImplementedError - - def get_by_uprn(self, uprn: int) -> Optional[EpcRecord]: - raise NotImplementedError diff --git a/tests/infrastructure/epc/__init__.py b/tests/infrastructure/epc/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/infrastructure/epc/gov_uk/__init__.py b/tests/infrastructure/epc/gov_uk/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/infrastructure/epc/gov_uk/conftest.py b/tests/infrastructure/epc/gov_uk/conftest.py deleted file mode 100644 index 8fbd3094..00000000 --- a/tests/infrastructure/epc/gov_uk/conftest.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import pathlib - -import pytest - -from infrastructure.epc.gov_uk.gov_uk_epc_client import GovUkEpcClient - -SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples") - - -@pytest.fixture -def rdsap_21_0_0_cert(): - return json.loads((SAMPLES_DIR / "RdSAP-Schema-21.0.0/epc.json").read_text()) - - -@pytest.fixture -def rdsap_21_0_1_cert(): - return json.loads((SAMPLES_DIR / "RdSAP-Schema-21.0.1/epc.json").read_text()) - - -@pytest.fixture -def epc_client(): - return GovUkEpcClient(auth_token="test-token") - - -def make_search_row( - cert_num="CERT-001", - address_line_1="1 Test Street", - postcode="SW1A 1AA", - post_town="London", - uprn=100023336956, - band="D", - registration_date="2024-01-01", - address_line_2=None, - address_line_3=None, - address_line_4=None, -): - return { - "certificateNumber": cert_num, - "addressLine1": address_line_1, - "addressLine2": address_line_2, - "addressLine3": address_line_3, - "addressLine4": address_line_4, - "postcode": postcode, - "postTown": post_town, - "uprn": uprn, - "currentEnergyEfficiencyBand": band, - "registrationDate": registration_date, - } diff --git a/tests/infrastructure/epc/gov_uk/test_gov_uk_epc_client.py b/tests/infrastructure/epc/gov_uk/test_gov_uk_epc_client.py deleted file mode 100644 index 46164a0e..00000000 --- a/tests/infrastructure/epc/gov_uk/test_gov_uk_epc_client.py +++ /dev/null @@ -1,211 +0,0 @@ -from unittest.mock import MagicMock, call, patch - -import pytest - -from datatypes.epc.domain.epc_property_data import EpcPropertyData -from datatypes.epc.search import EpcSearchResult -from infrastructure.epc.exceptions import EpcNotFoundError -from tests.infrastructure.epc.gov_uk.conftest import make_search_row - -_SLEEP = "infrastructure.epc.gov_uk._retry.time.sleep" - - -def _mock_response(status_code=200, json_data=None, headers=None): - resp = MagicMock() - resp.status_code = status_code - resp.is_success = 200 <= status_code < 300 - resp.json.return_value = json_data or {} - resp.text = str(json_data) - resp.headers = headers or {} - return resp - - -# --------------------------------------------------------------------------- -# Test 1: get_by_certificate_number happy path -# --------------------------------------------------------------------------- - - -def test_get_by_certificate_number_returns_epc_property_data( - epc_client, rdsap_21_0_1_cert -): - cert_response = {"data": rdsap_21_0_1_cert} - with patch("httpx.get", return_value=_mock_response(200, cert_response)): - result = epc_client.get_by_certificate_number("CERT-001") - - assert isinstance(result, EpcPropertyData) - - -# --------------------------------------------------------------------------- -# Test 2: get_by_certificate_number 404 -> EpcNotFoundError -# --------------------------------------------------------------------------- - - -def test_get_by_certificate_number_404_raises_not_found(epc_client): - with patch("httpx.get", return_value=_mock_response(404)): - with pytest.raises(EpcNotFoundError): - epc_client.get_by_certificate_number("BAD-CERT") - - -# --------------------------------------------------------------------------- -# Test 3: 429 retried, succeeds on 3rd attempt -# --------------------------------------------------------------------------- - - -def test_get_by_certificate_number_retries_on_429_and_succeeds( - epc_client, rdsap_21_0_1_cert -): - cert_response = {"data": rdsap_21_0_1_cert} - responses = [ - _mock_response(429), - _mock_response(429), - _mock_response(200, cert_response), - ] - with patch("httpx.get", side_effect=responses), patch(_SLEEP): - result = epc_client.get_by_certificate_number("CERT-001") - - assert isinstance(result, EpcPropertyData) - - -# --------------------------------------------------------------------------- -# Test 3b: 429 with Retry-After header -> sleeps for that value -# --------------------------------------------------------------------------- - - -def test_429_retry_after_header_drives_sleep_duration( - epc_client, rdsap_21_0_1_cert -): - cert_response = {"data": rdsap_21_0_1_cert} - responses = [ - _mock_response(429, headers={"Retry-After": "7"}), - _mock_response(200, cert_response), - ] - with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep: - epc_client.get_by_certificate_number("CERT-001") - - mock_sleep.assert_called_once_with(7.0) - - -# --------------------------------------------------------------------------- -# Test 3c: 429 without Retry-After -> falls back to exponential backoff -# --------------------------------------------------------------------------- - - -def test_429_without_retry_after_uses_exponential_backoff( - epc_client, rdsap_21_0_1_cert -): - cert_response = {"data": rdsap_21_0_1_cert} - responses = [ - _mock_response(429), - _mock_response(429), - _mock_response(200, cert_response), - ] - with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep: - epc_client.get_by_certificate_number("CERT-001") - - assert mock_sleep.call_args_list == [call(1.0), call(2.0)] - - -# --------------------------------------------------------------------------- -# Test 3d: malformed Retry-After header -> falls back to exponential backoff -# --------------------------------------------------------------------------- - - -def test_429_malformed_retry_after_falls_back_to_backoff( - epc_client, rdsap_21_0_1_cert -): - cert_response = {"data": rdsap_21_0_1_cert} - responses = [ - _mock_response(429, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}), - _mock_response(200, cert_response), - ] - with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep: - epc_client.get_by_certificate_number("CERT-001") - - mock_sleep.assert_called_once_with(1.0) - - -# --------------------------------------------------------------------------- -# Test 3e: Retry-After capped by max_backoff to avoid hostile/buggy values -# --------------------------------------------------------------------------- - - -def test_429_retry_after_capped_by_max_backoff(epc_client, rdsap_21_0_1_cert): - cert_response = {"data": rdsap_21_0_1_cert} - responses = [ - _mock_response(429, headers={"Retry-After": "9999"}), - _mock_response(200, cert_response), - ] - with patch("httpx.get", side_effect=responses), patch(_SLEEP) as mock_sleep: - epc_client.get_by_certificate_number("CERT-001") - - mock_sleep.assert_called_once_with(60.0) - - -# --------------------------------------------------------------------------- -# Test 4: get_by_uprn empty search -> None -# --------------------------------------------------------------------------- - - -def test_get_by_uprn_returns_none_when_no_results(epc_client): - with patch("httpx.get", return_value=_mock_response(200, {"data": []})): - result = epc_client.get_by_uprn(100023336956) - - assert result is None - - -# --------------------------------------------------------------------------- -# Test 5: get_by_uprn multiple results -> fetches latest by registration_date -# --------------------------------------------------------------------------- - - -def test_get_by_uprn_picks_most_recent_certificate(epc_client, rdsap_21_0_1_cert): - search_rows = [ - make_search_row(cert_num="CERT-OLD", registration_date="2022-01-01"), - make_search_row(cert_num="CERT-NEW", registration_date="2024-06-01"), - make_search_row(cert_num="CERT-MID", registration_date="2023-03-15"), - ] - cert_response = {"data": rdsap_21_0_1_cert} - - def fake_get(url, params=None, **kwargs): - if "search" in url: - return _mock_response(200, {"data": search_rows}) - return _mock_response(200, cert_response) - - with patch("httpx.get", side_effect=fake_get) as mock_get: - result = epc_client.get_by_uprn(100023336956) - - assert isinstance(result, EpcPropertyData) - # Second call must be for the most recent cert - cert_call = mock_get.call_args_list[1] - assert cert_call.kwargs["params"]["certificate_number"] == "CERT-NEW" - - -# --------------------------------------------------------------------------- -# Test 6: search_by_postcode returns list[EpcSearchResult] -# --------------------------------------------------------------------------- - - -def test_search_by_postcode_returns_results(epc_client): - rows = [ - make_search_row(cert_num="CERT-A", address_line_1="1 High Street"), - make_search_row(cert_num="CERT-B", address_line_1="2 High Street"), - ] - with patch("httpx.get", return_value=_mock_response(200, {"data": rows})): - results = epc_client.search_by_postcode("SW1A 1AA") - - assert len(results) == 2 - assert all(isinstance(r, EpcSearchResult) for r in results) - assert results[0].certificate_number == "CERT-A" - assert results[1].address_line_1 == "2 High Street" - - -# --------------------------------------------------------------------------- -# Test 7: search_by_postcode 404 -> empty list -# --------------------------------------------------------------------------- - - -def test_search_by_postcode_404_returns_empty_list(epc_client): - with patch("httpx.get", return_value=_mock_response(404)): - results = epc_client.search_by_postcode("ZZ9 9ZZ") - - assert results == []