diff --git a/AGENTS.md b/AGENTS.md index 1ff727d..0571513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,15 @@ Use this as the default operating guide for coding agents working here. ## Repository Layout - `backend/`: FastAPI app, SQLModel models, Alembic migrations, pytest suite, backend scripts. - `frontend/`: React 19 + TypeScript + Vite app, Chakra UI, TanStack Router, TanStack Query, Playwright tests. +- `ai_stack/`: AI inference backends (llamacpp, ollama, speaches, vllm). +- `esphome/`: ESPHome configuration for IoT devices. +- `glance/`: Glance dashboard configuration. +- `notebooks/`: Jupyter notebooks for AI/ML experiments (Gemma, Qwen, Whisper, Pydantic AI). - `scripts/`: top-level Docker and integration scripts. +- `hooks/`: Copier project generation hooks. +- `.github/`: GitHub workflows, issue/discussion templates, dependabot config. +- `docker-compose*.yml`: Docker Compose configurations (base, override, traefik). +- `makefile`: Make commands for common tasks. - `frontend/src/client/`: generated OpenAPI client; avoid manual edits. - `frontend/src/routeTree.gen.ts`: generated route tree; do not hand-edit. diff --git a/backend/Dockerfile b/backend/Dockerfile index 6bd645d..7c18afa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,12 +26,13 @@ COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/ # Install dependencies # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=cache,target=/root/.cache/pip \ uv pip install --system -e . ENV PYTHONPATH=/app # Copy application code and scripts -COPY ./scripts /app/scripts COPY ./app /app/app +COPY ./scripts /app/scripts CMD ["fastapi", "run", "--workers", "4", "app/main.py"] diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 177dc1e..e803c77 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -17,19 +17,11 @@ def read_items( """ Retrieve items. """ - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() statement = select(Item).offset(skip).limit(limit) items = session.exec(statement).all() + count_statement = select(func.count()).select_from(Item) else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() statement = ( select(Item) .where(Item.owner_id == current_user.id) @@ -37,6 +29,12 @@ def read_items( .limit(limit) ) items = session.exec(statement).all() + count_statement = ( + select(func.count()) + .select_from(Item) + .where(Item.owner_id == current_user.id) + ) + count = session.exec(count_statement).one() return ItemsPublic(data=items, count=count) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818..e653758 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -38,13 +38,12 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: """ Retrieve users. """ + statement = select(User).offset(skip).limit(limit) + users = session.exec(statement).all() count_statement = select(func.count()).select_from(User) count = session.exec(count_statement).one() - statement = select(User).offset(skip).limit(limit) - users = session.exec(statement).all() - return UsersPublic(data=users, count=count) diff --git a/backend/app/api/routes/weather.py b/backend/app/api/routes/weather.py index 5ffc339..290a11e 100644 --- a/backend/app/api/routes/weather.py +++ b/backend/app/api/routes/weather.py @@ -1,196 +1,198 @@ -from datetime import datetime -from typing import Any, Optional, Dict +from typing import Any import httpx -from fastapi import APIRouter, HTTPException, Query, Depends +from fastapi import APIRouter, HTTPException, Query from pydantic import ValidationError -# Import directly from models file +from app.core.cache import cache_response, weather_cache from app.models import ( - WeatherApiError, - WeatherResponse, AirTemperatureResponse, - WindDirectionResponse, + FourDayForecastResponse, LightningResponse, - WBGTResponse, TwentyFourHourForecastResponse, - FourDayForecastResponse + WBGTResponse, + WeatherApiError, + WeatherResponse, + WindDirectionResponse, ) router = APIRouter(prefix="/weather", tags=["weather"]) WEATHER_API_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api" +_http_client: httpx.AsyncClient | None = None + + +async def get_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient(timeout=30.0) + return _http_client + async def make_api_request( - endpoint: str, - params: Dict[str, Any] = None, - response_model: Any = None + endpoint: str, + params: dict[str, Any] | None = None, + response_model: Any = None, ) -> Any: - """ - Generic function to make API requests to the weather API - """ url = f"{WEATHER_API_BASE_URL}/{endpoint}" - + try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(url, params=params) - - # Handle error responses - if response.status_code != 200: - try: - error_data = response.json() - error = WeatherApiError(**error_data) - raise HTTPException( - status_code=response.status_code, - detail=f"{error.name}: {error.error_msg}" - ) - except (ValidationError, KeyError): - raise HTTPException( - status_code=response.status_code, - detail=f"Error from weather API: {response.text}" - ) - - # Parse successful response + client = await get_http_client() + response = await client.get(url, params=params) + + if response.status_code != 200: try: - data = response.json() - - # Try parsing with the model - if response_model: - return response_model(**data) - return data - except ValidationError as e: - # If there's a validation error, provide detailed error information - print(f"Validation error parsing weather data: {str(e)}") - - # If the error is specifically about timestamp fields, try to fix - if 'update_timestamp' in str(e) or 'updated_timestamp' in str(e) or 'updatedTimestamp' in str(e): - # Try to manually fix the data structure - if 'data' in data: - # For standard items structure - if 'items' in data['data']: - for item in data['data']['items']: - if 'update_timestamp' in item and 'updated_timestamp' not in item: - item['updated_timestamp'] = item['update_timestamp'] - - # For forecast records structure (both 24-hour and 4-day) - if 'records' in data['data']: - for record in data['data']['records']: - # Handle updatedTimestamp field - if 'updatedTimestamp' in record and 'updated_timestamp' not in record: - record['updated_timestamp'] = record['updatedTimestamp'] - - # Handle possible "tiemstamp" typo in API that should be "timestamp" - if 'tiemstamp' in record and 'timestamp' not in record: - record['timestamp'] = record['tiemstamp'] - - # Try parsing again with the fixed data - try: - if response_model: - return response_model(**data) - return data - except ValidationError: - # If it still fails, fall back to the original error - pass - - # Handle field name mismatches for air temperature data - if 'device_id' in str(e) and 'Station' in str(e): - # API returns deviceId, but model expects device_id - if 'data' in data and 'stations' in data['data']: - for station in data['data']['stations']: - if 'deviceId' in station and 'device_id' not in station: - station['device_id'] = station['deviceId'] - - # Try parsing again with the fixed data - try: - if response_model: - return response_model(**data) - return data - except ValidationError as new_e: - # If there are other issues, continue with fixes - print(f"Still having validation issues after device_id fix: {str(new_e)}") - - # Handle 24-hour forecast structure issues - if 'TwentyFourHourForecast' in str(e): - # Handle missing area_metadata or other structural issues - if 'data' in data: - # If area_metadata is missing but required, provide an empty list - if 'area_metadata' not in data['data'] and 'records' in data['data']: - data['data']['area_metadata'] = [] - - # Try parsing again with the fixed data - try: - if response_model: - return response_model(**data) - return data - except ValidationError as new_e: - # If it still fails, continue with other fixes - print(f"Still having validation issues after 24-hour forecast fix: {str(new_e)}") - - # Handle readings issues (station_id -> stationId mapping and data flattening) - if 'station_id' in str(e) or 'value' in str(e) or 'Reading' in str(e) or 'stationId' in str(e): - if 'data' in data and 'readings' in data['data']: - for reading in data['data']['readings']: - # The API structure has timestamp at reading level and data array with stationId/value pairs - # but the frontend/older structure expects flattened readings - if 'data' in reading and isinstance(reading['data'], list): - # For air temperature, flatten the data structure - # Convert from: {"timestamp": "...", "data": [{"stationId": "S109", "value": 33}]} - # To: [{"station_id": "S109", "value": 33, "timestamp": "..."}] - flattened_data = [] - timestamp = reading.get('timestamp') - for data_point in reading['data']: - flattened_data.append({ - 'station_id': data_point.get('stationId', data_point.get('id')), - 'value': data_point.get('value'), - 'timestamp': timestamp - }) - # Replace the readings array with flattened structure - data['data']['readings'] = flattened_data - break # Only process first reading with nested data - elif 'id' in reading and 'station_id' not in reading: - reading['station_id'] = reading['id'] - - # Try parsing again with the fixed data - try: - if response_model: - return response_model(**data) - return data - except ValidationError as new_e: - # If it still fails, fall back to the original error - print(f"Still having validation issues after station_id fix: {str(new_e)}") - - # If we couldn't fix it, raise the original error + error_data = response.json() + error = WeatherApiError(**error_data) raise HTTPException( - status_code=500, - detail=f"Error parsing weather data: {str(e)}" + status_code=response.status_code, + detail=f"{error.name}: {error.error_msg}", ) - + except (ValidationError, KeyError): + raise HTTPException( + status_code=response.status_code, + detail=f"Error from weather API: {response.text}", + ) + + try: + data = response.json() + + if response_model: + return response_model(**data) + return data + except ValidationError as e: + print(f"Validation error parsing weather data: {str(e)}") + + if ( + "update_timestamp" in str(e) + or "updated_timestamp" in str(e) + or "updatedTimestamp" in str(e) + ): + if "data" in data: + if "items" in data["data"]: + for item in data["data"]["items"]: + if ( + "update_timestamp" in item + and "updated_timestamp" not in item + ): + item["updated_timestamp"] = item["update_timestamp"] + + if "records" in data["data"]: + for record in data["data"]["records"]: + if ( + "updatedTimestamp" in record + and "updated_timestamp" not in record + ): + record["updated_timestamp"] = record["updatedTimestamp"] + + if "tiemstamp" in record and "timestamp" not in record: + record["timestamp"] = record["tiemstamp"] + + try: + if response_model: + return response_model(**data) + return data + except ValidationError: + pass + + if "device_id" in str(e) and "Station" in str(e): + if "data" in data and "stations" in data["data"]: + for station in data["data"]["stations"]: + if "deviceId" in station and "device_id" not in station: + station["device_id"] = station["deviceId"] + + try: + if response_model: + return response_model(**data) + return data + except ValidationError as new_e: + print( + f"Still having validation issues after device_id fix: {str(new_e)}" + ) + + if "TwentyFourHourForecast" in str(e): + if "data" in data: + if ( + "area_metadata" not in data["data"] + and "records" in data["data"] + ): + data["data"]["area_metadata"] = [] + + try: + if response_model: + return response_model(**data) + return data + except ValidationError as new_e: + print( + f"Still having validation issues after 24-hour forecast fix: {str(new_e)}" + ) + + if ( + "station_id" in str(e) + or "value" in str(e) + or "Reading" in str(e) + or "stationId" in str(e) + ): + if "data" in data and "readings" in data["data"]: + for reading in data["data"]["readings"]: + if "data" in reading and isinstance(reading["data"], list): + flattened_data = [] + timestamp = reading.get("timestamp") + for data_point in reading["data"]: + flattened_data.append( + { + "station_id": data_point.get( + "stationId", data_point.get("id") + ), + "value": data_point.get("value"), + "timestamp": timestamp, + } + ) + data["data"]["readings"] = flattened_data + break + elif "id" in reading and "station_id" not in reading: + reading["station_id"] = reading["id"] + + try: + if response_model: + return response_model(**data) + return data + except ValidationError as new_e: + print( + f"Still having validation issues after station_id fix: {str(new_e)}" + ) + + raise HTTPException( + status_code=500, + detail=f"Error parsing weather data: {str(e)}", + ) + except httpx.RequestError as e: raise HTTPException( status_code=503, - detail=f"Error communicating with weather API: {str(e)}" + detail=f"Error communicating with weather API: {str(e)}", ) @router.get("/two-hour-forecast", response_model=WeatherResponse) +@cache_response(_ttl=1800, cache=weather_cache) async def get_two_hour_forecast( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Retrieve the latest two hour weather forecast from data.gov.sg API. - + - Updated half-hourly from NEA - Forecasts are given for multiple areas in Singapore """ - - # Build the URL and parameters params = {} if date: params["date"] = date @@ -201,22 +203,23 @@ async def get_two_hour_forecast( @router.get("/air-temperature", response_model=AirTemperatureResponse) +@cache_response(_ttl=60, cache=weather_cache) async def get_air_temperature( - date: Optional[str] = Query( + date: str | None = Query( None, description="Format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (SGT). Example: 2024-07-16 or 2024-07-16T23:59:00", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Get air temperature readings across Singapore - + - Has per-minute readings from NEA - Unit of measure for readings is °C """ - params = {} if date: params["date"] = date @@ -227,22 +230,23 @@ async def get_air_temperature( @router.get("/wind-direction", response_model=WindDirectionResponse) +@cache_response(_ttl=60, cache=weather_cache) async def get_wind_direction( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Get wind direction readings across Singapore - + - Has per-minute readings from NEA - Unit of measure for readings is ° """ - params = {} if date: params["date"] = date @@ -253,24 +257,23 @@ async def get_wind_direction( @router.get("/lightning", response_model=LightningResponse) +@cache_response(_ttl=300, cache=weather_cache) async def get_lightning( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Retrieve the latest lightning observation - + - Updated multiple times throughout the day """ - - params = { - "api": "lightning" # Required parameter for this endpoint - } + params = {"api": "lightning"} if date: params["date"] = date if pagination_token: @@ -280,25 +283,24 @@ async def get_lightning( @router.get("/wbgt", response_model=WBGTResponse) +@cache_response(_ttl=300, cache=weather_cache) async def get_wbgt( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Retrieve the latest WBGT (Wet Bulb Globe Temperature) data for accurate heat stress assessment - + - Updated multiple times throughout the day - Unit of measure for readings is °C """ - - params = { - "api": "wbgt" # Required parameter for this endpoint - } + params = {"api": "wbgt"} if date: params["date"] = date if pagination_token: @@ -308,52 +310,56 @@ async def get_wbgt( @router.get("/twenty-four-hour-forecast", response_model=TwentyFourHourForecastResponse) +@cache_response(_ttl=3600, cache=weather_cache) async def get_twenty_four_hour_forecast( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2024-07-16 or 2024-07-16T23:59:00", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Retrieve the latest 24 hour weather forecast - + - Updated multiple times throughout the day - Provides forecasts for different areas of Singapore """ - params = {} if date: params["date"] = date if pagination_token: params["paginationToken"] = pagination_token - return await make_api_request("twenty-four-hr-forecast", params, TwentyFourHourForecastResponse) + return await make_api_request( + "twenty-four-hr-forecast", params, TwentyFourHourForecastResponse + ) @router.get("/four-day-outlook", response_model=FourDayForecastResponse) +@cache_response(_ttl=7200, cache=weather_cache) async def get_four_day_outlook( - date: Optional[str] = Query( + date: str | None = Query( None, description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)", ), - pagination_token: Optional[str] = Query( - None, description="Pagination token for retrieving subsequent data pages" + pagination_token: str | None = Query( + None, + description="Pagination token for retrieving subsequent data pages", ), ) -> Any: """ Retrieve the latest 4 day weather forecast - + - Updated twice a day from NEA - The forecast is for the next 4 days """ - params = {} if date: params["date"] = date if pagination_token: params["paginationToken"] = pagination_token - return await make_api_request("four-day-outlook", params, FourDayForecastResponse) \ No newline at end of file + return await make_api_request("four-day-outlook", params, FourDayForecastResponse) diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 0000000..9e9be2c --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,53 @@ +import time +from collections import OrderedDict +from collections.abc import Callable +from functools import wraps +from typing import Any + + +class LRUCache: + def __init__(self, max_size: int = 128, ttl: int = 300): + self.max_size = max_size + self.ttl = ttl + self.cache: OrderedDict[str, tuple[Any, float]] = OrderedDict() + + def get(self, key: str) -> Any | None: + if key not in self.cache: + return None + value, timestamp = self.cache[key] + if time.time() - timestamp > self.ttl: + del self.cache[key] + return None + self.cache.move_to_end(key) + return value + + def set(self, key: str, value: Any) -> None: + if key in self.cache: + self.cache.move_to_end(key) + elif len(self.cache) >= self.max_size: + self.cache.popitem(last=False) + self.cache[key] = (value, time.time()) + + def clear(self) -> None: + self.cache.clear() + + +weather_cache = LRUCache(max_size=64, ttl=300) + + +def cache_response(*, _ttl: int = 300, cache: LRUCache | None = None): + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + target_cache = cache or weather_cache + cache_key = f"{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}" + cached = target_cache.get(cache_key) + if cached is not None: + return cached + result = await func(*args, **kwargs) + target_cache.set(cache_key, result) + return result + + return wrapper + + return decorator diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb..19b2cb5 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -4,7 +4,14 @@ from app import crud from app.core.config import settings from app.models import User, UserCreate -engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +engine = create_engine( + str(settings.SQLALCHEMY_DATABASE_URI), + pool_size=20, + max_overflow=10, + pool_timeout=30, + pool_recycle=3600, + pool_pre_ping=True, +) # make sure all SQLModel models are imported (app.models) before initializing DB diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801..2684088 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +from contextlib import asynccontextmanager + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -7,6 +9,15 @@ from app.api.main import api_router from app.core.config import settings +@asynccontextmanager +async def lifespan(_app: FastAPI): + from app.api.routes.weather import _http_client + + yield + if _http_client and not _http_client.is_closed: + await _http_client.aclose() + + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" @@ -18,6 +29,7 @@ app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + lifespan=lifespan, ) # Set all CORS enabled origins diff --git a/backend/app/models.py b/backend/app/models.py index 4e336c3..6e6d995 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,9 +1,10 @@ import uuid from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any -from pydantic import EmailStr, BaseModel, Field, model_validator -from sqlmodel import Field as SQLModelField, Relationship, SQLModel +from pydantic import BaseModel, EmailStr, Field +from sqlmodel import Field as SQLModelField +from sqlmodel import Relationship, SQLModel # Shared properties @@ -119,7 +120,7 @@ class NewPassword(SQLModel): class LabelLocation(BaseModel): latitude: float longitude: float - + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -128,7 +129,7 @@ class LabelLocation(BaseModel): class AreaMetadata(BaseModel): name: str label_location: LabelLocation - + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -138,7 +139,7 @@ class ForecastPeriod(BaseModel): start: datetime end: datetime text: str - + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -147,7 +148,7 @@ class ForecastPeriod(BaseModel): class Forecast(BaseModel): area: str forecast: str - + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -157,19 +158,19 @@ class WeatherItem(BaseModel): updated_timestamp: datetime = Field(alias="update_timestamp") timestamp: datetime valid_period: ForecastPeriod - forecasts: List[Forecast] - + forecasts: list[Forecast] + model_config = { "extra": "ignore", # Ignore extra fields in API response - "populate_by_name": True # Allow populating by field name or alias + "populate_by_name": True, # Allow populating by field name or alias } class WeatherData(BaseModel): - area_metadata: List[AreaMetadata] - items: List[WeatherItem] - pagination_token: Optional[str] = None - + area_metadata: list[AreaMetadata] + items: list[WeatherItem] + pagination_token: str | None = None + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -177,21 +178,21 @@ class WeatherData(BaseModel): class WeatherResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[Dict[str, Any]] = None - + error_msg: str | None = Field(None, alias="errorMsg") + data: dict[str, Any] | None = None + model_config = { "extra": "ignore", # Ignore extra fields in API response - "populate_by_name": True # Allow populating by field name or alias + "populate_by_name": True, # Allow populating by field name or alias } class WeatherApiError(BaseModel): code: int name: str - data: Optional[Dict[str, Any]] = None + data: dict[str, Any] | None = None error_msg: str = Field(alias="errorMsg") - + model_config = { "extra": "ignore" # Ignore extra fields in API response } @@ -200,13 +201,13 @@ class WeatherApiError(BaseModel): # Models for air temperature readings class Station(BaseModel): id: str - device_id: Optional[str] = Field(None, alias="deviceId") # API returns deviceId + device_id: str | None = Field(None, alias="deviceId") # API returns deviceId name: str - location: Dict[str, float] # Contains latitude and longitude - + location: dict[str, float] # Contains latitude and longitude + model_config = { "extra": "ignore", # Ignore extra fields in API response - "populate_by_name": True # Allow both snake_case and camelCase + "populate_by_name": True, # Allow both snake_case and camelCase } @@ -214,70 +215,52 @@ class Station(BaseModel): class ReadingDataPoint(BaseModel): station_id: str = Field(alias="stationId") value: float - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + + model_config = {"extra": "ignore", "populate_by_name": True} class Reading(BaseModel): timestamp: datetime - data: List[ReadingDataPoint] - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + data: list[ReadingDataPoint] + + model_config = {"extra": "ignore", "populate_by_name": True} class AirTemperatureData(BaseModel): - stations: List[Station] - readings: List[Reading] + stations: list[Station] + readings: list[Reading] reading_type: str = Field(alias="readingType") reading_unit: str = Field(alias="readingUnit") - pagination_token: Optional[str] = Field(None, alias="paginationToken") - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + pagination_token: str | None = Field(None, alias="paginationToken") + + model_config = {"extra": "ignore", "populate_by_name": True} class AirTemperatureResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[AirTemperatureData] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + error_msg: str | None = Field(None, alias="errorMsg") + data: AirTemperatureData | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} # Models for wind direction readings class WindDirectionData(BaseModel): - stations: List[Station] - readings: List[Reading] + stations: list[Station] + readings: list[Reading] reading_type: str = Field(alias="readingType") reading_unit: str = Field(alias="readingUnit") - pagination_token: Optional[str] = Field(None, alias="paginationToken") - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + pagination_token: str | None = Field(None, alias="paginationToken") + + model_config = {"extra": "ignore", "populate_by_name": True} class WindDirectionResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[WindDirectionData] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + error_msg: str | None = Field(None, alias="errorMsg") + data: WindDirectionData | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} # Models for lightning observation @@ -285,73 +268,55 @@ class LightningItem(BaseModel): # Generic item model for lightning data # The actual structure can be expanded based on the real API response pass - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class LightningRecord(BaseModel): datetime: str - item: Dict[str, Any] # Flexible structure to accommodate different data formats - updated_timestamp: Optional[datetime] = Field(None) - - model_config = { - "extra": "ignore" - } + item: dict[str, Any] # Flexible structure to accommodate different data formats + updated_timestamp: datetime | None = Field(None) + + model_config = {"extra": "ignore"} class LightningData(BaseModel): - records: List[LightningRecord] - pagination_token: Optional[str] = Field(None, alias="paginationToken") - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + records: list[LightningRecord] + pagination_token: str | None = Field(None, alias="paginationToken") + + model_config = {"extra": "ignore", "populate_by_name": True} class LightningResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[LightningData] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + error_msg: str | None = Field(None, alias="errorMsg") + data: LightningData | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} # Models for WBGT (Wet Bulb Globe Temperature) observations class WBGTRecord(BaseModel): datetime: str - item: Dict[str, Any] # Flexible structure to accommodate different data formats - updated_timestamp: Optional[datetime] = Field(None) - - model_config = { - "extra": "ignore" - } + item: dict[str, Any] # Flexible structure to accommodate different data formats + updated_timestamp: datetime | None = Field(None) + + model_config = {"extra": "ignore"} class WBGTData(BaseModel): - records: List[WBGTRecord] - pagination_token: Optional[str] = Field(None, alias="paginationToken") - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + records: list[WBGTRecord] + pagination_token: str | None = Field(None, alias="paginationToken") + + model_config = {"extra": "ignore", "populate_by_name": True} class WBGTResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[WBGTData] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + error_msg: str | None = Field(None, alias="errorMsg") + data: WBGTData | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} # Models for 24-hour weather forecast @@ -359,48 +324,38 @@ class ForecastPeriodGeneral(BaseModel): start: datetime end: datetime text: str - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class TemperatureRange(BaseModel): low: int high: int unit: str - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class HumidityRange(BaseModel): low: int high: int unit: str - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class ForecastInfo(BaseModel): - code: Optional[str] = None + code: str | None = None text: str - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class WindInfo(BaseModel): - speed: Dict[str, Any] + speed: dict[str, Any] direction: str - - model_config = { - "extra": "ignore" - } + + model_config = {"extra": "ignore"} class GeneralForecast(BaseModel): @@ -409,11 +364,8 @@ class GeneralForecast(BaseModel): relative_humidity: HumidityRange = Field(alias="relativeHumidity") forecast: ForecastInfo wind: WindInfo - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + + model_config = {"extra": "ignore", "populate_by_name": True} class ForecastItem(BaseModel): @@ -421,34 +373,25 @@ class ForecastItem(BaseModel): updated_timestamp: datetime = Field(alias="updatedTimestamp") timestamp: datetime general: GeneralForecast - periods: Optional[List[Dict[str, Any]]] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + periods: list[dict[str, Any]] | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} class TwentyFourHourForecastData(BaseModel): - area_metadata: Optional[List[AreaMetadata]] = Field(default_factory=list) - records: List[ForecastItem] - pagination_token: Optional[str] = Field(None, alias="paginationToken") - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + area_metadata: list[AreaMetadata] | None = Field(default_factory=list) + records: list[ForecastItem] + pagination_token: str | None = Field(None, alias="paginationToken") + + model_config = {"extra": "ignore", "populate_by_name": True} class TwentyFourHourForecastResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[TwentyFourHourForecastData] = None - - model_config = { - "extra": "ignore", - "populate_by_name": True - } + error_msg: str | None = Field(None, alias="errorMsg") + data: TwentyFourHourForecastData | None = None + + model_config = {"extra": "ignore", "populate_by_name": True} # Models for 4-day weather forecast @@ -456,30 +399,30 @@ class FourDayForecastItem(BaseModel): date: str updated_timestamp: datetime = Field(alias="updatedTimestamp") timestamp: datetime - forecasts: List[Dict[str, Any]] # Flexible structure for the forecasts - + forecasts: list[dict[str, Any]] # Flexible structure for the forecasts + model_config = { "extra": "ignore", - "populate_by_name": True # Allow both snake_case and camelCase + "populate_by_name": True, # Allow both snake_case and camelCase } class FourDayForecastData(BaseModel): - records: List[FourDayForecastItem] - pagination_token: Optional[str] = Field(None, alias="paginationToken") - + records: list[FourDayForecastItem] + pagination_token: str | None = Field(None, alias="paginationToken") + model_config = { "extra": "ignore", - "populate_by_name": True # Allow both snake_case and camelCase + "populate_by_name": True, # Allow both snake_case and camelCase } class FourDayForecastResponse(BaseModel): code: int - error_msg: Optional[str] = Field(None, alias="errorMsg") - data: Optional[FourDayForecastData] = None - + error_msg: str | None = Field(None, alias="errorMsg") + data: FourDayForecastData | None = None + model_config = { "extra": "ignore", - "populate_by_name": True # Allow both snake_case and camelCase + "populate_by_name": True, # Allow both snake_case and camelCase } diff --git a/backend/app/tests/scripts/test_backend_pre_start.py b/backend/app/tests/scripts/test_backend_pre_start.py index 631690f..0591da0 100644 --- a/backend/app/tests/scripts/test_backend_pre_start.py +++ b/backend/app/tests/scripts/test_backend_pre_start.py @@ -24,10 +24,10 @@ def test_init_successful_connection() -> None: except Exception: connection_successful = False - assert ( - connection_successful - ), "The database connection should be successful and not raise an exception." + assert connection_successful, ( + "The database connection should be successful and not raise an exception." + ) - assert session_mock.exec.called_once_with( - select(1) - ), "The session should execute a select statement once." + assert session_mock.exec.called_once_with(select(1)), ( + "The session should execute a select statement once." + ) diff --git a/backend/app/tests/scripts/test_test_pre_start.py b/backend/app/tests/scripts/test_test_pre_start.py index a176f38..a2ead3c 100644 --- a/backend/app/tests/scripts/test_test_pre_start.py +++ b/backend/app/tests/scripts/test_test_pre_start.py @@ -24,10 +24,10 @@ def test_init_successful_connection() -> None: except Exception: connection_successful = False - assert ( - connection_successful - ), "The database connection should be successful and not raise an exception." + assert connection_successful, ( + "The database connection should be successful and not raise an exception." + ) - assert session_mock.exec.called_once_with( - select(1) - ), "The session should execute a select statement once." + assert session_mock.exec.called_once_with(select(1)), ( + "The session should execute a select statement once." + ) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index abba147..84af863 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,9 +7,10 @@ ARG VITE_API_URL ARG NODE_ENV=production ENV VITE_API_URL=${VITE_API_URL} -COPY package*.json /app/ +COPY package.json package-lock.json /app/ -RUN NODE_ENV=development npm ci +RUN --mount=type=cache,target=/root/.npm \ + npm ci --cache=/root/.npm ENV NODE_ENV=${NODE_ENV} diff --git a/frontend/Dockerfile.playwright b/frontend/Dockerfile.playwright index 4498c37..7f3370a 100644 --- a/frontend/Dockerfile.playwright +++ b/frontend/Dockerfile.playwright @@ -2,9 +2,10 @@ FROM node:24 WORKDIR /app -COPY package*.json /app/ +COPY package.json package-lock.json /app/ -RUN npm install +RUN --mount=type=cache,target=/root/.npm \ + npm ci --cache=/root/.npm RUN npx -y playwright install --with-deps