diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 954425e..b434ea8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v6.1.0 with: - python-version: "3.12.4" + python-version: "3.x" - name: "Install requirements" run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e2395bc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: "Tests" + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: "Run Tests" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v6.0.1" + + - name: "Set up Python" + uses: actions/setup-python@v6.1.0 + with: + python-version: "3.x" + + - name: "Install requirements" + run: | + python -m pip install --upgrade pip + pip install -r requirements-ci.txt + + - name: "Run tests" + run: | + python -m pytest tests/ -v diff --git a/README.md b/README.md index 2e98586..ca25225 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,11 @@ This library for the [Pirate Weather API](https://pirateweather.net) which is an API, and provides access to detailed weather information from around the globe. +**Now supporting Pirate Weather API v2.8+** with new data fields including smoke levels, solar radiation, fire indices, and detailed precipitation breakdowns. + * [Installation](#installation) * [Get started](#get-started) +* [New Features](#new-features) * [Contact us](#contact-us) * [License](#license) @@ -138,6 +141,73 @@ api_key = "0123456789" asyncio.run(main(api_key)) ``` +### New Features + +#### API v2.8+ Support + +This library now supports all Pirate Weather API v2.8+ fields: + +**New Weather Data Fields:** +- `smoke` - Air quality smoke levels (µg/m³) +- `solar` - Solar radiation (W/m²) +- `feelsLike` - Apparent temperature based on wind and humidity +- `cape` - Convective Available Potential Energy +- `fireIndex` - Fire weather index +- `liquidAccumulation`, `snowAccumulation`, `iceAccumulation` - Precipitation by type +- `rainIntensity`, `snowIntensity`, `iceIntensity` - Intensity by precipitation type +- `currentDayIce`, `currentDayLiquid`, `currentDaySnow` - Accumulations for the current day +- `dawnTime`, `duskTime` - Civil twilight times + +**New Metadata Fields:** +- `sourceTimes` - Model update timestamps +- `sourceIDX` - Grid coordinates for each model +- `version` - API version +- `processTime` - Request processing time +- `ingestVersion` - Data ingest version +- `nearestCity`, `nearestCountry`, `nearestSubNational` - Location information + +**Day/Night Forecast Block:** + +The library now supports the optional `day_night` forecast block which provides 12-hour forecast periods: + +```python +forecast = pirate_weather.get_forecast(latitude, longitude) + +# Access day/night forecast data +for period in forecast.day_night.data: + print(f"Time: {period.time}, Temp: {period.temperature}, Smoke: {period.smoke}") +``` + +**Example accessing new fields:** + +```python +forecast = pirate_weather.get_forecast(latitude, longitude) + +# Current conditions with new fields +print(f"Current smoke level: {forecast.currently.smoke} µg/m³") +print(f"Solar radiation: {forecast.currently.solar} W/m²") +print(f"Feels like: {forecast.currently.feels_like}°") +print(f"Fire index: {forecast.currently.fire_index}") + +# Hourly forecasts with precipitation breakdowns +for hour in forecast.hourly.data: + if hour.rain_intensity > 0: + print(f"Rain intensity: {hour.rain_intensity} mm/h") + if hour.snow_intensity > 0: + print(f"Snow intensity: {hour.snow_intensity} cm/h") + +# Daily forecasts with new max fields +for day in forecast.daily.data: + print(f"Max smoke: {day.smoke_max} at {day.smoke_max_time}") + print(f"Max solar: {day.solar_max} at {day.solar_max_time}") + print(f"Dawn: {day.dawn_time}, Dusk: {day.dusk_time}") + +# Metadata +print(f"API Version: {forecast.flags.version}") +print(f"Nearest City: {forecast.flags.nearest_city}") +print(f"Process Time: {forecast.flags.process_time}ms") +``` + ### License. Library is released under the [MIT License](./LICENSE). diff --git a/pirate_weather/api.py b/pirate_weather/api.py index 2423811..4f82c3f 100644 --- a/pirate_weather/api.py +++ b/pirate_weather/api.py @@ -157,7 +157,7 @@ def get_recent_time_machine_forecast( current_time = int(datetime.now().timestamp()) if timezone: tz = pytz.timezone(timezone) - current_time = datetime.now(tz) + current_time = int(datetime.now(tz).timestamp()) diff = required_time - current_time @@ -228,3 +228,37 @@ async def get_time_machine_forecast( session=client_session, ) return Forecast(**data) + + async def get_recent_time_machine_forecast( + self, + latitude: float, + longitude: float, + time: datetime, + client_session: aiohttp.ClientSession, + extend: bool = None, + lang=Languages.ENGLISH, + values_units=Units.AUTO, + exclude: [Weather] = None, + timezone: str = None, + ) -> Forecast: + required_time = int(time.timestamp()) + current_time = int(datetime.now().timestamp()) + if timezone: + tz = pytz.timezone(timezone) + current_time = int(datetime.now(tz).timestamp()) + + diff = required_time - current_time + + exclude = self.convert_exclude_param_to_string(exclude) + + url = self.get_url(latitude, longitude, diff) + data = await self.request_manager.make_request( + url=url, + extend=Weather.HOURLY if extend else None, + lang=lang, + units=values_units, + exclude=exclude, + timezone=timezone, + session=client_session, + ) + return Forecast(**data) diff --git a/pirate_weather/base.py b/pirate_weather/base.py index b53f797..d545192 100644 --- a/pirate_weather/base.py +++ b/pirate_weather/base.py @@ -30,12 +30,13 @@ class AutoInit: def __init__(self, **params): try: timezone = pytz.timezone(params.pop("timezone", None)) - except (pytz.UnknownTimeZoneError, AttributeError): + except (pytz.UnknownTimeZoneError, AttributeError, TypeError): timezone = pytz.UTC - for field in self.__annotations__: + for field in getattr(self.__class__, "__annotations__", {}): api_field = undo_snake_case_key(field) - if self.__annotations__[field] == datetime: + annotations = getattr(self.__class__, "__annotations__", {}) + if annotations.get(field) == datetime: params[api_field] = get_datetime_from_unix( params.get(api_field), timezone ) diff --git a/pirate_weather/forecast.py b/pirate_weather/forecast.py index 2cf89c5..2046f55 100644 --- a/pirate_weather/forecast.py +++ b/pirate_weather/forecast.py @@ -24,6 +24,22 @@ class CurrentlyForecast(base.AutoInit): uv_index: int visibility: float ozone: float + # New fields from API v2+ + rain_intensity: float = None + snow_intensity: float = None + ice_intensity: float = None + smoke: float = None + solar: float = None + feels_like: float = None + cape: float = None + fire_index: float = None + liquid_accumulation: float = None + snow_accumulation: float = None + ice_accumulation: float = None + station_pressure: float = None + current_day_ice: float = None + current_day_liquid: float = None + current_day_snow: float = None class MinutelyForecastItem(base.AutoInit): @@ -32,6 +48,10 @@ class MinutelyForecastItem(base.AutoInit): precip_intensity_error: float precip_probability: float precip_type: str + # New fields from API v2+ + rain_intensity: float = None + snow_intensity: float = None + sleet_intensity: float = None class MinutelyForecast(base.BaseWeather): @@ -44,6 +64,7 @@ class HourlyForecastItem(base.AutoInit): summary: str = None icon: str precip_intensity: float + precip_intensity_error: float = None precip_probability: float precip_type: str precipAccumulation: float @@ -59,6 +80,21 @@ class HourlyForecastItem(base.AutoInit): uv_index: int visibility: float ozone: float + # New fields from API v2+ + nearest_storm_distance: int = None + nearest_storm_bearing: int = None + smoke: float = None + solar: float = None + feels_like: float = None + cape: float = None + fire_index: float = None + liquid_accumulation: float = None + snow_accumulation: float = None + ice_accumulation: float = None + rain_intensity: float = None + snow_intensity: float = None + ice_intensity: float = None + station_pressure: float = None class HourlyForecast(base.BaseWeather): @@ -107,6 +143,32 @@ class DailyForecastItem(base.AutoInit): apparent_temperature_min_time: int apparent_temperature_max: float apparent_temperature_max_time: int + # New fields from API v2+ + rain_intensity: float = None + rain_intensity_max: float = None + rain_intensity_max_time: int = None + snow_intensity: float = None + snow_intensity_max: float = None + snow_intensity_max_time: int = None + ice_intensity: float = None + ice_intensity_max: float = None + ice_intensity_max_time: int = None + smoke_max: float = None + smoke_max_time: int = None + solar_max: float = None + solar_max_time: int = None + cape_max: float = None + cape_max_time: int = None + fire_index_max: float = None + fire_index_max_time: int = None + liquid_accumulation: float = None + snow_accumulation: float = None + ice_accumulation: float = None + current_day_ice: float = None + current_day_liquid: float = None + current_day_snow: float = None + dawn_time: int = None + dusk_time: int = None class DailyForecast(base.BaseWeather): @@ -114,6 +176,51 @@ class DailyForecast(base.BaseWeather): data_class = DailyForecastItem +# DayNight block is similar to hourly but has some additional fields +class DayNightForecastItem(base.AutoInit): + time: int + summary: str = None + icon: str + precip_intensity: float + precip_intensity_max: float = None + precip_probability: float + precip_type: str + precipAccumulation: float + temperature: float + apparent_temperature: float + dew_point: float + humidity: float + pressure: float + wind_speed: float + wind_gust: float + wind_bearing: int + cloud_cover: float + uv_index: int + visibility: float + ozone: float + # Fields that may be in day_night + smoke: float = None + solar: float = None + feels_like: float = None + cape: float = None + fire_index: float = None + liquid_accumulation: float = None + snow_accumulation: float = None + ice_accumulation: float = None + rain_intensity: float = None + snow_intensity: float = None + ice_intensity: float = None + rain_intensity_max: float = None + snow_intensity_max: float = None + ice_intensity_max: float = None + station_pressure: float = None + + +class DayNightForecast(base.BaseWeather): + data: list[DayNightForecastItem] + data_class = DayNightForecastItem + + class Alert(base.AutoInit): title: str regions: list @@ -130,6 +237,15 @@ class Flags(base.AutoInit): nearest__station: float pirate_weather__unavailable: bool units: str + # New fields from API v2+ + source_times: dict = None + source_i_d_x: dict = None + version: str = None + process_time: float = None + ingest_version: str = None + nearest_city: str = None + nearest_country: str = None + nearest_sub_national: str = None class Forecast: @@ -140,6 +256,7 @@ class Forecast: minutely: MinutelyForecast hourly: HourlyForecast daily: DailyForecast + day_night: DayNightForecast alerts: list[Alert] flags: Flags offset: int @@ -153,6 +270,7 @@ def __init__( minutely: dict = None, hourly: dict = None, daily: dict = None, + day_night: dict = None, alerts: [dict] = None, flags: dict = None, offset: int = None, @@ -166,6 +284,7 @@ def __init__( self.minutely = MinutelyForecast(timezone=timezone, **(minutely or {})) self.hourly = HourlyForecast(timezone=timezone, **(hourly or {})) self.daily = DailyForecast(timezone=timezone, **(daily or {})) + self.day_night = DayNightForecast(timezone=timezone, **(day_night or {})) self.alerts = [Alert(timezone=timezone, **alert) for alert in (alerts or [])] self.flags = Flags(timezone=timezone, **(flags or {})) diff --git a/pirate_weather/types/languages.py b/pirate_weather/types/languages.py index 91d39c8..888b802 100644 --- a/pirate_weather/types/languages.py +++ b/pirate_weather/types/languages.py @@ -10,6 +10,7 @@ class Languages(str, Enum): BOSNIAN = "bs" CATALAN = "ca" CZECH = "cs" + WELSH = "cy" DANISH = "da" GERMAN = "de" GREEK = "el" @@ -17,8 +18,11 @@ class Languages(str, Enum): ESPERANTO = "eo" SPANISH = "es" ESTONIAN = "et" + PERSIAN = "fa" FINNISH = "fi" FRENCH = "fr" + IRISH = "ga" + GAELIC = "gd" HEBREW = "he" HINDI = "hi" CROATIAN = "hr" @@ -52,6 +56,7 @@ class Languages(str, Enum): TURKISH = "tr" UKRAINIAN = "uk" URDU = "ur" + VIETNAMESE = "vi" IGPAY_ATINLAY = "x-pig-latin" SIMPLIFIED_CHINESE = "zh" TRADITIONAL_CHINESE = "zh-tw" diff --git a/pirate_weather/types/units.py b/pirate_weather/types/units.py index 2fcc01e..eff1a53 100644 --- a/pirate_weather/types/units.py +++ b/pirate_weather/types/units.py @@ -4,7 +4,7 @@ class Units(str, Enum): AUTO = "auto" CA = "ca" - UK2 = "uk2" + UK = "uk" US = "us" SI = "si" diff --git a/setup.py b/setup.py index 401cfe5..49cbed9 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup -__version__ = "0.0.10" +__version__ = "1.0.0" with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md")) as f: diff --git a/tests/data.py b/tests/data.py index 3719f5e..04d5982 100644 --- a/tests/data.py +++ b/tests/data.py @@ -1,139 +1,246 @@ DATA = { - "latitude": 42.3601, - "longitude": -71.0589, - "timezone": "America/New_York", + "latitude": 50.45, + "longitude": -104.617, + "timezone": "America/Mexico_City", + "offset": -6.0, + "elevation": 579, "currently": { - "time": 1509993277, - "summary": "Drizzle", - "icon": "rain", - "nearestStormDistance": 0, - "precipIntensity": 0.0089, - "precipIntensityError": 0.0046, - "precipProbability": 0.9, - "precipType": "rain", - "temperature": 66.1, - "apparentTemperature": 66.31, - "dewPoint": 60.77, + "time": 1765220280, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "nearestStormDistance": 183.55, + "nearestStormBearing": 101, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "none", + "rainIntensity": 0.0, + "snowIntensity": 0.0, + "iceIntensity": 0.0, + "temperature": -7.54, + "apparentTemperature": -13.5, + "dewPoint": -9.95, "humidity": 0.83, - "pressure": 1010.34, - "windSpeed": 5.59, - "windGust": 12.03, - "windBearing": 246, - "cloudCover": 0.7, - "uvIndex": 1, - "visibility": 9.84, - "ozone": 267.44, + "pressure": 1008.31, + "windSpeed": 20.26, + "windGust": 32.87, + "windBearing": 315, + "cloudCover": 0.52, + "uvIndex": 1.51, + "visibility": 15.96, + "ozone": 396.59, + "smoke": 0.01, + "fireIndex": 6.32, + "feelsLike": -13.5, + "currentDayIce": 0.0, + "currentDayLiquid": 0.0, + "currentDaySnow": 0.2593, + "solar": 274.49, + "cape": 0, }, "minutely": { - "summary": "Light rain stopping in 13 min., starting again 30 min. later.", - "icon": "rain", + "summary": "Partly cloudy for the hour.", + "icon": "partly-cloudy-day", "data": [ { - "time": 1509993240, - "precipIntensity": 0.007, - "precipIntensityError": 0.004, - "precipProbability": 0.84, - "precipType": "rain", + "time": 1765220280, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "none", + "rainIntensity": 0.0, + "snowIntensity": 0.0, + "sleetIntensity": 0.0, } ], }, "hourly": { - "summary": "Rain starting later this afternoon, continuing until this evening.", - "icon": "rain", + "summary": "Light sleet tomorrow morning.", + "icon": "sleet", "data": [ { - "time": 1509991200, - "summary": "Mostly Cloudy", + "time": 1765216800, + "summary": "Partly Cloudy", "icon": "partly-cloudy-day", - "precipIntensity": 0.0007, - "precipProbability": 0.1, - "precipType": "rain", - "temperature": 65.76, - "apparentTemperature": 66.01, - "dewPoint": 60.99, - "humidity": 0.85, - "pressure": 1010.57, - "windSpeed": 4.23, - "windGust": 9.52, - "windBearing": 230, - "cloudCover": 0.62, - "uvIndex": 1, - "visibility": 9.32, - "ozone": 268.95, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "snow", + "rainIntensity": 0.0, + "snowIntensity": 0.0, + "iceIntensity": 0.0, + "temperature": -7.04, + "apparentTemperature": -13.35, + "dewPoint": -8.0, + "humidity": 0.91, + "pressure": 1008.55, + "windSpeed": 21.6, + "windGust": 33.84, + "windBearing": 310, + "cloudCover": 0.58, + "uvIndex": 1.24, + "visibility": 16.09, + "ozone": 406.56, + "smoke": 0.0, + "liquidAccumulation": 0.0, + "snowAccumulation": 0.0, + "iceAccumulation": 0.0, + "nearestStormDistance": 151.46, + "nearestStormBearing": 104, + "fireIndex": 6.0, + "feelsLike": -14.45, + "solar": 227.36, + "cape": 0, } ], }, "daily": { - "summary": "Mixed precipitation throughout the week, with temperatures falling to 39°F on Saturday.", - "icon": "rain", + "summary": "Mixed precipitation tomorrow through Thursday, with high temperatures bottoming out at -25°C on Friday.", + "icon": "sleet", "data": [ { - "time": 1509944400, - "summary": "Rain starting in the afternoon, continuing until evening.", - "icon": "rain", - "sunriseTime": 1509967519, - "sunsetTime": 1510003982, - "moonPhase": 0.59, - "precipIntensity": 0.0088, - "precipIntensityMax": 0.0725, - "precipIntensityMaxTime": 1510002000, - "precipProbability": 0.73, - "precipType": "rain", - "temperatureHigh": 66.35, - "temperatureHighTime": 1509994800, - "temperatureLow": 41.28, - "temperatureLowTime": 1510056000, - "apparentTemperatureHigh": 66.53, - "apparentTemperatureHighTime": 1509994800, - "apparentTemperatureLow": 35.74, - "apparentTemperatureLowTime": 1510056000, - "dewPoint": 57.66, - "humidity": 0.86, - "pressure": 1012.93, - "windSpeed": 3.22, - "windGust": 26.32, - "windGustTime": 1510023600, - "windBearing": 270, - "cloudCover": 0.8, - "uvIndex": 2, - "uvIndexTime": 1509987600, - "visibility": 10, - "ozone": 269.45, - "temperatureMin": 52.08, - "temperatureMinTime": 1510027200, - "temperatureMax": 66.35, - "temperatureMaxTime": 1509994800, - "apparentTemperatureMin": 52.08, - "apparentTemperatureMinTime": 1510027200, - "apparentTemperatureMax": 66.53, - "apparentTemperatureMaxTime": 1509994800, + "time": 1765173600, + "summary": "Mostly cloudy in the morning and evening.", + "icon": "partly-cloudy-day", + "dawnTime": 1765202837, + "sunriseTime": 1765205195, + "sunsetTime": 1765234463, + "duskTime": 1765236820, + "moonPhase": 0.63, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1765173600, + "precipProbability": 0.0, + "precipType": "snow", + "rainIntensity": 0.0, + "rainIntensityMax": 0.0, + "snowIntensity": 0.0, + "snowIntensityMax": 0.0, + "iceIntensity": 0.0, + "iceIntensityMax": 0.0, + "temperatureHigh": -6.24, + "temperatureHighTime": 1765224000, + "temperatureLow": -9.59, + "temperatureLowTime": 1765242000, + "apparentTemperatureHigh": -11.82, + "apparentTemperatureHighTime": 1765227600, + "apparentTemperatureLow": -15.03, + "apparentTemperatureLowTime": 1765245600, + "dewPoint": -10.92, + "humidity": 0.91, + "pressure": 1007.67, + "windSpeed": 15.24, + "windGust": 22.26, + "windGustTime": 1765216800, + "windBearing": 198, + "cloudCover": 0.76, + "uvIndex": 1.52, + "uvIndexTime": 1765220400, + "visibility": 15.55, + "temperatureMin": -13.75, + "temperatureMinTime": 1765173600, + "temperatureMax": -6.24, + "temperatureMaxTime": 1765224000, + "apparentTemperatureMin": -20.23, + "apparentTemperatureMinTime": 1765180800, + "apparentTemperatureMax": -11.82, + "apparentTemperatureMaxTime": 1765227600, + "smokeMax": 0.02, + "smokeMaxTime": 1765234800, + "liquidAccumulation": 0.0, + "snowAccumulation": 0.0, + "iceAccumulation": 0.0, + "fireIndexMax": 8.0, + "fireIndexMaxTime": 1765238400, + "solarMax": 272.62, + "solarMaxTime": 1765220400, + "capeMax": 0.0, + "capeMaxTime": 1765173600, + } + ], + }, + "day_night": { + "summary": "Test day/night forecast", + "icon": "partly-cloudy-day", + "data": [ + { + "time": 1765216800, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipProbability": 0.0, + "precipType": "snow", + "temperature": -7.04, + "apparentTemperature": -13.35, + "dewPoint": -8.0, + "humidity": 0.91, + "pressure": 1008.55, + "windSpeed": 21.6, + "windGust": 33.84, + "windBearing": 310, + "cloudCover": 0.58, + "uvIndex": 1.24, + "visibility": 16.09, + "ozone": 406.56, + "smoke": 0.0, + "solar": 227.36, + "feelsLike": -14.45, + "cape": 0, + "fireIndex": 6.0, } ], }, "alerts": [ { - "title": "Flood Watch for Mason, WA", - "time": 1509993360, - "expires": 1510036680, - "description": "...FLOOD WATCH REMAINS IN EFFECT THROUGH LATE MONDAY NIGHT...\nTHE FLOOD WATCH CONTINUES FOR\n* A PORTION OF NORTHWEST WASHINGTON...INCLUDING THE FOLLOWING\nCOUNTY...MASON.\n* THROUGH LATE FRIDAY NIGHT\n* A STRONG WARM FRONT WILL BRING HEAVY RAIN TO THE OLYMPICS\nTONIGHT THROUGH THURSDAY NIGHT. THE HEAVY RAIN WILL PUSH THE\nSKOKOMISH RIVER ABOVE FLOOD STAGE TODAY...AND MAJOR FLOODING IS\nPOSSIBLE.\n* A FLOOD WARNING IS IN EFFECT FOR THE SKOKOMISH RIVER. THE FLOOD\nWATCH REMAINS IN EFFECT FOR MASON COUNTY FOR THE POSSIBILITY OF\nAREAL FLOODING ASSOCIATED WITH A MAJOR FLOOD.\n", - "uri": "http://alerts.weather.gov/cap/wwacapget.php?x=WA1255E4DB8494.FloodWatch.1255E4DCE35CWA.SEWFFASEW.38e78ec64613478bb70fc6ed9c87f6e6", + "title": "special weather statement in effect", + "regions": ["City of Regina"], + "severity": "Minor", + "time": 1765189611, + "expires": 1765247211, + "description": "Strong winds and a wintry mix of precipitation is expected.\n\nLocations: Portions of southern Saskatchewan and southern Manitoba.\n\nTime span: Tuesday morning through to Wednesday morning.\n\nRemarks: A low-pressure system is expected to develop just east of the Rocky Mountains over southern Alberta Monday night and track southeast into southern Saskatchewan and western North Dakota on Tuesday.\n\nA narrow band of freezing rain is possible beginning Tuesday morning, extending from Kindersley to Estevan, presenting potential hazardous travel conditions.\n\nAs the system progresses eastward, heavier snowfall is likely across southeastern Saskatchewan and southwestern Manitoba, including the parklands. While exact accumulations remain uncertain, a general range of 10 to 20 cm is anticipated, with higher totals possible over elevated terrain.\n\nBehind the system, winds will strengthen and could produce blowing snow across southern Saskatchewan on Tuesday. However, warmer air and the potential for freezing rain in southern areas may limit both snowfall and the extent of blowing snow there. The windiest locations are expected to see reduced new-snow amounts, except in southeast Saskatchewan where sufficient snowfall combined with stronger winds may still result in localized blowing snow.\n\nMeteorologists are actively monitoring the evolving situation, and it is anticipated that warnings will be issued later today.\n\n###\n\nPlease continue to monitor alerts and forecasts issued by Environment Canada. To report severe weather, send an email to SKstorm@ec.gc.ca, call 1-800-239-0484 or post reports on X using #SKStorm.\n\nFor more information about the alerting program, please visit: https://www.canada.ca/en/services/environment/weather/severeweather/weather-alerts/colour-coded-alerts.", + "uri": "https://severeweather.wmo.int/v2/cap-alerts/ca-msc-xx/2025/12/08/10/26/51-5f851e56aa977246cbaef9166ec56197.xml", } ], "flags": { "sources": [ - "nwspa", - "cmc", + "ETOPO1", + "hrrrsubh", + "rtma_ru", + "hrrr_0-18", + "nbm", + "nbm_fire", + "ecmwf_ifs", + "hrrr_18-48", "gfs", - "hrrr", - "icon", - "isd", - "madis", - "nam", - "sref", - "darksky", - "nearest-precip", + "gefs", ], - "nearest-station": 1.835, - "units": "us", + "sourceTimes": { + "hrrr_subh": "2025-12-08 17Z", + "rtma_ru": "2025-12-08 18:30Z", + "hrrr_0-18": "2025-12-08 16Z", + "nbm": "2025-12-08 15Z", + "nbm_fire": "2025-12-08 12Z", + "ecmwf_ifs": "2025-12-08 00Z", + "hrrr_18-48": "2025-12-08 12Z", + "gfs": "2025-12-08 12Z", + "gefs": "2025-12-08 06Z", + }, + "nearest-station": 0, + "units": "ca", + "version": "V2.8.5", + "sourceIDX": { + "hrrr": {"x": 727, "y": 982, "lat": 50.45, "lon": -104.63}, + "nbm": {"x": 989, "y": 1270, "lat": 50.45, "lon": -104.6}, + "ecmwf_ifs": {"x": 302, "y": 158, "lat": 50.5, "lon": -104.5}, + "rtma_ru": {"x": 989, "y": 1270, "lat": 50.45, "lon": -104.6}, + "gfs": {"x": 1022, "y": 562, "lat": 50.5, "lon": -104.5}, + "etopo": {"x": 4523, "y": 8427, "lat": 50.45, "lon": -104.6167}, + }, + "processTime": 32951, + "ingestVersion": "v30", + "nearestCity": "Regina", + "nearestCountry": "Canada", + "nearestSubNational": "Saskatchewan", }, } diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 14758a2..bdcae9f 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -20,40 +20,46 @@ sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))) -@mock.patch("requests.Session", mokcs.MockSession) -def get_forecast_sync() -> Forecast: - pirate_weather = PirateWeather("api_key") +@pytest.fixture +def forecast_sync(): + with mock.patch("requests.Session", mokcs.MockSession): + pirate_weather = PirateWeather("api_key") + return pirate_weather.get_forecast(DATA["latitude"], DATA["longitude"]) - return pirate_weather.get_forecast(DATA["latitude"], DATA["longitude"]) - -def get_forecast_async(): +@pytest.fixture +def forecast_async(): async def get_async_data(): pirate_weather = PirateWeatherAsync("api_key") with aioresponses.aioresponses() as resp: resp.get(re.compile(".+"), status=200, payload=copy.deepcopy(DATA)) - result = await pirate_weather.get_forecast( - DATA["latitude"], - DATA["longitude"], - client_session=aiohttp.ClientSession(), - ) + async with aiohttp.ClientSession() as session: + result = await pirate_weather.get_forecast( + DATA["latitude"], + DATA["longitude"], + client_session=session, + ) return result - loop = asyncio.get_event_loop() - return loop.run_until_complete(get_async_data()) + return asyncio.run(get_async_data()) + + +@pytest.fixture(params=["sync", "async"]) +def forecast(request, forecast_sync, forecast_async): + if request.param == "sync": + return forecast_sync + return forecast_async -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_base_fields(forecast): assert isinstance(forecast, Forecast) assert forecast.latitude == DATA["latitude"] assert forecast.longitude == DATA["longitude"] - assert forecast.timezone == "America/New_York" + assert forecast.timezone == DATA["timezone"] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_currently(forecast): f_item, d_item = forecast.currently, copy.deepcopy(DATA["currently"]) for key in d_item: @@ -64,7 +70,6 @@ def test_forecast_currently(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_minutely(forecast): assert forecast.minutely.summary == DATA["minutely"]["summary"] assert forecast.minutely.icon == DATA["minutely"]["icon"] @@ -80,7 +85,6 @@ def test_forecast_minutely(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_hourly(forecast): assert forecast.hourly.summary == DATA["hourly"]["summary"] assert forecast.hourly.icon == DATA["hourly"]["icon"] @@ -96,7 +100,6 @@ def test_forecast_hourly(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_daily(forecast): assert forecast.daily.summary == DATA["daily"]["summary"] assert forecast.daily.icon == DATA["daily"]["icon"] @@ -112,7 +115,21 @@ def test_forecast_daily(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) +def test_forecast_day_night(forecast): + assert forecast.day_night.summary == DATA["day_night"]["summary"] + assert forecast.day_night.icon == DATA["day_night"]["icon"] + + for f_item, d_item in zip( + forecast.day_night.data, copy.deepcopy(DATA["day_night"]["data"]), strict=False + ): + for key in d_item: + forecast_key = utils.snake_case_key(key) + if isinstance(getattr(f_item, forecast_key), datetime): + d_item[key] = get_datetime_from_unix(d_item[key]) + assert hasattr(f_item, forecast_key) + assert getattr(f_item, forecast_key) == d_item[key] + + def test_forecast_alerts(forecast): for f_item, d_item in zip( forecast.alerts, copy.deepcopy(DATA["alerts"]), strict=False @@ -125,7 +142,6 @@ def test_forecast_alerts(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_flags(forecast): d_item = copy.deepcopy(DATA["flags"]) f_item = forecast.flags