From c4f2fbc91f1fa73d0cbc8b144daccfcc3073b102 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:58:31 +0000 Subject: [PATCH 01/13] Initial plan From a01f571b555b89c523f34fff13d4f468f6d3c1b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:04:56 +0000 Subject: [PATCH 02/13] Add new API v2.8+ fields and missing async method Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- pirate_weather/api.py | 34 ++++++++++++++++++ pirate_weather/forecast.py | 57 +++++++++++++++++++++++++++++++ pirate_weather/types/languages.py | 5 +++ pirate_weather/types/units.py | 2 +- 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/pirate_weather/api.py b/pirate_weather/api.py index 2423811..d50cd6e 100644 --- a/pirate_weather/api.py +++ b/pirate_weather/api.py @@ -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 = datetime.now(tz) + + 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/forecast.py b/pirate_weather/forecast.py index 2cf89c5..9b08ea2 100644 --- a/pirate_weather/forecast.py +++ b/pirate_weather/forecast.py @@ -24,6 +24,16 @@ class CurrentlyForecast(base.AutoInit): uv_index: int visibility: float ozone: float + # New fields from API v2+ + 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 class MinutelyForecastItem(base.AutoInit): @@ -59,6 +69,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 +132,29 @@ class DailyForecastItem(base.AutoInit): apparent_temperature_min_time: int apparent_temperature_max: float apparent_temperature_max_time: int + # New fields from API v2+ + 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 + rain_intensity_max: float = None + rain_intensity_max_time: int = None + snow_intensity_max: float = None + snow_intensity_max_time: int = None + ice_intensity_max: float = None + ice_intensity_max_time: int = 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): @@ -130,6 +178,15 @@ class Flags(base.AutoInit): nearest__station: float pirate_weather__unavailable: bool units: str + # New fields from API v2+ + source_times: dict = None + source_idx: 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: 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" From fefb42a61b518504532a8b8bdee58dfdb7fde14a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:06:55 +0000 Subject: [PATCH 03/13] Add support for optional day_night forecast block Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- pirate_weather/forecast.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pirate_weather/forecast.py b/pirate_weather/forecast.py index 9b08ea2..54479a9 100644 --- a/pirate_weather/forecast.py +++ b/pirate_weather/forecast.py @@ -162,6 +162,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 @@ -197,6 +242,7 @@ class Forecast: minutely: MinutelyForecast hourly: HourlyForecast daily: DailyForecast + day_night: DayNightForecast alerts: list[Alert] flags: Flags offset: int @@ -210,6 +256,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, @@ -223,6 +270,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 {})) From 976b3040736808b93395ce25a3640ac98c444b85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:09:24 +0000 Subject: [PATCH 04/13] Fix timezone handling bug in get_recent_time_machine_forecast Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- pirate_weather/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pirate_weather/api.py b/pirate_weather/api.py index d50cd6e..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 @@ -245,7 +245,7 @@ async 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 From 127ae9cdc095c0e9c405a93713cdfcf4c4d86a23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:10:10 +0000 Subject: [PATCH 05/13] Update tests with new API v2.8+ data and add test workflow Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- .github/workflows/test.yml | 30 ++++ pirate_weather/forecast.py | 28 +++- tests/data.py | 285 +++++++++++++++++++++++-------------- tests/test_forecast.py | 2 +- 4 files changed, 231 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9848657 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: "Tests" + +on: + pull_request: + types: [opened] + +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.12.4" + + - 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/pirate_weather/forecast.py b/pirate_weather/forecast.py index 54479a9..2046f55 100644 --- a/pirate_weather/forecast.py +++ b/pirate_weather/forecast.py @@ -25,6 +25,9 @@ class CurrentlyForecast(base.AutoInit): 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 @@ -34,6 +37,9 @@ class CurrentlyForecast(base.AutoInit): 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): @@ -42,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): @@ -54,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 @@ -133,6 +144,15 @@ class DailyForecastItem(base.AutoInit): 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 @@ -144,12 +164,6 @@ class DailyForecastItem(base.AutoInit): liquid_accumulation: float = None snow_accumulation: float = None ice_accumulation: float = None - rain_intensity_max: float = None - rain_intensity_max_time: int = None - snow_intensity_max: float = None - snow_intensity_max_time: int = None - ice_intensity_max: float = None - ice_intensity_max_time: int = None current_day_ice: float = None current_day_liquid: float = None current_day_snow: float = None @@ -225,7 +239,7 @@ class Flags(base.AutoInit): units: str # New fields from API v2+ source_times: dict = None - source_idx: dict = None + source_i_d_x: dict = None version: str = None process_time: float = None ingest_version: str = None diff --git a/tests/data.py b/tests/data.py index 3719f5e..9c3a84a 100644 --- a/tests/data.py +++ b/tests/data.py @@ -1,139 +1,212 @@ 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, } ], }, "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..14afdc7 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -50,7 +50,7 @@ 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()]) From 49e813ac47aff97e743ff1b22d77c73b761579a6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:12:53 +0000 Subject: [PATCH 06/13] style fixes by ruff --- tests/data.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/data.py b/tests/data.py index 9c3a84a..4bddb02 100644 --- a/tests/data.py +++ b/tests/data.py @@ -64,7 +64,8 @@ "icon": "partly-cloudy-day", "precipIntensity": 0.0, "precipProbability": 0.0, - "precipIntensityError": 0.0, "precipType": "snow", + "precipIntensityError": 0.0, + "precipType": "snow", "rainIntensity": 0.0, "snowIntensity": 0.0, "iceIntensity": 0.0, @@ -109,7 +110,8 @@ "precipIntensity": 0.0, "precipIntensityMax": 0.0, "precipIntensityMaxTime": 1765173600, - "precipProbability": 0.0, "precipType": "snow", + "precipProbability": 0.0, + "precipType": "snow", "rainIntensity": 0.0, "rainIntensityMax": 0.0, "snowIntensity": 0.0, From 68386f956c51a9b6b9f2598e501beac666686e4d Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 8 Dec 2025 14:15:30 -0500 Subject: [PATCH 07/13] Update python and API version --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 3 +-- setup.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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 index 9848657..e2395bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,6 @@ name: "Tests" on: pull_request: - types: [opened] permissions: contents: read @@ -18,7 +17,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/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: From 65aa2f07432cca67bfd826ae28571c7ea3170f30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:20:29 +0000 Subject: [PATCH 08/13] Fix Python 3.14 compatibility by replacing deprecated get_event_loop() Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- tests/test_forecast.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 14afdc7..4c7837c 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -41,8 +41,7 @@ async def get_async_data(): return result - loop = asyncio.get_event_loop() - return loop.run_until_complete(get_async_data()) + return asyncio.run(get_async_data()) @pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) From dc6471fcd1c590a739e685be6092014296773020 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:31:35 +0000 Subject: [PATCH 09/13] Fix unclosed sessions, add day_night test, update README Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++ tests/data.py | 32 +++++++++++++++++++ tests/test_forecast.py | 27 +++++++++++++--- 3 files changed, 124 insertions(+), 5 deletions(-) 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/tests/data.py b/tests/data.py index 4bddb02..04d5982 100644 --- a/tests/data.py +++ b/tests/data.py @@ -159,6 +159,38 @@ } ], }, + "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": "special weather statement in effect", diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 4c7837c..361cfbf 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -33,11 +33,12 @@ async def get_async_data(): 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 @@ -111,6 +112,22 @@ 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] + + @pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_alerts(forecast): for f_item, d_item in zip( From 9896dfbb3c65a515d18e5165092557fdbce833ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:39:36 +0000 Subject: [PATCH 10/13] Fix Python 3.14 test collection issue by using fixtures instead of parametrize Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- tests/test_forecast.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 361cfbf..f235c07 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -20,14 +20,15 @@ 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: @@ -45,7 +46,14 @@ async def get_async_data(): return asyncio.run(get_async_data()) -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) +@pytest.fixture(params=["sync", "async"]) +def forecast(request, forecast_sync, forecast_async): + if request.param == "sync": + return forecast_sync + else: + return forecast_async + + def test_forecast_base_fields(forecast): assert isinstance(forecast, Forecast) assert forecast.latitude == DATA["latitude"] @@ -53,7 +61,6 @@ def test_forecast_base_fields(forecast): 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 +71,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 +86,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 +101,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 +116,6 @@ 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"] @@ -128,7 +131,6 @@ def test_forecast_day_night(forecast): assert getattr(f_item, forecast_key) == d_item[key] -@pytest.mark.parametrize("forecast", [get_forecast_sync(), get_forecast_async()]) def test_forecast_alerts(forecast): for f_item, d_item in zip( forecast.alerts, copy.deepcopy(DATA["alerts"]), strict=False @@ -141,7 +143,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 From 0e57d59aad2ac8232b6528d415cf05fe400396d9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:41:43 +0000 Subject: [PATCH 11/13] style fixes by ruff --- tests/test_forecast.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_forecast.py b/tests/test_forecast.py index f235c07..bdcae9f 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -50,8 +50,7 @@ async def get_async_data(): def forecast(request, forecast_sync, forecast_async): if request.param == "sync": return forecast_sync - else: - return forecast_async + return forecast_async def test_forecast_base_fields(forecast): From 6390ad13050cffd5760d3883d22b23f083f2dec7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:47:00 +0000 Subject: [PATCH 12/13] Fix Python 3.14 compatibility in AutoInit class annotations handling Co-authored-by: cloneofghosts <10248058+cloneofghosts@users.noreply.github.com> --- pirate_weather/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pirate_weather/base.py b/pirate_weather/base.py index b53f797..f3cd122 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 ) From 8da8618bba2441af714698fd2e87e1845f476648 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:47:50 +0000 Subject: [PATCH 13/13] style fixes by ruff --- pirate_weather/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pirate_weather/base.py b/pirate_weather/base.py index f3cd122..d545192 100644 --- a/pirate_weather/base.py +++ b/pirate_weather/base.py @@ -33,9 +33,9 @@ def __init__(self, **params): except (pytz.UnknownTimeZoneError, AttributeError, TypeError): timezone = pytz.UTC - for field in getattr(self.__class__, '__annotations__', {}): + for field in getattr(self.__class__, "__annotations__", {}): api_field = undo_snake_case_key(field) - annotations = getattr(self.__class__, '__annotations__', {}) + annotations = getattr(self.__class__, "__annotations__", {}) if annotations.get(field) == datetime: params[api_field] = get_datetime_from_unix( params.get(api_field), timezone