From caee4de2f45433cbcfb3faaaf536ed8c4b99c139 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 19:44:29 +0000 Subject: [PATCH] feat(ingestion): relocate EpcClientService to infrastructure + SolarRepo (#1133) Move the EpcClientService package (client + _retry + exceptions + tests) from the dying backend/ tree to infrastructure/epc_client/ as the New-EPC-API Fetcher; update the two callers (address2UPRN, a script). All 14 client tests pass. Add SolarRepository port + SolarPostgresRepository persisting Google Solar building insights as JSONB (solar_building_insights table), one row per Property. The EPC repo half of this slice already landed in #1129. pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- backend/address2UPRN/main.py | 2 +- backend/epc_client/__init__.py | 3 -- infrastructure/epc_client/__init__.py | 3 ++ .../epc_client/_retry.py | 2 +- .../epc_client/epc_client_service.py | 4 +- .../epc_client/exceptions.py | 0 .../epc_client/tests/__init__.py | 0 .../epc_client/tests/conftest.py | 2 +- .../epc_client/tests/test_client.py | 14 +++---- .../tests/test_mapper_dispatcher.py | 0 infrastructure/postgres/solar_table.py | 22 ++++++++++ repositories/solar/__init__.py | 0 .../solar/solar_postgres_repository.py | 35 ++++++++++++++++ repositories/solar/solar_repository.py | 19 +++++++++ scripts/fetch_cohort2_api_jsons.py | 6 +-- tests/repositories/solar/__init__.py | 0 .../solar/test_solar_repository.py | 41 +++++++++++++++++++ 17 files changed, 135 insertions(+), 18 deletions(-) delete mode 100644 backend/epc_client/__init__.py create mode 100644 infrastructure/epc_client/__init__.py rename {backend => infrastructure}/epc_client/_retry.py (91%) rename {backend => infrastructure}/epc_client/epc_client_service.py (97%) rename {backend => infrastructure}/epc_client/exceptions.py (100%) rename {backend => infrastructure}/epc_client/tests/__init__.py (100%) rename {backend => infrastructure}/epc_client/tests/conftest.py (93%) rename {backend => infrastructure}/epc_client/tests/test_client.py (94%) rename {backend => infrastructure}/epc_client/tests/test_mapper_dispatcher.py (100%) create mode 100644 infrastructure/postgres/solar_table.py create mode 100644 repositories/solar/__init__.py create mode 100644 repositories/solar/solar_postgres_repository.py create mode 100644 repositories/solar/solar_repository.py create mode 100644 tests/repositories/solar/__init__.py create mode 100644 tests/repositories/solar/test_solar_repository.py diff --git a/backend/address2UPRN/main.py b/backend/address2UPRN/main.py index 389816cc..02eb27dc 100644 --- a/backend/address2UPRN/main.py +++ b/backend/address2UPRN/main.py @@ -19,7 +19,7 @@ from backend.address2UPRN.scoring import all_uprns_match, rank_address_similarit from datatypes.epc.domain.historic_epc_matching import ( match_addresses_for_postcode, ) -from backend.epc_client.epc_client_service import EpcClientService +from infrastructure.epc_client.epc_client_service import EpcClientService from datatypes.epc.domain.historic_epc_matching import ScoredHistoricEpc logger = setup_logger() diff --git a/backend/epc_client/__init__.py b/backend/epc_client/__init__.py deleted file mode 100644 index 84062592..00000000 --- a/backend/epc_client/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from backend.epc_client.epc_client_service import EpcClientService - -__all__ = ["EpcClientService"] diff --git a/infrastructure/epc_client/__init__.py b/infrastructure/epc_client/__init__.py new file mode 100644 index 00000000..f8718b77 --- /dev/null +++ b/infrastructure/epc_client/__init__.py @@ -0,0 +1,3 @@ +from infrastructure.epc_client.epc_client_service import EpcClientService + +__all__ = ["EpcClientService"] diff --git a/backend/epc_client/_retry.py b/infrastructure/epc_client/_retry.py similarity index 91% rename from backend/epc_client/_retry.py rename to infrastructure/epc_client/_retry.py index bbdd0cff..d37f5e9c 100644 --- a/backend/epc_client/_retry.py +++ b/infrastructure/epc_client/_retry.py @@ -1,7 +1,7 @@ import time from typing import Callable, TypeVar -from backend.epc_client.exceptions import EpcRateLimitError +from infrastructure.epc_client.exceptions import EpcRateLimitError T = TypeVar("T") diff --git a/backend/epc_client/epc_client_service.py b/infrastructure/epc_client/epc_client_service.py similarity index 97% rename from backend/epc_client/epc_client_service.py rename to infrastructure/epc_client/epc_client_service.py index 72dbf142..16cd4d2f 100644 --- a/backend/epc_client/epc_client_service.py +++ b/infrastructure/epc_client/epc_client_service.py @@ -5,12 +5,12 @@ from typing import Any, Optional import httpx -from backend.epc_client.exceptions import ( +from infrastructure.epc_client.exceptions import ( EpcApiError, EpcNotFoundError, EpcRateLimitError, ) -from backend.epc_client._retry import call_with_retry +from infrastructure.epc_client._retry import call_with_retry from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.mapper import EpcPropertyDataMapper from datatypes.epc.search import EpcSearchResult diff --git a/backend/epc_client/exceptions.py b/infrastructure/epc_client/exceptions.py similarity index 100% rename from backend/epc_client/exceptions.py rename to infrastructure/epc_client/exceptions.py diff --git a/backend/epc_client/tests/__init__.py b/infrastructure/epc_client/tests/__init__.py similarity index 100% rename from backend/epc_client/tests/__init__.py rename to infrastructure/epc_client/tests/__init__.py diff --git a/backend/epc_client/tests/conftest.py b/infrastructure/epc_client/tests/conftest.py similarity index 93% rename from backend/epc_client/tests/conftest.py rename to infrastructure/epc_client/tests/conftest.py index 2dab138e..dc491c2b 100644 --- a/backend/epc_client/tests/conftest.py +++ b/infrastructure/epc_client/tests/conftest.py @@ -2,7 +2,7 @@ import json import pathlib import pytest -from backend.epc_client.epc_client_service import EpcClientService +from infrastructure.epc_client.epc_client_service import EpcClientService SAMPLES_DIR = pathlib.Path("backend/epc_api/json_samples") diff --git a/backend/epc_client/tests/test_client.py b/infrastructure/epc_client/tests/test_client.py similarity index 94% rename from backend/epc_client/tests/test_client.py rename to infrastructure/epc_client/tests/test_client.py index 70425a92..2b6c4099 100644 --- a/backend/epc_client/tests/test_client.py +++ b/infrastructure/epc_client/tests/test_client.py @@ -1,11 +1,11 @@ from unittest.mock import MagicMock, patch, call import pytest -from backend.epc_client.epc_client_service import EpcClientService +from infrastructure.epc_client.epc_client_service import EpcClientService from datatypes.epc.search import EpcSearchResult -from backend.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError +from infrastructure.epc_client.exceptions import EpcNotFoundError, EpcRateLimitError from datatypes.epc.domain.epc_property_data import EpcPropertyData -from backend.epc_client.tests.conftest import make_search_row +from infrastructure.epc_client.tests.conftest import make_search_row def _mock_response(status_code=200, json_data=None, headers=None): @@ -78,7 +78,7 @@ def test_429_retry_after_header_drives_sleep_duration( _mock_response(200, cert_response), ] with patch("httpx.get", side_effect=responses), patch( - "backend.epc_client._retry.time.sleep" + "infrastructure.epc_client._retry.time.sleep" ) as mock_sleep: epc_service.get_by_certificate_number("CERT-001") @@ -100,7 +100,7 @@ def test_429_without_retry_after_uses_exponential_backoff( _mock_response(200, cert_response), ] with patch("httpx.get", side_effect=responses), patch( - "backend.epc_client._retry.time.sleep" + "infrastructure.epc_client._retry.time.sleep" ) as mock_sleep: epc_service.get_by_certificate_number("CERT-001") @@ -121,7 +121,7 @@ def test_429_malformed_retry_after_falls_back_to_backoff( _mock_response(200, cert_response), ] with patch("httpx.get", side_effect=responses), patch( - "backend.epc_client._retry.time.sleep" + "infrastructure.epc_client._retry.time.sleep" ) as mock_sleep: epc_service.get_by_certificate_number("CERT-001") @@ -140,7 +140,7 @@ def test_429_retry_after_capped_by_max_backoff(epc_service, rdsap_21_0_1_cert): _mock_response(200, cert_response), ] with patch("httpx.get", side_effect=responses), patch( - "backend.epc_client._retry.time.sleep" + "infrastructure.epc_client._retry.time.sleep" ) as mock_sleep: epc_service.get_by_certificate_number("CERT-001") diff --git a/backend/epc_client/tests/test_mapper_dispatcher.py b/infrastructure/epc_client/tests/test_mapper_dispatcher.py similarity index 100% rename from backend/epc_client/tests/test_mapper_dispatcher.py rename to infrastructure/epc_client/tests/test_mapper_dispatcher.py diff --git a/infrastructure/postgres/solar_table.py b/infrastructure/postgres/solar_table.py new file mode 100644 index 00000000..1563ce15 --- /dev/null +++ b/infrastructure/postgres/solar_table.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any, ClassVar, Optional + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class SolarBuildingInsightsRow(SQLModel, table=True): + """Persisted Google Solar `buildingInsights` response for one Property. + + Stored as JSONB — the raw fetched insights are retained whole so the + structured projection a future SolarPotential type needs can be derived + without re-fetching. One row per Property. + """ + + __tablename__: ClassVar[str] = "solar_building_insights" # pyright: ignore[reportIncompatibleVariableOverride] + + id: Optional[int] = Field(default=None, primary_key=True) + property_id: int = Field(index=True, unique=True) + insights: dict[str, Any] = Field(sa_column=Column(JSONB, nullable=False)) diff --git a/repositories/solar/__init__.py b/repositories/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/solar/solar_postgres_repository.py b/repositories/solar/solar_postgres_repository.py new file mode 100644 index 00000000..9c8a70a7 --- /dev/null +++ b/repositories/solar/solar_postgres_repository.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any, Optional + +from sqlmodel import Session, select + +from infrastructure.postgres.solar_table import SolarBuildingInsightsRow +from repositories.solar.solar_repository import SolarRepository + + +class SolarPostgresRepository(SolarRepository): + def __init__(self, session: Session) -> None: + self._session = session + + def save(self, property_id: int, insights: dict[str, Any]) -> None: + existing = self._session.exec( + select(SolarBuildingInsightsRow).where( + SolarBuildingInsightsRow.property_id == property_id + ) + ).first() + if existing is None: + self._session.add( + SolarBuildingInsightsRow(property_id=property_id, insights=insights) + ) + else: + existing.insights = insights + self._session.add(existing) + + def get(self, property_id: int) -> Optional[dict[str, Any]]: + row = self._session.exec( + select(SolarBuildingInsightsRow).where( + SolarBuildingInsightsRow.property_id == property_id + ) + ).first() + return row.insights if row is not None else None diff --git a/repositories/solar/solar_repository.py b/repositories/solar/solar_repository.py new file mode 100644 index 00000000..aa91022a --- /dev/null +++ b/repositories/solar/solar_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Optional + + +class SolarRepository(ABC): + """Persists and loads a Property's Google Solar building insights. + + Thin save/get over the raw fetched insights (a future SolarPotential domain + type will derive its fields from these). Written by Ingestion, read by + Baseline/Modelling — never re-fetched downstream (ADR-0003). + """ + + @abstractmethod + def save(self, property_id: int, insights: dict[str, Any]) -> None: ... + + @abstractmethod + def get(self, property_id: int) -> Optional[dict[str, Any]]: ... diff --git a/scripts/fetch_cohort2_api_jsons.py b/scripts/fetch_cohort2_api_jsons.py index f44a29ea..70211453 100644 --- a/scripts/fetch_cohort2_api_jsons.py +++ b/scripts/fetch_cohort2_api_jsons.py @@ -18,9 +18,9 @@ from dotenv import load_dotenv REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from backend.epc_client._retry import call_with_retry -from backend.epc_client.epc_client_service import EpcClientService -from backend.epc_client.exceptions import ( +from infrastructure.epc_client._retry import call_with_retry +from infrastructure.epc_client.epc_client_service import EpcClientService +from infrastructure.epc_client.exceptions import ( EpcApiError, EpcNotFoundError, EpcRateLimitError, diff --git a/tests/repositories/solar/__init__.py b/tests/repositories/solar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/solar/test_solar_repository.py b/tests/repositories/solar/test_solar_repository.py new file mode 100644 index 00000000..3623ae6e --- /dev/null +++ b/tests/repositories/solar/test_solar_repository.py @@ -0,0 +1,41 @@ +"""SolarRepo round-trips Google Solar building insights for a Property.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Engine +from sqlmodel import Session + +from repositories.solar.solar_postgres_repository import SolarPostgresRepository + + +def test_building_insights_round_trip(db_engine: Engine) -> None: + # Arrange + insights: dict[str, Any] = { + "name": "buildings/ChIJ", + "solarPotential": { + "maxArrayPanelsCount": 42, + "panelCapacityWatts": 250.0, + "roofSegmentStats": [{"pitchDegrees": 30.0, "azimuthDegrees": 180.0}], + }, + } + + # Act + with Session(db_engine) as session: + SolarPostgresRepository(session).save(property_id=5, insights=insights) + session.commit() + with Session(db_engine) as session: + reloaded = SolarPostgresRepository(session).get(5) + + # Assert + assert reloaded == insights + + +def test_get_returns_none_when_no_insights_stored(db_engine: Engine) -> None: + # Arrange / Act + with Session(db_engine) as session: + reloaded = SolarPostgresRepository(session).get(999) + + # Assert + assert reloaded is None