diff --git a/pyproject.toml b/pyproject.toml index 55f8035..233a337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,10 @@ dependencies = [ "numpy >= 1.25.0", "sentry-sdk >= 2.1.1", "pyhocon>=0.3.61", + "sqlalchemy>=2.0.44", "apitally[fastapi]>=0.22.3", "auth0-fastapi-api>=1.0.0b5", + "fastapi-cache2[memcache]>=0.2.2", ] [dependency-groups] @@ -46,11 +48,13 @@ dev = [ "types-pytz>=2025.2.0.20251108", "pandas-stubs>=2.3.2.250926", "pytest-asyncio>=1.3.0", + "freezegun>=1.5.5", ] [tool.uv.sources] dp-sdk = { url = "https://github.com/openclimatefix/data-platform/releases/download/v0.18.2/dp_sdk-0.18.2-py3-none-any.whl" } + [project.urls] repository = "https://github.com/openclimatefix/quartz-api" diff --git a/src/quartz_api/cmd/main.py b/src/quartz_api/cmd/main.py index 3903f96..cdf8615 100644 --- a/src/quartz_api/cmd/main.py +++ b/src/quartz_api/cmd/main.py @@ -16,6 +16,8 @@ from fastapi import FastAPI, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend from grpclib.client import Channel from pydantic import BaseModel from pyhocon import ConfigFactory, ConfigTree @@ -29,6 +31,7 @@ from ._logging import setup_json_logging log = logging.getLogger(__name__) +# set hpack to warning log level logging.getLogger("hpack").setLevel(logging.WARNING) static_dir = pathlib.Path(__file__).parent.parent / "static" @@ -67,7 +70,6 @@ def _custom_openapi(server: FastAPI) -> dict[str, Any]: return openapi_schema - @asynccontextmanager async def _lifespan(server: FastAPI, conf: ConfigTree) -> Generator[None]: """Configure FastAPI app instance with startup and shutdown events.""" @@ -120,6 +122,9 @@ def _create_server(conf: ConfigTree) -> FastAPI: }, ) + # set up cache + FastAPICache.init(InMemoryBackend(), expire=120, prefix="fastapi-cache") + # Add the default routes server.mount("/static", StaticFiles(directory=static_dir.as_posix()), name="static") diff --git a/src/quartz_api/internal/backends/dataplatform/client.py b/src/quartz_api/internal/backends/dataplatform/client.py index 5f020f7..52fd757 100644 --- a/src/quartz_api/internal/backends/dataplatform/client.py +++ b/src/quartz_api/internal/backends/dataplatform/client.py @@ -1,5 +1,6 @@ """A data platform implementation that conforms to the DatabaseInterface.""" +import asyncio import datetime as dt import logging from struct import Struct @@ -9,6 +10,7 @@ from fastapi import HTTPException from typing_extensions import override +# from quartz_api.internal.models import ForecastHorizon, Region from quartz_api.internal import models from quartz_api.internal.middleware.auth import get_oauth_id_from_sub @@ -36,14 +38,22 @@ async def get_predicted_solar_power_production_for_location( forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, + forecaster_name: str | None = None, + start_datetime: dt.datetime | None = None, + end_datetime: dt.datetime | None = None, + created_before_datetime: dt.datetime | None = None, ) -> list[models.PredictedPower]: values = await self._get_predicted_power_production_for_location( - location_uuid=UUID(location), + location_uuid=location, energy_source=dp.EnergySource.SOLAR, forecast_horizon=forecast_horizon, forecast_horizon_minutes=forecast_horizon_minutes, smooth_flag=smooth_flag, oauth_id=None, + forecaster_name=forecaster_name, + start_datetime=start_datetime, + end_datetime=end_datetime, + created_before_datetime=created_before_datetime, ) return values @@ -56,7 +66,7 @@ async def get_predicted_wind_power_production_for_location( smooth_flag: bool = True, ) -> list[models.PredictedPower]: values = await self._get_predicted_power_production_for_location( - location_uuid=UUID(location), + location_uuid=location, energy_source=dp.EnergySource.WIND, forecast_horizon=forecast_horizon, forecast_horizon_minutes=forecast_horizon_minutes, @@ -69,11 +79,17 @@ async def get_predicted_wind_power_production_for_location( async def get_actual_solar_power_production_for_location( self, location: str, + observer_name: str | None = None, + start_datetime: dt.datetime | None = None, + end_datetime: dt.datetime | None = None, ) -> list[models.ActualPower]: values = await self._get_actual_power_production_for_location( - UUID(location), + location, dp.EnergySource.SOLAR, oauth_id=None, + observer_name=observer_name, + start_datetime=start_datetime, + end_datetime=end_datetime, ) return values @@ -83,7 +99,7 @@ async def get_actual_wind_power_production_for_location( location: str, ) -> list[models.ActualPower]: values = await self._get_actual_power_production_for_location( - UUID(location), + location, dp.EnergySource.WIND, oauth_id=None, ) @@ -101,6 +117,7 @@ async def get_wind_regions(self) -> list[str]: @override async def get_solar_regions(self, type: str | None = None) -> list[models.Region]: + location_type_filter = dp.LocationType.STATE if type == "nation": location_type_filter = dp.LocationType.NATION @@ -121,11 +138,11 @@ async def get_solar_regions(self, type: str | None = None) -> list[models.Region "location_uuid": loc.location_uuid, "effective_capacity_watts": loc.effective_capacity_watts, **dict(struct_to_dict(loc.metadata)), + }, ) regions.append(region) return regions - return [loc.location_uuid for loc in resp.locations] @override async def get_sites(self, authdata: dict[str, str]) -> list[models.Site]: @@ -318,11 +335,48 @@ async def get_substation( ) + async def get_forecast_metadata( + self, + location_uuid: str, + authdata: dict[str, str], #noqa: ARG002 + model_name: str | None = None, + ) -> models.ForecastMetadata: + """Get forecast metadata for a site.""" + req = dp.GetLatestForecastsRequest( + location_uuid=location_uuid, + energy_source=dp.EnergySource.SOLAR, + ) + resp = await self.dp_client.get_latest_forecasts(req) + + # Filter by model name if provided + if model_name: + resp.forecasts = [ + forecast for forecast in resp.forecasts + if forecast.forecaster.forecaster_name == model_name + ] + + resp.forecasts.sort( + key=lambda f: f.created_timestamp_utc, + reverse=True, + ) + forecasts = resp.forecasts[0] + + return models.ForecastMetadata( + initialization_timestamp_utc=forecasts.initialization_timestamp_utc, + created_timestamp_utc=forecasts.created_timestamp_utc, + forecaster_name=forecasts.forecaster.forecaster_name, + forecaster_version=forecasts.forecaster.forecaster_version, + ) + + async def _get_actual_power_production_for_location( self, location_uuid: UUID, energy_source: dp.EnergySource, oauth_id: str | None, + observer_name: str = "ruvnl", + start_datetime: dt.datetime | None = None, + end_datetime: dt.datetime | None = None, traceid: str = "unknown", ) -> list[models.ActualPower]: """Local function to retrieve actual values regardless of energy type.""" @@ -335,10 +389,10 @@ async def _get_actual_power_production_for_location( traceid, ) - start, end = get_window() + start, end = get_window(start=start_datetime, end=end_datetime) req = dp.GetObservationsAsTimeseriesRequest( location_uuid=location_uuid, - observer_name="ruvnl", + observer_name=observer_name, energy_source=energy_source, time_window=dp.TimeWindow( start_timestamp_utc=start, @@ -367,6 +421,10 @@ async def _get_predicted_power_production_for_location( forecast_horizon: models.ForecastHorizon = models.ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, # noqa: ARG002 + forecaster_name: str | None = None, + start_datetime: dt.datetime | None = None, + end_datetime: dt.datetime | None = None, + created_before_datetime: dt.datetime | None = None, traceid: str = "unknown", ) -> list[models.PredictedPower]: """Local function to retrieve predicted values regardless of energy type.""" @@ -379,7 +437,7 @@ async def _get_predicted_power_production_for_location( traceid, ) - start, end = get_window() + start, end = get_window(start=start_datetime, end=end_datetime) if forecast_horizon == models.ForecastHorizon.latest or forecast_horizon_minutes is None: forecast_horizon_minutes = 0 @@ -389,22 +447,28 @@ async def _get_predicted_power_production_for_location( # from my asking around at least forecast_horizon_minutes = 9 * 60 - # Use the forecaster that produced the most recent forecast for the location by default, - # taking into account the desired horizon. - # * At some point, we may want to allow the user to specify a particular forecaster. - req = dp.GetLatestForecastsRequest( - location_uuid=location_uuid, - energy_source=energy_source, - pivot_timestamp_utc=start - dt.timedelta(minutes=forecast_horizon_minutes), - ) - resp = await self.dp_client.get_latest_forecasts(req, metadata={"traceid": traceid}) - if len(resp.forecasts) == 0: - return [] - resp.forecasts.sort( - key=lambda f: f.created_timestamp_utc, - reverse=True, - ) - forecaster = resp.forecasts[0].forecaster + if forecaster_name is None: + # Use the forecaster that produced the most recent forecast for the location by default, + # taking into account the desired horizon. + # * At some point, we may want to allow the user to specify a particular forecaster. + req = dp.GetLatestForecastsRequest( + location_uuid=location_uuid, + energy_source=energy_source, + pivot_timestamp_utc=start - dt.timedelta(minutes=forecast_horizon_minutes), + ) + resp = await self.dp_client.get_latest_forecasts(req, metadata={"traceid": traceid}) + if len(resp.forecasts) == 0: + return [] + resp.forecasts.sort( + key=lambda f: f.created_timestamp_utc, + reverse=True, + ) + forecaster = resp.forecasts[0].forecaster + else: + req = dp.ListForecastersRequest(forecaster_names_filter=[forecaster_name], + latest_versions_only=True) + resp = await self.dp_client.list_forecasters(req, metadata={"traceid": traceid}) + forecaster = resp.forecasters[0] req = dp.GetForecastAsTimeseriesRequest( location_uuid=location_uuid, @@ -415,6 +479,7 @@ async def _get_predicted_power_production_for_location( end_timestamp_utc=end, ), forecaster=forecaster, + pivot_timestamp_utc=created_before_datetime, ) resp = await self.dp_client.get_forecast_as_timeseries(req, metadata={"traceid": traceid}) @@ -423,6 +488,13 @@ async def _get_predicted_power_production_for_location( time=value.target_timestamp_utc, power_kW=int(value.effective_capacity_watts * value.p50_value_fraction / 1000.0), created_time=value.created_timestamp_utc, + plevel_kW={ + "p10": int(value.effective_capacity_watts \ + * value.other_statistics_fractions["p10"] / 1000.0), + "p90": int(value.effective_capacity_watts \ + * value.other_statistics_fractions["p90"] / 1000.0), + } if "p10" in value.other_statistics_fractions + and "p90" in value.other_statistics_fractions else {}, ) for value in resp.values ] @@ -459,6 +531,7 @@ async def get_forecast_for_multiple_locations_one_timestamp( authdata: dict[str, str], datetime_utc: dt.datetime, model_name: str = "blend", + ) -> list[models.OneDatetimeManyForecastValues]: """Get a forecast for multiple sites. @@ -472,12 +545,12 @@ async def get_forecast_for_multiple_locations_one_timestamp( A list of OneDatetimeManyForecastValues objects. """ # get forecasters" + req = dp.ListForecastersRequest(forecaster_names_filter=[model_name], latest_versions_only=True) resp = await self.dp_client.list_forecasters(req) forecaster = resp.forecasters[0] - req = dp.GetForecastAtTimestampRequest( location_uuids=location_uuids, energy_source=dp.EnergySource.SOLAR, @@ -502,6 +575,133 @@ async def get_forecast_for_multiple_locations_one_timestamp( return forecasts_one_timestamp + @override + async def get_forecast_for_multiple_locations( + self, + location_uuids_to_location_ids: dict[str, int], + authdata: dict[str, str], + start_datetime_utc: dt.datetime | None = None, + end_datetime_utc: dt.datetime | None = None, + model_name: str | None = None, + ) -> list[models.OneDatetimeManyForecastValuesMW, +]: + """Get a forecast for multiple sites. + + Args: + location_uuids_to_location_ids: A mapping from location UUIDs to location IDs. + authdata: Authentication data for the user. + start_datetime_utc: The start datetime for the prediction window. Default is None. + end_datetime_utc: The end datetime for the prediction window. Default is None. + model_name: The name of the forecasting model to use. Default is None. + + Returns: + A list of OneDatetimeManyForecastValuesMW objects. + """ + start, end = get_window(start=start_datetime_utc, end=end_datetime_utc) + + # timestamps 30 mins apart from start to end + n_half_hours = int((((end - start).total_seconds() // 60) // 30) + 1) + timestamps = [start + dt.timedelta(minutes=30 * x) for x in range(n_half_hours)] + + # get forecasters + req = dp.ListForecastersRequest(forecaster_names_filter=[model_name], + latest_versions_only=True) + resp = await self.dp_client.list_forecasters(req) + forecaster = resp.forecasters[0] + + forecasts_per_timestamp = [] + tasks = [] + for timestamp in timestamps: + req = dp.GetForecastAtTimestampRequest( + location_uuids=list(location_uuids_to_location_ids.keys()), + energy_source=dp.EnergySource.SOLAR, + timestamp_utc=timestamp, + forecaster=forecaster, + ) + # resp = await self.dp_client.get_forecast_at_timestamp(req) + task = asyncio.create_task(self.dp_client.get_forecast_at_timestamp(req)) + tasks.append(task) + list_results = await asyncio.gather(*tasks, return_exceptions=True) + for exc in filter(lambda x: isinstance(x, Exception), list_results): + raise exc + + for resp in list_results: + + if len(resp.values) == 0: + continue + + forecasts_one_timestamp = models.OneDatetimeManyForecastValuesMW( + datetime_utc=resp.timestamp_utc, + forecast_values={ + location_uuids_to_location_ids[forecast.location_uuid]: round( + forecast.value_fraction * forecast.effective_capacity_watts / 10**6, 2) + for forecast in resp.values + }) + + # sort by dictionary by keys + forecasts_one_timestamp.forecast_values =\ + dict(sorted(forecasts_one_timestamp.forecast_values.items())) + + forecasts_per_timestamp.append(forecasts_one_timestamp) + + return forecasts_per_timestamp + + @override + async def get_generation_for_multiple_locations( + self, + location_uuids_to_location_ids: dict[str, int], + authdata: dict[str, str], + start_datetime: dt.datetime | None = None, + end_datetime: dt.datetime | None = None, + observer_name: str = "ruvnl", + ) -> list[models.GSPYieldGroupByDatetime]: + """Get a forecast for multiple sites.""" + start, end = get_window(start=start_datetime, end=end_datetime) + + tasks = [] + for location_uuid in location_uuids_to_location_ids: + req = dp.GetObservationsAsTimeseriesRequest( + location_uuid=location_uuid, + observer_name=observer_name, + energy_source=dp.EnergySource.SOLAR, + time_window=dp.TimeWindow( + start_timestamp_utc=start, + end_timestamp_utc=end, + ), + ) + task = asyncio.create_task(self.dp_client.get_observations_as_timeseries(req)) + tasks.append(task) + # observation = await self.dp_client.get_observations_as_timeseries(req) + # observations.append(observation) + + list_results = await asyncio.gather(*tasks, return_exceptions=True) + for exc in filter(lambda x: isinstance(x, Exception), list_results): + raise exc + + # Combine results into GSPYieldGroupByDatetime + observations_by_datetime = {} + for observation in list_results: + + location_id = location_uuids_to_location_ids[observation.location_uuid] + + for value in observation.values: + timestamp = value.timestamp_utc + if timestamp not in observations_by_datetime: + # make a dictionary generation_kw_by_gsp_id + observations_by_datetime[timestamp] = {} + + generation_kw = int(value.effective_capacity_watts * value.value_fraction / 1000.0) + observations_by_datetime[timestamp][location_id] = generation_kw + + # format to list of GSPYieldGroupByDatetime + observations_by_datetime_formated = [ + models.GSPYieldGroupByDatetime( + datetime_utc=timestamp, + generation_kw_by_gsp_id=dict(sorted(generation_kw_by_gsp_id.items())), + ) + for timestamp, generation_kw_by_gsp_id in observations_by_datetime.items() + ] + return observations_by_datetime_formated def struct_to_dict(values:Struct) -> dict: """Converts a Struct to a dictionary.""" @@ -516,3 +716,4 @@ def struct_to_dict(values:Struct) -> dict: d[key] = str(value["stringValue"]) return d + diff --git a/src/quartz_api/internal/backends/dummydb/client.py b/src/quartz_api/internal/backends/dummydb/client.py index 7ef7919..4e786e8 100644 --- a/src/quartz_api/internal/backends/dummydb/client.py +++ b/src/quartz_api/internal/backends/dummydb/client.py @@ -219,6 +219,18 @@ async def get_substation_forecast( return values + @override + async def get_forecast_metadata() -> models.ForecastMetadata: + raise NotImplementedError() + + @override + async def get_generation_for_multiple_locations() -> list[models.GSPYieldGroupByDatetime]: + raise NotImplementedError() + + @override + async def get_forecast_for_multiple_locations() -> list[models.OneDatetimeManyForecastValues]: + raise NotImplementedError() + @override async def get_forecast_for_multiple_locations_one_timestamp( self, @@ -229,6 +241,7 @@ async def get_forecast_for_multiple_locations_one_timestamp( raise NotImplementedError("DummyDB client does not support multi-location forecasts.") + def _basicSolarPowerProductionFunc( timeUnix: int, scaleFactor: int = 10000, diff --git a/src/quartz_api/internal/backends/quartzdb/client.py b/src/quartz_api/internal/backends/quartzdb/client.py index 39c80ab..f263604 100644 --- a/src/quartz_api/internal/backends/quartzdb/client.py +++ b/src/quartz_api/internal/backends/quartzdb/client.py @@ -449,6 +449,17 @@ async def get_substation( ) -> models.SubstationProperties: raise NotImplementedError("QuartzDB backend does not support substations") + @override + async def get_forecast_metadata() -> models.ForecastMetadata: + raise NotImplementedError() + + @override + async def get_generation_for_multiple_locations() -> list[models.GSPYieldGroupByDatetime]: + raise NotImplementedError() + + @override + async def get_forecast_for_multiple_locations() -> list[models.OneDatetimeManyForecastValues]: + raise NotImplementedError() @override async def get_forecast_for_multiple_locations_one_timestamp( @@ -460,6 +471,7 @@ async def get_forecast_for_multiple_locations_one_timestamp( raise NotImplementedError("QuartzDB client does not support multi-location forecasts.") + def check_user_has_access_to_site( session: Session, email: str, diff --git a/src/quartz_api/internal/backends/test_utils.py b/src/quartz_api/internal/backends/test_utils.py new file mode 100644 index 0000000..ffacb14 --- /dev/null +++ b/src/quartz_api/internal/backends/test_utils.py @@ -0,0 +1,27 @@ +import datetime as dt + +from freezegun import freeze_time + +from quartz_api.internal.backends.utils import get_window + + +def test_get_window_defaults() -> None: + with freeze_time("2023-01-01"): + start, end = get_window() + assert start == dt.datetime(2022, 12, 30, tzinfo=dt.UTC) + assert end == dt.datetime(2023, 1, 3, tzinfo=dt.UTC) + + +def test_get_window_with_params() -> None: + custom_start = dt.datetime(2023, 2, 1, 12, tzinfo=dt.UTC) + custom_end = dt.datetime(2023, 2, 5, 12, tzinfo=dt.UTC) + start, end = get_window(start=custom_start, end=custom_end) + assert start == custom_start + assert end == custom_end + + +def test_get_window_with_partial_params() -> None: + custom_start = dt.datetime(2023, 3, 1, 8, tzinfo=dt.UTC) + start, end = get_window(start=custom_start) + assert start == custom_start + assert end == custom_start + dt.timedelta(days=4) diff --git a/src/quartz_api/internal/backends/utils.py b/src/quartz_api/internal/backends/utils.py index 98ec456..35230de 100644 --- a/src/quartz_api/internal/backends/utils.py +++ b/src/quartz_api/internal/backends/utils.py @@ -2,21 +2,40 @@ import datetime as dt +import numpy as np -def get_window() -> tuple[dt.datetime, dt.datetime]: + +def get_window( + start: dt.datetime | None = None, end: dt.datetime | None = None, +) -> tuple[dt.datetime, dt.datetime]: """Returns the start and end of the window for timeseries data.""" # Window start is the beginning of the day two days ago - start = (dt.datetime.now(tz=dt.UTC) - dt.timedelta(days=2)).replace( - hour=0, - minute=0, - second=0, - microsecond=0, - ) + if start is None: + start = (dt.datetime.now(tz=dt.UTC) - dt.timedelta(days=2)) + start = floor_6_hours_dt(start) + # Window end is the beginning of the day two days ahead - end = (dt.datetime.now(tz=dt.UTC) + dt.timedelta(days=2)).replace( - hour=0, - minute=0, - second=0, - microsecond=0, - ) + if end is None: + end = start + dt.timedelta(days=4) + return (start, end) + + +def floor_6_hours_dt(ts: dt.datetime) -> dt.datetime: + """Floor a datetime by 6 hours. + + For example: + 2021-01-01 17:01:01 --> 2021-01-01 12:00:00 + 2021-01-01 19:35:01 --> 2021-01-01 18:00:00 + + :param dt: datetime + :return: datetime rounded to lowest 6 hours + """ + approx = np.floor(ts.hour / 6.0) * 6.0 + ts = ts.replace(hour=0) + ts = ts.replace(minute=0) + ts = ts.replace(second=0) + ts = ts.replace(microsecond=0) + ts += dt.timedelta(hours=approx) + + return ts diff --git a/src/quartz_api/internal/models/__init__.py b/src/quartz_api/internal/models/__init__.py index 6cd1dec..5b0381b 100644 --- a/src/quartz_api/internal/models/__init__.py +++ b/src/quartz_api/internal/models/__init__.py @@ -9,8 +9,12 @@ ActualPower, ForecastHorizon, PredictedPower, - OneDatetimeManyForecastValues, Region, + ForecastMetadata, + GSPYieldGroupByDatetime, + OneDatetimeManyForecastValues, + OneDatetimeManyForecastValuesMW, + SiteProperties, Site, SubstationProperties, diff --git a/src/quartz_api/internal/models/db_interface.py b/src/quartz_api/internal/models/db_interface.py index 3ecb188..4652c72 100644 --- a/src/quartz_api/internal/models/db_interface.py +++ b/src/quartz_api/internal/models/db_interface.py @@ -10,7 +10,10 @@ from .endpoint_types import ( ActualPower, ForecastHorizon, + ForecastMetadata, + GSPYieldGroupByDatetime, OneDatetimeManyForecastValues, + OneDatetimeManyForecastValuesMW, PredictedPower, Region, Site, @@ -30,7 +33,10 @@ async def get_predicted_solar_power_production_for_location( forecast_horizon: ForecastHorizon = ForecastHorizon.latest, forecast_horizon_minutes: int | None = None, smooth_flag: bool = True, - model_name: str | None = None, + forecaster_name: str | None = None, + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, + created_before_datetime: datetime | None = None, ) -> list[PredictedPower]: """Returns a list of predicted solar power production for a given location. @@ -39,7 +45,10 @@ async def get_predicted_solar_power_production_for_location( forecast_horizon: The forecast horizon to use. forecast_horizon_minutes: The forecast horizon in minutes to use. smooth_flag: Whether to smooth the forecast data. - model_name: The name of the model to use for predictions. + forecaster_name: The name of the model to use for predictions. + start_datetime: The start datetime for the prediction window. Default is None. + end_datetime: The end datetime for the prediction window. Default is None. + created_before_datetime: The upper limit for the creation datetime. Default is None. """ pass @@ -48,6 +57,8 @@ async def get_actual_solar_power_production_for_location( self, location: str, observer_name: str | None = None, + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, ) -> list[ActualPower]: """Returns a list of actual solar power production for a given location.""" pass @@ -79,7 +90,7 @@ async def get_wind_regions(self) -> list[str]: pass @abc.abstractmethod - async def get_solar_regions(self) -> list[Region]: + async def get_solar_regions(self, type:str | None = None) -> list[str] | list[Region]: """Returns a list of solar regions.""" pass @@ -113,6 +124,32 @@ async def get_site_forecast( """Get a forecast for a site.""" pass + # This is a legacy method that is being phased out in favor of get_site_forecast + @abc.abstractmethod + async def get_forecast_for_multiple_locations( + self, + location_uuids_to_location_ids: dict[str, int], + authdata: dict[str, str], + model_name: str | None = None, + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, + + ) -> list[OneDatetimeManyForecastValuesMW]: + """Get a forecast for multiple sites.""" + pass + + + @abc.abstractmethod + async def get_forecast_metadata( + self, + location_uuid: str, + authdata: dict[str, str], + model_name: str | None = None, + ) -> ForecastMetadata: + """Get forecast metadata for a site.""" + pass + + @abc.abstractmethod async def get_site_generation( self, site_uuid: UUID, authdata: dict[str, str], observer_name: str | None = None, @@ -120,6 +157,19 @@ async def get_site_generation( """Get the generation for a site.""" pass + # This is a legacy method that is being phased out in favor of get_site_generation + @abc.abstractmethod + async def get_generation_for_multiple_locations( + self, + location_uuids_to_location_ids: dict[str, int], + authdata: dict[str, str], + start_datetime: datetime | None = None, + end_datetime: datetime | None = None, + observer_name: str | None = None, + ) -> list[GSPYieldGroupByDatetime]: + """Get a forecast for multiple sites.""" + pass + @abc.abstractmethod async def post_site_generation( self, site_uuid: UUID, generation: list[ActualPower], authdata: dict[str, str], diff --git a/src/quartz_api/internal/models/endpoint_types.py b/src/quartz_api/internal/models/endpoint_types.py index 560891a..37de004 100644 --- a/src/quartz_api/internal/models/endpoint_types.py +++ b/src/quartz_api/internal/models/endpoint_types.py @@ -10,6 +10,27 @@ from pydantic import BaseModel, Field +def convert_to_camelcase(snake_str: str) -> str: + """Converts a given snake_case string into camelCase.""" + first, *others = snake_str.split("_") + return "".join([first.lower(), *map(str.title, others)]) + + +class EnhancedBaseModel(BaseModel): + """Ensures that attribute names are returned in camelCase.""" + + # Automatically creates camelcase alias for field names + # See https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator + class Config: # noqa: D106 + alias_generator = convert_to_camelcase + # allow_population_by_field_name = True + # orm_mode = True + # underscore_attrs_are_private = True + from_attributes = True + populate_by_name = True + + + class ForecastHorizon(str, Enum): """Defines the forecast horizon options. @@ -30,6 +51,12 @@ class PredictedPower(BaseModel): power_kW: float time: dt.datetime created_time: dt.datetime = Field(exclude=True) + plevel_kW: dict[str, float] = Field( + {}, + description="A dictionary of probabilistic levels for the forecast. " + "Keys are the level names (e.g., 'p10', 'p50', 'p90'), " + "and values are the corresponding power values in kW.", + ) def to_timezone(self, tz: str) -> "PredictedPower": """Converts the time of this predicted power value to the given timezone.""" @@ -39,6 +66,16 @@ def to_timezone(self, tz: str) -> "PredictedPower": created_time=self.created_time.astimezone(tz=ZoneInfo(key=tz)), ) +class ForecastMetadata(BaseModel): + """Defines the forecast metadata structure.""" + + initialization_timestamp_utc: dt.datetime \ + = Field(..., description="The initialization timestamp of the forecast in UTC.") + created_timestamp_utc: dt.datetime \ + = Field(..., description="The created timestamp of the forecast in UTC.") + forecaster_name: str = Field(..., description="The name of the forecaster.") + forecaster_version: str = Field(..., description="The version of the forecaster.") + class ActualPower(BaseModel): """Defines the data structure for an actual power value returned by the API.""" @@ -106,6 +143,17 @@ class Site(SiteProperties): json_schema_extra={"description": "The unique identifier for the site."}, ) + +class Region(BaseModel): + """Region metadata.""" + + region_name: str = Field(..., json_schema_extra={"description": "The name of the region."}) + region_metadata: dict | None = Field( + None, + json_schema_extra={"description": "Additional metadata about the region."}, + ) + + class SubstationProperties(LocationPropertiesBase): """Properties specific to a substation.""" @@ -137,17 +185,6 @@ class OneDatetimeManyForecastValues(BaseModel): ) -class Region(BaseModel): - """Region metadata.""" - - region_name: str = Field(..., json_schema_extra={"description": "The name of the region."}) - region_metadata: dict | None = Field( - None, - json_schema_extra={"description": "Additional metadata about the region."}, - ) - - - def get_timezone() -> str: """Stub function for timezone dependency. @@ -155,4 +192,32 @@ def get_timezone() -> str: """ return "UTC" + +class OneDatetimeManyForecastValuesMW(EnhancedBaseModel): + """One datetime with many forecast values. + + This is a legacy route that is being phased out. + """ + + datetime_utc: dt.datetime = Field(..., description="The timestamp of the gsp yield") + forecast_values: dict[int|str, float] = Field( + ..., + description="List of forecasts by ids. Key is gsp_id, value is generation_mw. " + "We keep this as a dictionary to keep the size of the file small ", + ) + +class GSPYieldGroupByDatetime(EnhancedBaseModel): + """gsp yields for one a singel datetime. + + This is a legacy route that is being phased out. + """ + + datetime_utc: dt.datetime = Field(..., description="The timestamp of the gsp yield") + generation_kw_by_gsp_id: dict[int|str, float] = Field( + ..., + description="List of generations by ids. Key is gsp_id, value is generation_kw. " + "We keep this as a dictionary to keep the size of the file small ", + ) + + TZDependency = Annotated[str, Depends(get_timezone)] diff --git a/src/quartz_api/internal/service/uk_national/cache.py b/src/quartz_api/internal/service/uk_national/cache.py new file mode 100644 index 0000000..f6315c0 --- /dev/null +++ b/src/quartz_api/internal/service/uk_national/cache.py @@ -0,0 +1,44 @@ +"""Cache key builder.""" +import logging +from collections.abc import Callable + +from fastapi import Request, Response + +log = logging.getLogger(__name__) + + +async def key_builder( + func: Callable, # noqa + namespace: str = "", + *, + request: Request = None, + response: Response = None, # noqa + args, # noqa + kwargs, # noqa +) -> str: + """This makes a general cache key for the request. + + Note that different users will have the same cache + We could have put this key builder in cmd/main.py + but I thought it was too much of a risk to be used accidentally + on private user routes + """ + # TODO add permission from read:intraday users + + params = request.query_params.items() + # remove UI tag + params = [(k, v) for k, v in params if k != "UI"] + # remove some legacy query params that arent needed anymore + legacy_query_params = ["compact", "historic"] + params = [(k, v) for k, v in params if k not in legacy_query_params] + + key = ":".join([ + namespace, + request.method.lower(), + request.url.path, + repr(sorted(params)), + ]) + + log.info(f"Cache key generated: {key}") + + return key diff --git a/src/quartz_api/internal/service/uk_national/gsp.py b/src/quartz_api/internal/service/uk_national/gsp.py index 494299b..737f7a0 100644 --- a/src/quartz_api/internal/service/uk_national/gsp.py +++ b/src/quartz_api/internal/service/uk_national/gsp.py @@ -1,15 +1,27 @@ """The 'gsp' FastAPI router object.""" +from datetime import UTC, datetime -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException +from fastapi_cache.decorator import cache from starlette import status from quartz_api.internal.middleware.auth import AuthDependency from quartz_api.internal.models import ( DBClientDependency, + ForecastHorizon, + GSPYieldGroupByDatetime, + OneDatetimeManyForecastValuesMW, ) -from .pydantic_models import Forecast, ForecastValue, GSPYield +from .cache import key_builder +from .pydantic_models import ForecastValue, GSPYield +from .time_utils import ( + ceil_30_minutes_dt, + floor_30_minutes_dt, + format_datetime, + limit_end_datetime_by_permissions, +) router = APIRouter(tags=["GSP"]) @@ -18,10 +30,16 @@ "/{gsp_id}/forecast", status_code=status.HTTP_200_OK, ) +@cache(key_builder=key_builder) async def get_forecasts_for_a_specific_gsp( db: DBClientDependency, auth: AuthDependency, -) -> Forecast | list[ForecastValue]: + gsp_id: int, + forecast_horizon_minutes: int | None = None, + start_datetime_utc: str | None = None, + end_datetime_utc: str | None = None, + creation_utc_limit: str | None = None, +) -> list[ForecastValue]: """### Get recent forecast values for a specific GSP. This route returns the most recent forecast for each _target_time_ for a @@ -43,16 +61,57 @@ async def get_forecasts_for_a_specific_gsp( - **creation_utc_limit**: optional, only return forecasts made before this datetime. returns the latest forecast made 60 minutes before the target time) """ - raise NotImplementedError() + start_datetime_utc = format_datetime(start_datetime_utc) + end_datetime_utc = format_datetime(end_datetime_utc) + creation_utc_limit = format_datetime(creation_utc_limit) + + permissions = getattr(auth, "permissions", []) + end_datetime_utc = limit_end_datetime_by_permissions(permissions, end_datetime_utc) + + gsps = await db.get_solar_regions(type="gsp") + gsp_location = [ + site for site in gsps if int(site.region_metadata["gsp_id"]) == gsp_id + ] + gsp_location_uuid = str(gsp_location[0].region_metadata["location_uuid"]) + + forecast_horizon = ForecastHorizon.latest + if forecast_horizon_minutes is None: + forecast_horizon = ForecastHorizon.horizon + + predicted_powers = await db.get_predicted_solar_power_production_for_location( + location=gsp_location_uuid, + forecast_horizon=forecast_horizon, + forecast_horizon_minutes=forecast_horizon_minutes, + smooth_flag=False, + forecaster_name="blend", + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, + created_before_datetime=creation_utc_limit, + ) + + national_forecasts = [ + ForecastValue( + target_time=pp.time, + expected_power_generation_megawatts=pp.power_kW / 1000, + ) + for pp in predicted_powers + ] + + return national_forecasts @router.get( "/{gsp_id}/pvlive", status_code=status.HTTP_200_OK, ) +@cache(key_builder=key_builder) async def get_truths_for_a_specific_gsp( db: DBClientDependency, - auth: AuthDependency, + auth: AuthDependency, # noqa FBT001 # TODO + gsp_id: int, + regime: str = "in-day", + start_datetime_utc: str | None = None, + end_datetime_utc: str | None = None, ) -> list[GSPYield]: """### Get PV_Live values for a specific GSP for yesterday and today. @@ -74,9 +133,199 @@ async def get_truths_for_a_specific_gsp( Only 3 days of history is available. If you want to get more PVLive data, please use the [PVLive API](https://www.solar.sheffield.ac.uk/api/) """ - raise NotImplementedError() + start_datetime_utc = format_datetime(start_datetime_utc) + end_datetime_utc = format_datetime(end_datetime_utc) + + gsps = await db.get_solar_regions(type="gsp") + + gsp_location = [ + site for site in gsps if int(site.region_metadata["gsp_id"]) == gsp_id + ] + + gsp_location_uuid = str(gsp_location[0].region_metadata["location_uuid"]) + + regime = regime.replace("-", "_") + + solar_production = await db.get_actual_solar_power_production_for_location( + location=gsp_location_uuid, + observer_name=f"pvlive_{regime}", + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, + ) + + gsp_yields = [ + GSPYield( + datetime_utc=sp.Time, + solar_generation_kw=sp.PowerKW, + ) + for sp in solar_production + ] + + return gsp_yields + + +# corresponds to route /v0/solar/GB/gsp/forecast/all/ +# TODO currently takes 9 seconds to load, so probably needs optimization +@router.get( + "/forecast/all/", + response_model=list[OneDatetimeManyForecastValuesMW], + include_in_schema=False, +) +@cache(key_builder=key_builder, expire=60*30) +async def get_all_available_forecasts( + db: DBClientDependency, + auth: AuthDependency, + start_datetime_utc: str | None = None, + end_datetime_utc: str | None = None, + gsp_ids: str | None = None, + creation_limit_utc: str | None = None, +) -> list[OneDatetimeManyForecastValuesMW]: + """### Get all forecasts for all GSPs. + + The return object contains a forecast object with system details and + forecast values for all GSPs. + + This request may take a longer time to load because a lot of data is being + pulled from the database. + + If _compact_ is set to true, the response will be a list of GSPGenerations objects. + This return object is significantly smaller, but less readable. + + _gsp_ids_ is a list of integers that correspond to the GSP ids. + If this is 1,2,3,4 the response will only contain those GSPs. + + #### Parameters + - **historic**: boolean that defaults to `true`, returning yesterday's and + today's forecasts for all GSPs + - **start_datetime_utc**: optional start datetime for the query. e.g '2023-08-12 10:00:00+00:00' + - **end_datetime_utc**: optional end datetime for the query. e.g '2023-08-12 14:00:00+00:00' + """ + gsps = await db.get_solar_regions(type="gsp") + # might need to add nation location in here too + + # format gsp_ids + if isinstance(gsp_ids, str): + try: + gsp_ids = [int(gsp_id) for gsp_id in gsp_ids.split(",") if gsp_id != ""] + except ValueError as e: + # this can happen if gsp_ids is not a valid integer + raise HTTPException( + status_code=422, + detail=f"Invalid GSP IDs format. \ + Tried to convert '{gsp_ids}' into list of integers") from e + + if len(gsp_ids) == 0: + gsp_ids = None + + # get locations uuids + location_uuids_to_gsp_id = { + str(gsp.region_metadata["location_uuid"]): int(gsp.region_metadata["gsp_id"]) + for gsp in gsps + } + if gsp_ids is not None: + location_uuids_to_gsp_id = { + location_uuid: gsp_id + for location_uuid, gsp_id in location_uuids_to_gsp_id.items() + if gsp_id in gsp_ids + } + + start_datetime_utc = format_datetime(start_datetime_utc) + end_datetime_utc = format_datetime(end_datetime_utc) + creation_limit_utc = format_datetime(creation_limit_utc) + + if start_datetime_utc is not None: + start_datetime_utc = ceil_30_minutes_dt(start_datetime_utc) + if end_datetime_utc is not None: + end_datetime_utc = floor_30_minutes_dt(end_datetime_utc) + + # TODO + # end_datetime_utc = limit_end_datetime_by_permissions(permissions, end_datetime_utc) + + # by default, don't get any data in the past if more than one gsp + if start_datetime_utc is None and (gsp_ids is None or len(gsp_ids) > 1): + start_datetime_utc = floor_30_minutes_dt(datetime.now(tz=UTC)) + + if start_datetime_utc is not None: + start_datetime_utc = ceil_30_minutes_dt(start_datetime_utc) + + forecast_values = await db.get_forecast_for_multiple_locations( + location_uuids_to_location_ids=location_uuids_to_gsp_id, + start_datetime_utc=start_datetime_utc, + end_datetime_utc=end_datetime_utc, + model_name="blend", + authdata=auth, + ) + + return forecast_values + + +# corresponds to API route /v0/solar/GB/gsp/pvlive/all +# TODO currently takes 2 seconds to load, so probably needs optimization +@router.get( + "/pvlive/all", + response_model=list[GSPYieldGroupByDatetime], + include_in_schema=False, +) +@cache(key_builder=key_builder, expire=60*30) +async def get_truths_for_all_gsps( + db: DBClientDependency, + auth: AuthDependency, + regime: str = "in-day", + start_datetime_utc: str | None = None, + end_datetime_utc: str | None = None, + gsp_ids: str | None = None, +) -> list[GSPYieldGroupByDatetime]: + """### Get PV_Live values for all GSPs for yesterday and today. + + The return object is a series of real-time PV generation estimates or + truth values from __PV_Live__ for all GSPs. + + Setting the _regime_ parameter to _day-after_ includes + the previous day's truth values for the GSPs. + + If _regime_ is not specified, the parameter defaults to _in-day_. + + If _compact_ is set to true, the response will be a list of GSPGenerations objects. + This return object is significantly smaller, but less readable. + + #### Parameters + - **regime**: can choose __in-day__ or __day-after__ + - **start_datetime_utc**: optional start datetime for the query. + - **end_datetime_utc**: optional end datetime for the query. + """ + try: + if isinstance(gsp_ids, str): + gsp_ids = [int(gsp_id) for gsp_id in gsp_ids.split(",") if gsp_id != ""] + except ValueError as e: + # this can happen if gsp_ids is not a valid integer + raise HTTPException( + status_code=422, + detail=f"Invalid GSP IDs format. Tried to convert {gsp_ids} into list of integers", + ) from e + + gsps = await db.get_solar_regions(type="gsp") + + start_datetime_utc = format_datetime(start_datetime_utc) + end_datetime_utc = format_datetime(end_datetime_utc) + # get locations uuids + location_uuids_to_gsp_id = { + str(gsp.region_metadata["location_uuid"]): int(gsp.region_metadata["gsp_id"]) + for gsp in gsps + } + if gsp_ids is not None: + location_uuids_to_gsp_id = { + location_uuid: gsp_id + for location_uuid, gsp_id in location_uuids_to_gsp_id.items() + if gsp_id in gsp_ids + } -# TODO add forecast/all and pvlive/all route. -# These are hidden but used by the UI + observations = await db.get_generation_for_multiple_locations( + location_uuids_to_location_ids=location_uuids_to_gsp_id, + observer_name=f"pvlive_{regime.replace('-', '_')}", + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, + authdata=auth, + ) + return observations diff --git a/src/quartz_api/internal/service/uk_national/national.py b/src/quartz_api/internal/service/uk_national/national.py index 5bb7b3f..2376bab 100644 --- a/src/quartz_api/internal/service/uk_national/national.py +++ b/src/quartz_api/internal/service/uk_national/national.py @@ -1,16 +1,29 @@ """The 'national' FastAPI router object.""" +from datetime import UTC, datetime from enum import Enum -from fastapi import APIRouter, Request +from fastapi import APIRouter +from fastapi_cache.decorator import cache from starlette import status from quartz_api.internal.middleware.auth import AuthDependency from quartz_api.internal.models import ( DBClientDependency, + ForecastHorizon, + ForecastMetadata, ) -from .pydantic_models import NationalForecast, NationalForecastValue, NationalYield +from .cache import key_builder +from .pydantic_models import ( + InputDataLastUpdated, + Location, + MLModel, + NationalForecast, + NationalForecastValue, + NationalYield, +) +from .time_utils import format_datetime, limit_end_datetime_by_permissions router = APIRouter(tags=["National"]) @@ -19,8 +32,8 @@ "pvnet_intraday": "pvnet_v2", "pvnet_day_ahead": "pvnet_day_ahead", "pvnet_intraday_ecmwf_only": "pvnet_ecmwf", - "pvnet_intraday_met_office_only": "pvnet-ukv-only", - "pvnet_intraday_sat_only": "pvnet-sat-only", + "pvnet_intraday_met_office_only": "pvnet_ukv_only", + "pvnet_intraday_sat_only": "pvnet_sat_only", } @@ -39,10 +52,10 @@ class ModelName(str, Enum): "/forecast", status_code=status.HTTP_200_OK, ) +@cache(key_builder=key_builder) async def get_national_forecast( db: DBClientDependency, auth: AuthDependency, - request: Request, forecast_horizon_minutes: int | None = None, include_metadata: bool = False, start_datetime_utc: str | None = None, @@ -80,16 +93,88 @@ async def get_national_forecast( Returns: The national forecast data. """ - raise NotImplementedError() + start_datetime_utc = format_datetime(start_datetime_utc) + end_datetime_utc = format_datetime(end_datetime_utc) + creation_limit_utc = format_datetime(creation_limit_utc) + + permissions = getattr(auth, "permissions", []) + end_datetime_utc = limit_end_datetime_by_permissions(permissions, end_datetime_utc) + + model_name = model_names_external_to_internal[model_name] + if trend_adjuster_on: + model_name = model_name + "_adjust" + + sites = await db.get_solar_regions(type="nation") + national_location_uuid = sites[0].region_metadata["location_uuid"] + + if include_metadata: + forecast_metadata: ForecastMetadata \ + = await db.get_forecast_metadata(location_uuid=national_location_uuid, + model_name=model_name, + authdata=auth) + + # Legacy inputdata, + # In nowcasting_datamodel, we get this from the database + old = datetime(1970, 1, 1, tzinfo=UTC) + input = InputDataLastUpdated(gsp=old, nwp=old, pv=old, satellite=old) + + national_forecast = NationalForecast( + location=Location.from_region(sites[0]), + model=MLModel(name=forecast_metadata.forecaster_name, + version=forecast_metadata.forecaster_version), + forecast_creation_time=forecast_metadata.created_timestamp_utc, + initialization_datetime_utc=forecast_metadata.initialization_timestamp_utc, + forecast_values=[], + input_data_last_updated=input, + ) + + forecast_horizon = ForecastHorizon.latest + if forecast_horizon_minutes is not None: + forecast_horizon = ForecastHorizon.horizon + + predicted_powers = await db.get_predicted_solar_power_production_for_location( + location=national_location_uuid, + forecast_horizon=forecast_horizon, + forecast_horizon_minutes=forecast_horizon_minutes, + smooth_flag=False, + forecaster_name=model_name, + start_datetime=start_datetime_utc, + end_datetime=end_datetime_utc, + created_before_datetime=creation_limit_utc, + ) + + + national_forecast_values = [ + NationalForecastValue( + target_time=pp.time, + expected_power_generation_megawatts=pp.power_kW / 1000, + plevels={ + "plevel_10": pp.plevel_kW["p10"] / 1000, + "plevel_90": pp.plevel_kW["p90"] / 1000, + }, + ) + for pp in predicted_powers + ] + + if not include_metadata: + return national_forecast_values + else: + national_forecast.forecast_values = national_forecast_values + return national_forecast + + + + @router.get( "/pvlive", status_code=status.HTTP_200_OK, ) +@cache(key_builder=key_builder) async def get_national_pvlive( db: DBClientDependency, - auth: AuthDependency, + auth: AuthDependency, # noqa FBT001 # TODO regime: str | None = "in-day", ) -> list[NationalYield]: """### Get national PV_Live values for yesterday and/or today. @@ -109,7 +194,25 @@ async def get_national_pvlive( - **regime**: can choose __in-day__ or __day-after__ """ - raise NotImplementedError() + sites = await db.get_solar_regions(type="nation") + national_location_uuid = sites[0].region_metadata["location_uuid"] + + regime = regime.replace("-", "_") + + solar_production = await db.get_actual_solar_power_production_for_location( + location=national_location_uuid, + observer_name=f"pvlive_{regime}", + ) + + national_yields = [ + NationalYield( + datetime_utc=sp.Time, + solar_generation_kw=sp.PowerKW, + ) + for sp in solar_production + ] + + return national_yields # Note have removed elexon API call, as not used diff --git a/src/quartz_api/internal/service/uk_national/pydantic_models.py b/src/quartz_api/internal/service/uk_national/pydantic_models.py index 54b39c3..491abc0 100644 --- a/src/quartz_api/internal/service/uk_national/pydantic_models.py +++ b/src/quartz_api/internal/service/uk_national/pydantic_models.py @@ -22,6 +22,8 @@ from pydantic import BaseModel, Field, field_validator +from quartz_api.internal.models import Region + logger = logging.getLogger(__name__) @@ -49,19 +51,47 @@ class Location(EnhancedBaseModel): """Location that the forecast is for.""" label: str = Field(..., description="") - gsp_id: int | None = Field(None, description="The Grid Supply Point (GSP) id", index=True) + gsp_id: int | None = Field(None, description="The Grid Supply Point (GSP) id") gsp_name: str | None = Field(None, description="The GSP name") gsp_group: str | None = Field(None, description="The GSP group name") region_name: str | None = Field(None, description="The GSP region name") installed_capacity_mw: float | None = Field( - None, description="The installed capacity of the GSP in MW", + None, + description="The installed capacity of the GSP in MW", ) + @classmethod + def from_region(cls, region: Region) -> "Location": + """Change RegionSQL to Location. + + RegionSQL is defined in nowcasting_datamodel + """ + region_gsp_id = int(region.region_metadata["gsp_id"]) + installed_capacity_mw = region.region_metadata["effective_capacity_watts"] / 10**6 + if "full_name" in region.region_metadata: + full_name = region.region_metadata["full_name"] + else: + full_name = region.region_name + + gsp_name = region.region_name + gsp_group = region.region_name + region_name = full_name + + return Location( + label=f"GSP_{region_gsp_id}", + gsp_id=region_gsp_id, + gsp_name=gsp_name.upper(), + gsp_group=gsp_group.upper(), + region_name=region_name, + installed_capacity_mw=installed_capacity_mw, + ) + + class MLModel(EnhancedBaseModel): """ML model that is being used.""" - name: str | None = Field(..., description="The name of the model", index=True) + name: str | None = Field(..., description="The name of the model") version: str | None = Field(..., description="The version of the model") @@ -76,11 +106,15 @@ class ForecastValue(EnhancedBaseModel): ), ) expected_power_generation_megawatts: float = Field( - ..., ge=0, description="The forecasted value in MW", + ..., + ge=0, + description="The forecasted value in MW", ) expected_power_generation_normalized: float | None = Field( - None, ge=0, description="The forecasted value divided by the GSP capacity [%]", + None, + ge=0, + description="The forecasted value divided by the GSP capacity [%]", ) @@ -91,7 +125,8 @@ class InputDataLastUpdated(EnhancedBaseModel): nwp: datetime = Field(..., description="The time when the input NWP data was last updated") pv: datetime = Field(..., description="The time when the input PV data was last updated") satellite: datetime = Field( - ..., description="The time when the input satellite data was last updated", + ..., + description="The time when the input satellite data was last updated", ) @@ -101,7 +136,8 @@ class Forecast(EnhancedBaseModel): location: Location = Field(..., description="The location object for this forecaster") model: MLModel = Field(..., description="The name of the model that made this forecast") forecast_creation_time: datetime = Field( - ..., description="The time when the forecaster was made", + ..., + description="The time when the forecaster was made", ) historic: bool = Field( False, @@ -163,79 +199,6 @@ def from_location_sql(self) -> "LocationWithGSPYields": ) -class GSPYieldGroupByDatetime(EnhancedBaseModel): - """gsp yields for one a singel datetime.""" - - datetime_utc: datetime = Field(..., description="The timestamp of the gsp yield") - generation_kw_by_gsp_id: dict[int, float] = Field( - ..., - description="List of generations by gsp_id. Key is gsp_id, value is generation_kw. " - "We keep this as a dictionary to keep the size of the file small ", - ) - - -class OneDatetimeManyForecastValues(EnhancedBaseModel): - """One datetime with many forecast values.""" - - datetime_utc: datetime = Field(..., description="The timestamp of the gsp yield") - forecast_values: dict[int, float] = Field( - ..., - description="List of forecasts by gsp_id. Key is gsp_id, value is generation_kw. " - "We keep this as a dictionary to keep the size of the file small ", - ) - - -def convert_forecasts_to_many_datetime_many_generation( - forecasts: list[Forecast], - historic: bool = True, - start_datetime_utc: datetime | None = None, - end_datetime_utc: datetime | None = None, -) -> list[OneDatetimeManyForecastValues]: - """Change forecasts to list of OneDatetimeManyForecastValues. - - This converts a list of forecast objects to a list of OneDatetimeManyForecastValues objects. - - N forecasts, which T forecast values each, - is converted into - T OneDatetimeManyForecastValues objects with N forecast values each. - - This reduces the size of the object as the datetimes are not repeated for each forecast values. - """ - many_forecast_values_by_datetime = {} - - # loop over locations and gsp yields to create a dictionary of gsp generation by datetime - for forecast in forecasts: - gsp_id = str(forecast.location.gsp_id) - forecast_values = forecast.forecast_values_latest if historic else forecast.forecast_values - - for forecast_value in forecast_values: - datetime_utc = forecast_value.target_time - if start_datetime_utc is not None and datetime_utc < start_datetime_utc: - continue - if end_datetime_utc is not None and datetime_utc > end_datetime_utc: - continue - - forecast_mw = forecast_value.expected_power_generation_megawatts - forecast_mw = round(forecast_mw, 2) - - # if the datetime object is not in the dictionary, add it - if datetime_utc not in many_forecast_values_by_datetime: - many_forecast_values_by_datetime[datetime_utc] = {gsp_id: forecast_mw} - else: - many_forecast_values_by_datetime[datetime_utc][gsp_id] = forecast_mw - - # convert dictionary to list of OneDatetimeManyForecastValues objects - many_forecast_values = [] - for datetime_utc, forecast_values in many_forecast_values_by_datetime.items(): - many_forecast_values.append( - OneDatetimeManyForecastValues( - datetime_utc=datetime_utc, forecast_values=forecast_values, - ), - ) - - return many_forecast_values - - NationalYield = GSPYield @@ -243,7 +206,8 @@ class NationalForecastValue(ForecastValue): """One Forecast of generation at one timestamp include properties.""" plevels: dict = Field( - None, description="Dictionary to hold properties of the forecast, like p_levels. ", + None, + description="Dictionary to hold properties of the forecast, like p_levels. ", ) expected_power_generation_normalized: float | None = Field( diff --git a/src/quartz_api/internal/service/uk_national/router.py b/src/quartz_api/internal/service/uk_national/router.py index 32a228e..28114f5 100644 --- a/src/quartz_api/internal/service/uk_national/router.py +++ b/src/quartz_api/internal/service/uk_national/router.py @@ -1,6 +1,5 @@ """The 'uk national and gsp' FastAPI router object and associated routes logic.""" - from importlib.metadata import version from fastapi import APIRouter @@ -16,7 +15,9 @@ general_routes_prefix = "/v0/solar/GB" router.include_router( - national_router, prefix=f"{general_routes_prefix}/national", tags=["National"], + national_router, + prefix=f"{general_routes_prefix}/national", + tags=["National"], ) router.include_router(gsp_router, prefix=f"{general_routes_prefix}/gsp", tags=["GSP"]) router.include_router(status_router, prefix=f"{general_routes_prefix}/status") diff --git a/src/quartz_api/internal/service/uk_national/status.py b/src/quartz_api/internal/service/uk_national/status.py index add7c84..3818fd1 100644 --- a/src/quartz_api/internal/service/uk_national/status.py +++ b/src/quartz_api/internal/service/uk_national/status.py @@ -1,27 +1,77 @@ """The 'status' FastAPI router object.""" +import os +from datetime import datetime from fastapi import APIRouter +from fastapi_cache.decorator import cache +from sqlalchemy import create_engine, text from starlette import status +from quartz_api.internal.models import ( + DBClientDependency, +) + +from .cache import key_builder from .pydantic_models import Status router = APIRouter() +db_url = os.getenv("DB_URL", None) +if db_url is not None: + engine = create_engine(db_url) + + @router.get( - "/", + "", status_code=status.HTTP_200_OK, ) -async def get_status( -) -> Status: +async def get_status() -> Status: """### Get status for the database and forecasts. Occasionally there may be a small problem or interruption with the forecast. This route is where the OCF team communicates the forecast status to users. """ - raise NotImplementedError() + # Note that we want to upgrade this, + # but currently this will pull from the nowcasting_datamodel database + + with engine.connect() as connection: + result = connection.execute(text("SELECT * from status order by created_utc desc limit 1")) + row = result.fetchone() + status = Status( + status=row[2], + message=row[3], + ) + return status + +@router.get("/check_last_forecast_run", include_in_schema=False) +@cache(key_builder=key_builder) +async def check_last_forecast_run( + db: DBClientDependency, + model_name: str | None = None) -> datetime: + """### Check the last forecast run status. -# TODO /check_last_forecast_run -# TODO /update_last_data + This route is used to check the status of the last forecast run. + """ + sites = await db.get_solar_regions(type="nation") + national_location_uuid = sites[0].region_metadata["location_uuid"] + + forecast = await db.get_forecast_metadata( + location_uuid=national_location_uuid, + authdata={}, + model_name=model_name, + ) + + # we should use created_timestamp_utc, + # but currently thats deafulted to 1970-01-01 + # So for now we use initialization_timestamp_utc + return forecast.initialization_timestamp_utc + + + +@router.get("/update_last_data", include_in_schema=False) +async def update_last_data() -> None: + """Update the last data. This is a legacy route, and should not be used.""" + raise NotImplementedError() diff --git a/src/quartz_api/internal/service/uk_national/system.py b/src/quartz_api/internal/service/uk_national/system.py index a4fc415..8f5263c 100644 --- a/src/quartz_api/internal/service/uk_national/system.py +++ b/src/quartz_api/internal/service/uk_national/system.py @@ -1,7 +1,7 @@ """The 'system' FastAPI router object.""" - from fastapi import APIRouter +from fastapi_cache.decorator import cache from starlette import status from quartz_api.internal.middleware.auth import AuthDependency @@ -9,18 +9,20 @@ DBClientDependency, ) +from .cache import key_builder from .pydantic_models import Location router = APIRouter(tags=["System"]) - @router.get( - "/gsp", + "/gsp/", status_code=status.HTTP_200_OK, ) +@cache(key_builder=key_builder) async def get_system_details( db: DBClientDependency, - auth: AuthDependency, + auth: AuthDependency, # noqa TODO use auth + gsp_id: int | None = None, ) -> list[Location]: """### Get system details for a single GSP or all GSPs. @@ -30,4 +32,41 @@ async def get_system_details( #### Parameters - **gsp_id**: gsp_id of the requested system """ - raise NotImplementedError() + # National + regions = await db.get_solar_regions(type="nation") + + national = regions[0] + installed_capacity_mw = national.region_metadata["effective_capacity_watts"] / 10**6 + + location = Location( + label="National-GB", + gsp_id=0, + gsp_name="National", + gsp_group="National", + region_name="National", + installed_capacity_mw=installed_capacity_mw, + ) + + if gsp_id == 0: + return [location] + + # GSP + regions = await db.get_solar_regions(type="gsp") + + locations = [location] + for region in regions: + + location = Location.from_region(region) + + if gsp_id is not None and gsp_id != location.gsp_id: + continue + + if gsp_id is not None and gsp_id == location.gsp_id: + return [location] + + locations.append(location) + + # sort by gsp_id + locations.sort(key=lambda x: x.gsp_id) + + return locations diff --git a/src/quartz_api/internal/service/uk_national/test_time_utils.py b/src/quartz_api/internal/service/uk_national/test_time_utils.py new file mode 100644 index 0000000..0916ea5 --- /dev/null +++ b/src/quartz_api/internal/service/uk_national/test_time_utils.py @@ -0,0 +1,33 @@ +from .time_utils import ceil_30_minutes_dt, floor_30_minutes_dt, format_datetime + + +def test_format_datetime(): + + + format_datetime_none = format_datetime(None) + assert format_datetime_none is None + + format_datetime_no_tz = format_datetime("2024-01-01T12:00:00") + assert format_datetime_no_tz.isoformat() == "2024-01-01T12:00:00+00:00" + + +def test_floor_30_minutes_dt(): + dt1 = floor_30_minutes_dt(format_datetime("2024-01-01T12:15:45+00:00")) + assert dt1.isoformat() == "2024-01-01T12:00:00+00:00" + + dt2 = floor_30_minutes_dt(format_datetime("2024-01-01T12:45:30+00:00")) + assert dt2.isoformat() == "2024-01-01T12:30:00+00:00" + + dt3 = floor_30_minutes_dt(format_datetime("2024-01-01T12:30:00+00:00")) + assert dt3.isoformat() == "2024-01-01T12:30:00+00:00" + + +def test_ceil_30_minutes_dt(): + dt1 = ceil_30_minutes_dt(format_datetime("2024-01-01T12:15:45+00:00")) + assert dt1.isoformat() == "2024-01-01T12:30:00+00:00" + + dt2 = ceil_30_minutes_dt(format_datetime("2024-01-01T12:45:30+00:00")) + assert dt2.isoformat() == "2024-01-01T13:00:00+00:00" + + dt3 = ceil_30_minutes_dt(format_datetime("2024-01-01T12:30:00+00:00")) + assert dt3.isoformat() == "2024-01-01T12:30:00+00:00" diff --git a/src/quartz_api/internal/service/uk_national/time_utils.py b/src/quartz_api/internal/service/uk_national/time_utils.py new file mode 100644 index 0000000..dc26610 --- /dev/null +++ b/src/quartz_api/internal/service/uk_national/time_utils.py @@ -0,0 +1,99 @@ +"""Utility functions for handling datetime objects in UK National context.""" + +import os +from datetime import UTC, datetime, timedelta + +import numpy as np +import sentry_sdk +from pytz import timezone + +utc = timezone("UTC") +# TODO this would be nice if this was done with the high level quartz-api config file +# One idea is we could put end_datetime_utc with auth into some middleware, +# and the time clipping is done then +INTRADAY_LIMIT_HOURS = float(os.getenv("INTRADAY_LIMIT_HOURS", 8)) + + +def format_datetime(datetime_str: str | None = None) -> datetime | None: + """Format datetime string to datetime object. + + If None return None, if not timezone, add UTC + :param datetime_str: The datetime string to be formatted. + :return: The formatted datetime object or None. + """ + if datetime_str is None: + return None + + else: + datetime_output = datetime.fromisoformat(datetime_str) + if datetime_output.tzinfo is None: + datetime_output = utc.localize(datetime_output) + return datetime_output + + +def floor_30_minutes_dt(dt: datetime) -> datetime: + """Floor a datetime by 30 mins. + + For example: + 2021-01-01 17:01:01 --> 2021-01-01 17:00:00 + 2021-01-01 17:35:01 --> 2021-01-01 17:30:00 + + :param dt: + :return: + """ + approx = np.floor(dt.minute / 30.0) * 30 + dt = dt.replace(minute=0) + dt = dt.replace(second=0) + dt = dt.replace(microsecond=0) + dt += timedelta(minutes=approx) + + return dt + + +def ceil_30_minutes_dt(dt: datetime) -> datetime: + """Ceil a datetime by 30 mins. + + For example: + 2021-01-01 17:01:01 --> 2021-01-01 17:30:00 + 2021-01-01 17:35:01 --> 2021-01-01 18:00:00 + 2021-01-01 17:30:00 --> 2021-01-01 17:30:00 + """ + dt_floor = floor_30_minutes_dt(dt) + if dt == dt_floor: + return dt_floor + dt_ceil = dt_floor + timedelta(minutes=30) + return dt_ceil + + +def limit_end_datetime_by_permissions( + permissions: list[str], + end_datetime_utc: datetime | None = None, + intraday_limit_hours: int = INTRADAY_LIMIT_HOURS, +) -> datetime: + """Limit end datetime so that intraday users can receive forecast values max. + + Check if end_datetime_utc is set; if set, check it's not more than 8 hours from now, + and if not set, set it to 8 hours from now. + + :param permissions: list of permissions, e.g. ['read:uk-intraday'] + :param end_datetime_utc: datetime, requested end time of forecast + :param intraday_limit_hours: int, maximum number of hours allowed ahead of now for forecasts + :return: datetime, end time of forecast, limited to max 8 hours from now + """ + if permissions is None or len(permissions) == 0: + sentry_sdk.capture_message( + "User has no permissions during limit_end_datetime_by_permissions check;" + "by default, users should have at least one role, so check in Auth0.", + ) + return end_datetime_utc + + is_intraday_only_user = "read:uk-intraday" in permissions + + intraday_max_allowed = datetime.now(UTC) + timedelta(hours=intraday_limit_hours) + if is_intraday_only_user: + if end_datetime_utc is None: + return intraday_max_allowed + else: + return min(end_datetime_utc, intraday_max_allowed) + + return end_datetime_utc diff --git a/uv.lock b/uv.lock index c30ab7f..e37d935 100644 --- a/uv.lock +++ b/uv.lock @@ -8,37 +8,46 @@ resolution-markers = [ [[package]] name = "ada-url" -version = "1.27.0" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/e4/3e8128eb0d7c9a472ea95b1e90cf79ff9e2fa9a6678ae23ccf81908ffad2/ada_url-1.27.0.tar.gz", hash = "sha256:f7adceb709e552aabbb680c1a0b4a856d76fdfca1922ca91236f84fc5c73ee7c", size = 261008, upload-time = "2025-09-26T20:42:55.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/93/0a8faf0c470e1e91e97667f1b7051f4ff4fda159cb221756f3423e84ad76/ada_url-1.27.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:39ca8a05f31e761fd021c3cf7df7d564132a6eb84fa4c75b83ea0f08a0fa0f52", size = 702603, upload-time = "2025-09-26T20:41:46.616Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6d/a22d25c471b9d71e0e0a3a0d6f687ce8a8eb5733e10e68c218c30fdd67b9/ada_url-1.27.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:9f661a184a98004707e00dbf6b9b3ca7c8dc4713e3b94135a44e980f096bddb6", size = 483317, upload-time = "2025-09-26T20:41:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/d1/47/2a4bf5706fe729051a7eac2199430a435e31edcad45fd761aa78a696dba5/ada_url-1.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b98d96b25710688a9a3c665ac7539b0ae658fc64a31a74294f39f81516e3d530", size = 478575, upload-time = "2025-09-26T20:41:50.033Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c3/6e84bc384a89c6b930be928a67b17d9aca88d9089178124dd72005392dc0/ada_url-1.27.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4218ede320875efddc14bc4ea53d2cefa20a40852f98cafeafa3f0ca0edeea9", size = 1646672, upload-time = "2025-09-26T20:41:51.823Z" }, - { url = "https://files.pythonhosted.org/packages/19/48/0a6aa0654c6900075d88ec8f0d7b7962e30718e76de049bd966b9c0dab8d/ada_url-1.27.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4684f04f32a69f7fd00bd0f87dfb636dc7df91b3ff64efce90bb9488807adaef", size = 1712521, upload-time = "2025-09-26T20:41:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/40/3e/e068c34976ed88fa72813b264d62c7a60f085ce1b68a793741f5cc602087/ada_url-1.27.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74d080c6b65703828038d0bebb95e5a121e66a2602da7c45841126653ab410d6", size = 2557421, upload-time = "2025-09-26T20:41:55.01Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/38e3168032453078313269df675013df76262fe03350d7431e3125238651/ada_url-1.27.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:27c143cc7e3bd70b0094252272b19ab9be468ff01e517ded1b7e1f35502e49b6", size = 2682600, upload-time = "2025-09-26T20:41:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f7/12d1da55e00167c3972c95fce7e950962af0f5b0aebcc05da4c5d273fc95/ada_url-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:9d0c025a6972f41a4a796df7011fe73028cf19ac1837ebbefbdbfc2061d5f437", size = 437892, upload-time = "2025-09-26T20:41:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/fe/99/db7f4362cd9024bbc64baa39f30a6b58cf86f61263c48718e2daf26419ef/ada_url-1.27.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:efe34acc78cdf02c3de1b115f0fda3e9a37d4a66cdf73f8795450ddee71e1b47", size = 702701, upload-time = "2025-09-26T20:41:59.647Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6b/a4bc7c7cc89a850974274e583af85fb1bbfbeeb3773eec2a0bab0ee7b595/ada_url-1.27.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:f2bbbdc57bff941486b6b51470487894dadc1132bf0c819dcc40b9bd064d36f8", size = 483281, upload-time = "2025-09-26T20:42:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7e/608516bfb9a37437ec32be6e4391ba31efcc1ffe1d0eabf4194394213c7a/ada_url-1.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac44ab21c3aaad92f132a8ca51059dc75bb19d04ece9c3443ce6688ea628f35", size = 478722, upload-time = "2025-09-26T20:42:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/ba/94/d780172046b0b949d0eb48643810964a130fc1139653ecea5fc1ba07e1ed/ada_url-1.27.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05ed61f58493fedcc4a09546c4a1eb1281984a0d47e3269bffcbe8cf83134b29", size = 1647719, upload-time = "2025-09-26T20:42:04.163Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/e3c8a18d8e3c13b4ac422f6cbc8eeff0434a2887e1c59f4c06fc9a69b14d/ada_url-1.27.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb02764e5276b5c071b50ed37ff44a103fce0e6e0dea1de319a91e484e5a0297", size = 1713515, upload-time = "2025-09-26T20:42:05.753Z" }, - { url = "https://files.pythonhosted.org/packages/96/cd/a3b5fce4d7f75a985b9bf40b3867624147dbd07fc7472f425215988daa12/ada_url-1.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:360c1d24c36f9a16503a38ad0de8c58a85a54cca0bd8984a83085f27d34eb2b1", size = 2558616, upload-time = "2025-09-26T20:42:07.535Z" }, - { url = "https://files.pythonhosted.org/packages/ed/cd/5dba2c7b08d215275bf0058b18c1b76d33e6da12b43abbdbf9e0356f67e2/ada_url-1.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9f8a45aac4f78b3205b5b0b711c562449214ace540c40712cfe64d05772d4c5", size = 2683796, upload-time = "2025-09-26T20:42:08.969Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/7424053c3af7c16f0ece5652264f76312bcfdc9de236901cd45a7b8f42f9/ada_url-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:27bf83ed13519636e4b8c6281bab7039d444be9288276a8c6363d541fe1f8c20", size = 437882, upload-time = "2025-09-26T20:42:10.481Z" }, - { url = "https://files.pythonhosted.org/packages/2f/58/5ef027ff124038d59c26f61742768afa54d2f3a4cc8cb66120f73e23619b/ada_url-1.27.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:98be524a61c7d08cdaf9581a61978e280e2b8c2850bd8b475654678e57d179c5", size = 702701, upload-time = "2025-09-26T20:42:11.809Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b8/2e5058b53ee13d4c00238240ddf3e9e39174fa4f5a4f3a82e15f2bb4786e/ada_url-1.27.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:22df977b491a039b8876f943c9ae42acd807e3f6f7df7d9513590866b166ffb1", size = 483279, upload-time = "2025-09-26T20:42:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d3/e53efa4bb6341a7807d13b8c0094f7d14993caa6f53bee2858b6f98b9eb9/ada_url-1.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3926ffdb607e48ce2785e5107a6b10e80fb3834b6891d7919ff519beab61cebe", size = 478720, upload-time = "2025-09-26T20:42:15.115Z" }, - { url = "https://files.pythonhosted.org/packages/73/59/961e0376b0d76daf38fc281f4cec4e8fa5d2dd726713b1e2c42a0ab863f0/ada_url-1.27.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f523b7be5de0a1319a112e6ce45647e83785862052b2dfee65d163a99b73c094", size = 1647699, upload-time = "2025-09-26T20:42:16.446Z" }, - { url = "https://files.pythonhosted.org/packages/be/b7/5f63017530d1a73ff1f7e1a28ffb1077ed2c8d433279ac713d2e78598e9f/ada_url-1.27.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69015f7e8a6240c55be0edb0c6fcf2235de1b8a8ea3c0db5c74030fd048af458", size = 1713491, upload-time = "2025-09-26T20:42:18.007Z" }, - { url = "https://files.pythonhosted.org/packages/45/be/3281fe880881001b967f8a54044ab1c0737baa211f0053851479d5a91a9b/ada_url-1.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db575cbdd19a8f40f3e2a6424eea016335695303c1cf7eb1780f337728c9927d", size = 2558614, upload-time = "2025-09-26T20:42:19.564Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/dd6feaf8c5d3f629215561c1739e3547b57ddf94bd0b4a4bbef160955648/ada_url-1.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c506654ea6426afbffae5102fa84b610a83e05ce9346b1dc6698b30634bf7999", size = 2683800, upload-time = "2025-09-26T20:42:20.994Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d7/ac30a364943cf4c5e46e14aa6db1a933d747503294ec7ba424620473849d/ada_url-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:403ea8e54fec1464e381c59657215f0ebff82ccb31713056d0f1a37c4c72c6c1", size = 437882, upload-time = "2025-09-26T20:42:22.484Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a6/ef/97c114934ebebe24b70cc49b191fc963896b778ed324583d02f46e0c9b3a/ada_url-1.28.0.tar.gz", hash = "sha256:ff2115679335f698da64e846913061cbb3064de35f1bea5d8f8e5c1b87756702", size = 270951, upload-time = "2026-01-08T16:55:57.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/c1/5ed019bfc4826cdc9d229bf3712b8d0a74fbbe2113c619ea2a918a0b6fba/ada_url-1.28.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:add17b8d1d3cefa819c35f6d7a5da8ea31b14c4e7568cfe0e9ad9b9c9221fd0c", size = 716577, upload-time = "2026-01-08T16:55:11.14Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/94f1ed9740ce68bbd51a0a507100d8fe517b2d2653dbb0b4c5e1704963be/ada_url-1.28.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:9f7944503f237c2baae9da507a0796772fe18f92b7c864827b08beb71094a711", size = 494303, upload-time = "2026-01-08T16:55:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c16d46c277f8151b60b506500dfe90e3acd3f53fe0787d976c30f324e70d/ada_url-1.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5953813104dfa1d9e0b2bf9619bb4fbe69a127a6a90c4b8cc9d56c46d66a419b", size = 492008, upload-time = "2026-01-08T16:55:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/afb39da403621d3b962b5600a1869b6327d51a8c53325d77cce52ee0c52a/ada_url-1.28.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d53006d0fd97e34e013e14997f882717124b08721a39b004650a68447adba48", size = 1679482, upload-time = "2026-01-08T16:55:15.269Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e5/0c8376c8378d775e98173452c9b0f93c37f6470c726fd03d8e7f66dc912f/ada_url-1.28.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:918b66b7a108c0f4cf9085c7ff8598b3e49aee1fb622a0e2101d08b1d32aaf4e", size = 1753199, upload-time = "2026-01-08T16:55:17.126Z" }, + { url = "https://files.pythonhosted.org/packages/e1/50/9526998d7787d150102e30a016964135a27762289a8b8cd6925b8b60fd4c/ada_url-1.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d6aaf0eb389b15a1e219b0c9a07d1996dca75f5ed021cd22b6800328a91c71a", size = 2588898, upload-time = "2026-01-08T16:55:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2d/58383d7b385421ef9bb49c630fef97f9ce6c3debfd4171d7b30cedc95261/ada_url-1.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6523b121d4b0252e7eb2febcf1d8487f01655c51924709657538751eba4aa142", size = 2722856, upload-time = "2026-01-08T16:55:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/091c4a40f57d9aa2ecff16380cfab1368e0e2d10feaa890781cd8af56b5b/ada_url-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a75c0fc288cfa1a67164eef907f834d6d9c039d6270133be3c56055019f44be", size = 448703, upload-time = "2026-01-08T16:55:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/b0/62/f17cd27dc771aa9f1471bab702fef35b0284e475607ba15afe4db19aa648/ada_url-1.28.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:4818a86e7d9731206d44695032baa5928d63d6cebd62435af3a5efe07d3b596a", size = 716710, upload-time = "2026-01-08T16:55:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/75/4e/afcf4a07ad019d18d2b1eee9f1853e99769a9f2fc2d2e617097e14c6022b/ada_url-1.28.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2c8b7fce157d2dac2636f649bf8239d8a372ec4b79c3d16dde781f3a9c7584b3", size = 494272, upload-time = "2026-01-08T16:55:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/ec/92/326150dfb481c501c903695205fc0dacf31661851abf532f619f5cc59e2a/ada_url-1.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:066f604d43b8a3c39bd8089740526f9580d8c8292aaa5b94846c4180aceead51", size = 492190, upload-time = "2026-01-08T16:55:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/0d/26/2a79ab2f0938f718a42637862619c8c49cae2e188c5bc4f9cbfb6867e894/ada_url-1.28.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0424184a215b95bd86e661e7ac5085eb86a5b06454fc582dcb520e771bc9949", size = 1680132, upload-time = "2026-01-08T16:55:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/60501ec606a6a748f5774c5baba9c309a8416d9be8b53ad4b4b2a7b4cae0/ada_url-1.28.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90c3b10b9165a5f2e325d1426bb8ed1fe35b4070af54669c26bae5860795b4d7", size = 1754395, upload-time = "2026-01-08T16:55:30.286Z" }, + { url = "https://files.pythonhosted.org/packages/59/e3/9fb50c311c05fca8e62f95fac1213c668c3dde76ff7bf993a9789cc7bc39/ada_url-1.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4b8346bdc3e5bc94f5977e2151491bd49390dbe33a03a55fb39d16e180e83f4", size = 2589995, upload-time = "2026-01-08T16:55:32.236Z" }, + { url = "https://files.pythonhosted.org/packages/88/60/d7cd46744f972099c44efa1841dc2446c05b90e196cdfb4cbdfbe77e65f3/ada_url-1.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:859e1a9237fb3020e998d7a80b629e9c37439f4c2642f28e4d708a9e669e972a", size = 2724075, upload-time = "2026-01-08T16:55:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/de/51/59f40cd5a9f09157d4506600d5f08715e738cb1e84404db94da01673f80e/ada_url-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:902696d75b46643803aa4b5c723fa743f35eceed9468cad16881f0f7c9870fa9", size = 448720, upload-time = "2026-01-08T16:55:35.657Z" }, + { url = "https://files.pythonhosted.org/packages/58/6d/d40b9dca41365d772bab8eba53d34bd4e3a93289ae0dc040aa334638a494/ada_url-1.28.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:212f8737210bd375a366a09dce74f46baae9bcba9d84d10cd12c564bd0bd80e3", size = 716706, upload-time = "2026-01-08T16:55:37.566Z" }, + { url = "https://files.pythonhosted.org/packages/9f/91/65bfa006fd54e643d1054b5d6e001b931e176b88ca70985ad2e33b26086f/ada_url-1.28.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:742415d1c283e4f813b34e1b3a7b8a8763da27064433d069a371440f59eccaa2", size = 494273, upload-time = "2026-01-08T16:55:38.79Z" }, + { url = "https://files.pythonhosted.org/packages/c0/36/3e499b9e766f306822511e5bd609ba33fc6122c3c8938338453ccc9e08f3/ada_url-1.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9455bf4e3335732696eade2f6b8bdd029e01a64a722ac009607adb63eb7fa951", size = 492192, upload-time = "2026-01-08T16:55:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cc/1b21fdf28cf419d7d2d554cda7729b79d2372fcda28b833209e896efbf14/ada_url-1.28.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb7d8c9cedb6044a66a6bfa08cf9b9883c6746ec94fb279d77c1267e127c0aef", size = 1680138, upload-time = "2026-01-08T16:55:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/75/f1/e2ffc03ced61f7c6dc8b13a1f1ad737a1dd0f6b271abf3f308e4c3756048/ada_url-1.28.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f0f3f39e0a2195d36c4892bc4a8dc6416fa1d2c942444c672a9bd031c575134", size = 1754383, upload-time = "2026-01-08T16:55:44.117Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/c35c4ee0aaabbb4e2add8f5e43672687c1fa6cc890a7b0c31b41060b2b21/ada_url-1.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ecde1d69407c8966814cf8638c3ae27a519927326e112dbbe7a8fddc7e62dd5", size = 2589943, upload-time = "2026-01-08T16:55:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/ed/63/164f65acb5e4ac68782f6703ab56b8efc85063b4495c7de8501d0fee7419/ada_url-1.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9350c0fc398029a9d204946fead03370d6d75d1715268c41fcaf888e85c85dde", size = 2724043, upload-time = "2026-01-08T16:55:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/f4/19/be43601590af920e97c2e6a7b158a9ecf3e5c3fb473cd2bd1c6b31da1146/ada_url-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:c85d531dcd7845d0cb1ba437f5741a2bda9ff6b8f99c21d003eecb5c36b06533", size = 448722, upload-time = "2026-01-08T16:55:48.439Z" }, +] + +[[package]] +name = "aiomcache" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/0a/914d8df1002d88ca70679d192f6e16d113e6b5cbcc13c51008db9230025f/aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359", size = 10640, upload-time = "2024-05-07T15:03:14.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/f8/78455f6377cbe85f335f4dbd40a807dafb72bd5fa05eb946f2ad0cec3d40/aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e", size = 10145, upload-time = "2024-05-07T15:03:12.003Z" }, ] [[package]] @@ -509,6 +518,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, ] +[[package]] +name = "fastapi-cache2" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pendulum" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/6f/7c2078bf097634276a266fe225d9d6a1f882fe505a662bd1835fb2cf6891/fastapi_cache2-0.2.2.tar.gz", hash = "sha256:71bf4450117dc24224ec120be489dbe09e331143c9f74e75eb6f576b78926026", size = 17950, upload-time = "2024-07-24T15:47:21.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b3/ce7c5d9f5e75257a3039ee1e38feb77bee29da3a1792c57d6ea1acb55d17/fastapi_cache2-0.2.2-py3-none-any.whl", hash = "sha256:e1fae86d8eaaa6c8501dfe08407f71d69e87cc6748042d59d51994000532846c", size = 25411, upload-time = "2024-07-24T15:47:19.065Z" }, +] + +[package.optional-dependencies] +memcache = [ + { name = "aiomcache" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + [[package]] name = "greenlet" version = "3.3.0" @@ -950,60 +991,60 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" }, - { url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" }, - { url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" }, - { url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" }, - { url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" }, - { url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" }, - { url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" }, - { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" }, - { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" }, - { url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" }, - { url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" }, - { url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" }, - { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" }, - { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" }, - { url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" }, - { url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" }, - { url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" }, - { url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" }, - { url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" }, - { url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" }, - { url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" }, - { url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" }, - { url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" }, - { url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" }, - { url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" }, - { url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, ] [[package]] @@ -1080,11 +1121,54 @@ wheels = [ [[package]] name = "pathspec" -version = "1.0.1" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/28/2e/83722ece0f6ee24387d6cb830dd562ddbcd6ce0b9d76072c6849670c31b4/pathspec-1.0.1.tar.gz", hash = "sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c", size = 129791, upload-time = "2026-01-06T13:02:55.15Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fe/2257c71721aeab6a6e8aa1f00d01f2a20f58547d249a6c8fef5791f559fc/pathspec-1.0.1-py3-none-any.whl", hash = "sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721", size = 54584, upload-time = "2026-01-06T13:02:53.601Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[package]] +name = "pendulum" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/7c/009c12b86c7cc6c403aec80f8a4308598dfc5995e5c523a5491faaa3952e/pendulum-3.1.0.tar.gz", hash = "sha256:66f96303560f41d097bee7d2dc98ffca716fbb3a832c4b3062034c2d45865015", size = 85930, upload-time = "2025-04-19T14:30:01.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/6e/d28d3c22e6708b819a94c05bd05a3dfaed5c685379e8b6dc4b34b473b942/pendulum-3.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:61a03d14f8c64d13b2f7d5859e4b4053c4a7d3b02339f6c71f3e4606bfd67423", size = 338596, upload-time = "2025-04-19T14:01:11.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/43324d58021d463c2eeb6146b169d2c935f2f840f9e45ac2d500453d954c/pendulum-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e674ed2d158afa5c361e60f1f67872dc55b492a10cacdaa7fcd7b7da5f158f24", size = 325854, upload-time = "2025-04-19T14:01:13.156Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a7/d2ae79b960bfdea94dab67e2f118697b08bc9e98eb6bd8d32c4d99240da3/pendulum-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c75377eb16e58bbe7e03ea89eeea49be6fc5de0934a4aef0e263f8b4fa71bc2", size = 344334, upload-time = "2025-04-19T14:01:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/94/941f071212e23c29aae7def891fb636930c648386e059ce09ea0dcd43933/pendulum-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:656b8b0ce070f0f2e5e2668247d3c783c55336534aa1f13bd0969535878955e1", size = 382259, upload-time = "2025-04-19T14:01:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/a78a701656aec00d16fee636704445c23ca11617a0bfe7c3848d1caa5157/pendulum-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48962903e6c1afe1f13548cb6252666056086c107d59e3d64795c58c9298bc2e", size = 436361, upload-time = "2025-04-19T14:01:18.796Z" }, + { url = "https://files.pythonhosted.org/packages/da/93/83f59ccbf4435c29dca8c63a6560fcbe4783079a468a5f91d9f886fd21f0/pendulum-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d364ec3f8e65010fefd4b0aaf7be5eb97e5df761b107a06f5e743b7c3f52c311", size = 353653, upload-time = "2025-04-19T14:01:20.159Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/42d6644ec6339b41066f594e52d286162aecd2e9735aaf994d7e00c9e09d/pendulum-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd52caffc2afb86612ec43bbeb226f204ea12ebff9f3d12f900a7d3097210fcc", size = 524567, upload-time = "2025-04-19T14:01:21.457Z" }, + { url = "https://files.pythonhosted.org/packages/de/45/d84d909202755ab9d3379e5481fdf70f53344ebefbd68d6f5803ddde98a6/pendulum-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d439fccaa35c91f686bd59d30604dab01e8b5c1d0dd66e81648c432fd3f8a539", size = 525571, upload-time = "2025-04-19T14:01:23.329Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/4de160773ce3c2f7843c310db19dd919a0cd02cc1c0384866f63b18a6251/pendulum-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:43288773a86d9c5c0ddb645f88f615ff6bd12fd1410b34323662beccb18f3b49", size = 260259, upload-time = "2025-04-19T14:01:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7f/ffa278f78112c6c6e5130a702042f52aab5c649ae2edf814df07810bbba5/pendulum-3.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:569ea5072ae0f11d625e03b36d865f8037b76e838a3b621f6967314193896a11", size = 253899, upload-time = "2025-04-19T14:01:26.442Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/b1bfe15a742f2c2713acb1fdc7dc3594ff46ef9418ac6a96fcb12a6ba60b/pendulum-3.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4dfd53e7583ccae138be86d6c0a0b324c7547df2afcec1876943c4d481cf9608", size = 336209, upload-time = "2025-04-19T14:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/87/0392da0c603c828b926d9f7097fbdddaafc01388cb8a00888635d04758c3/pendulum-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a6e06a28f3a7d696546347805536f6f38be458cb79de4f80754430696bea9e6", size = 323130, upload-time = "2025-04-19T14:01:29.336Z" }, + { url = "https://files.pythonhosted.org/packages/c0/61/95f1eec25796be6dddf71440ee16ec1fd0c573fc61a73bd1ef6daacd529a/pendulum-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e68d6a51880708084afd8958af42dc8c5e819a70a6c6ae903b1c4bfc61e0f25", size = 341509, upload-time = "2025-04-19T14:01:31.1Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7b/eb0f5e6aa87d5e1b467a1611009dbdc92f0f72425ebf07669bfadd8885a6/pendulum-3.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e3f1e5da39a7ea7119efda1dd96b529748c1566f8a983412d0908455d606942", size = 378674, upload-time = "2025-04-19T14:01:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/5a4c1b5de3e54e16cab21d2ec88f9cd3f18599e96cc90a441c0b0ab6b03f/pendulum-3.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9af1e5eeddb4ebbe1b1c9afb9fd8077d73416ade42dd61264b3f3b87742e0bb", size = 436133, upload-time = "2025-04-19T14:01:34.349Z" }, + { url = "https://files.pythonhosted.org/packages/87/5d/f7a1d693e5c0f789185117d5c1d5bee104f5b0d9fbf061d715fb61c840a8/pendulum-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f74aa8029a42e327bfc150472e0e4d2358fa5d795f70460160ba81b94b6945", size = 351232, upload-time = "2025-04-19T14:01:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/c97617eb31f1d0554edb073201a294019b9e0a9bd2f73c68e6d8d048cd6b/pendulum-3.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:cf6229e5ee70c2660148523f46c472e677654d0097bec010d6730f08312a4931", size = 521562, upload-time = "2025-04-19T14:01:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/0d0ef3393303877e757b848ecef8a9a8c7627e17e7590af82d14633b2cd1/pendulum-3.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:350cabb23bf1aec7c7694b915d3030bff53a2ad4aeabc8c8c0d807c8194113d6", size = 523221, upload-time = "2025-04-19T14:01:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/99/f3/aefb579aa3cebd6f2866b205fc7a60d33e9a696e9e629024752107dc3cf5/pendulum-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:42959341e843077c41d47420f28c3631de054abd64da83f9b956519b5c7a06a7", size = 260502, upload-time = "2025-04-19T14:01:39.814Z" }, + { url = "https://files.pythonhosted.org/packages/02/74/4332b5d6e34c63d4df8e8eab2249e74c05513b1477757463f7fdca99e9be/pendulum-3.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:006758e2125da2e624493324dfd5d7d1b02b0c44bc39358e18bf0f66d0767f5f", size = 253089, upload-time = "2025-04-19T14:01:41.171Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1f/af928ba4aa403dac9569f787adcf024005e7654433d71f7a84e608716837/pendulum-3.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:28658b0baf4b30eb31d096a375983cfed033e60c0a7bbe94fa23f06cd779b50b", size = 336209, upload-time = "2025-04-19T14:01:42.775Z" }, + { url = "https://files.pythonhosted.org/packages/b6/16/b010643007ba964c397da7fa622924423883c1bbff1a53f9d1022cd7f024/pendulum-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b114dcb99ce511cb8f5495c7b6f0056b2c3dba444ef1ea6e48030d7371bd531a", size = 323132, upload-time = "2025-04-19T14:01:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/64/19/c3c47aeecb5d9bceb0e89faafd800d39809b696c5b7bba8ec8370ad5052c/pendulum-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2404a6a54c80252ea393291f0b7f35525a61abae3d795407f34e118a8f133a18", size = 341509, upload-time = "2025-04-19T14:01:46.084Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/c06921ff6b860ff7e62e70b8e5d4dc70e36f5abb66d168bd64d51760bc4e/pendulum-3.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d06999790d9ee9962a1627e469f98568bf7ad1085553fa3c30ed08b3944a14d7", size = 378674, upload-time = "2025-04-19T14:01:47.727Z" }, + { url = "https://files.pythonhosted.org/packages/62/0b/a43953b9eba11e82612b033ac5133f716f1b76b6108a65da6f408b3cc016/pendulum-3.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94751c52f6b7c306734d1044c2c6067a474237e1e5afa2f665d1fbcbbbcf24b3", size = 436133, upload-time = "2025-04-19T14:01:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a0/ec3d70b3b96e23ae1d039f132af35e17704c22a8250d1887aaefea4d78a6/pendulum-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5553ac27be05e997ec26d7f004cf72788f4ce11fe60bb80dda604a64055b29d0", size = 351232, upload-time = "2025-04-19T14:01:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/f4/97/aba23f1716b82f6951ba2b1c9178a2d107d1e66c102762a9bf19988547ea/pendulum-3.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f8dee234ca6142bf0514368d01a72945a44685aaa2fc4c14c98d09da9437b620", size = 521563, upload-time = "2025-04-19T14:01:51.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/33/2c0d5216cc53d16db0c4b3d510f141ee0a540937f8675948541190fbd48b/pendulum-3.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7378084fe54faab4ee481897a00b710876f2e901ded6221671e827a253e643f2", size = 523221, upload-time = "2025-04-19T14:01:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/51/89/8de955c339c31aeae77fd86d3225509b998c81875e9dba28cb88b8cbf4b3/pendulum-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8539db7ae2c8da430ac2515079e288948c8ebf7eb1edd3e8281b5cdf433040d6", size = 260501, upload-time = "2025-04-19T14:01:54.749Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/226a3837363e94f8722461848feec18bfdd7d5172564d53aa3c3397ff01e/pendulum-3.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:1ce26a608e1f7387cd393fba2a129507c4900958d4f47b90757ec17656856571", size = 253087, upload-time = "2025-04-19T14:01:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/6e/23/e98758924d1b3aac11a626268eabf7f3cf177e7837c28d47bf84c64532d0/pendulum-3.1.0-py3-none-any.whl", hash = "sha256:f9178c2a8e291758ade1e8dd6371b1d26d08371b4c7730a6e9a3ef8b16ebae0f", size = 111799, upload-time = "2025-04-19T14:02:34.739Z" }, ] [[package]] @@ -1524,6 +1608,7 @@ dependencies = [ { name = "cryptography" }, { name = "dp-sdk" }, { name = "fastapi" }, + { name = "fastapi-cache2", extra = ["memcache"] }, { name = "numpy" }, { name = "pvsite-datamodel" }, { name = "pyhocon" }, @@ -1531,11 +1616,13 @@ dependencies = [ { name = "pyproj" }, { name = "pytz" }, { name = "sentry-sdk" }, + { name = "sqlalchemy" }, { name = "uvicorn" }, ] [package.dev-dependencies] dev = [ + { name = "freezegun" }, { name = "pandas-stubs" }, { name = "pylsp-mypy" }, { name = "pytest" }, @@ -1556,6 +1643,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=41.0.0" }, { name = "dp-sdk", url = "https://github.com/openclimatefix/data-platform/releases/download/v0.18.2/dp_sdk-0.18.2-py3-none-any.whl" }, { name = "fastapi", specifier = ">=0.105.0" }, + { name = "fastapi-cache2", extras = ["memcache"], specifier = ">=0.2.2" }, { name = "numpy", specifier = ">=1.25.0" }, { name = "pvsite-datamodel", specifier = "==1.2.3" }, { name = "pyhocon", specifier = ">=0.3.61" }, @@ -1563,11 +1651,13 @@ requires-dist = [ { name = "pyproj", specifier = ">=3.3.0" }, { name = "pytz", specifier = ">=2023.3" }, { name = "sentry-sdk", specifier = ">=2.1.1" }, + { name = "sqlalchemy", specifier = ">=2.0.44" }, { name = "uvicorn", specifier = ">=0.24.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "freezegun", specifier = ">=1.5.5" }, { name = "pandas-stubs", specifier = ">=2.3.2.250926" }, { name = "pylsp-mypy", specifier = ">=0.6.8" }, { name = "pytest", specifier = ">=8.0.0" }, @@ -1598,28 +1688,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +version = "0.14.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, ] [[package]] @@ -1640,15 +1730,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.48.0" +version = "2.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/94/23ac26616a883f492428d9ee9ad6eee391612125326b784dbfc30e1e7bab/sentry_sdk-2.49.0.tar.gz", hash = "sha256:c1878599cde410d481c04ef50ee3aedd4f600e4d0d253f4763041e468b332c30", size = 387228, upload-time = "2026-01-08T09:56:25.642Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" }, ] [[package]] @@ -1708,7 +1798,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.13.3" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -1717,42 +1807,45 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/5a/d24f5c7ef787fc152b1e4e4cfb84ef9364dbf165b3c7f7817e2f2583f749/testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7", size = 79885, upload-time = "2026-01-07T23:35:22.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/53efc88d890d7dd38337424a83bbff32007d9d3390a79a4b53bfddaa64e8/testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59", size = 125385, upload-time = "2026-01-07T23:35:21.343Z" }, ] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] @@ -1843,23 +1936,23 @@ wheels = [ [[package]] name = "unittest-xml-reporting" -version = "3.2.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/40/3bf1afc96e93c7322520981ac4593cbb29daa21b48d32746f05ab5563dca/unittest-xml-reporting-3.2.0.tar.gz", hash = "sha256:edd8d3170b40c3a81b8cf910f46c6a304ae2847ec01036d02e9c0f9b85762d28", size = 18002, upload-time = "2022-01-20T19:09:55.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/6b/5847d0e6e95d08e056f23b3f8cd95bede2d3ade10a1c1a9d5b50916454e1/unittest_xml_reporting-4.0.0.tar.gz", hash = "sha256:bfa1ed65e9e6f33c161d04470d89050458cfb65a5a5d0358834ef7ce037d9136", size = 43152, upload-time = "2026-01-07T15:50:58.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/88/f6e9b87428584a3c62cac768185c438ca6d561367a5d267b293259d76075/unittest_xml_reporting-3.2.0-py2.py3-none-any.whl", hash = "sha256:f3d7402e5b3ac72a5ee3149278339db1a8f932ee405f48bcb9c681372f2717d5", size = 20936, upload-time = "2022-01-20T19:09:53.824Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5d/18d3c571c3a842a0ffbc4ad04637646a991d05eb3eab412b5226bbdbf294/unittest_xml_reporting-4.0.0-py2.py3-none-any.whl", hash = "sha256:e3e24bac8ea27c454d8be1d718851b2c5c8f712d75281920b6af81bdfef2f9bc", size = 20298, upload-time = "2026-01-07T15:50:57.222Z" }, ] [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]]