From 86f748a5427aac809828dbe3f251fe57e7fbe55a Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 19 Mar 2026 01:34:27 +0000 Subject: [PATCH 1/2] feat: auto unit preference based on location country (#487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'Auto (based on location)' option to temperature unit setting - Detect country from location coordinates via country_code field - Unit mapping: US→°F/mph, GB→°C/mph, CA→°C/km-h, default→°C/m-s - PW client uses matching unit system per location - New unit_utils.py with country-to-unit resolution - Display layer resolves effective unit preference per-location --- .../presentation/current_conditions.py | 70 ++++++++++++------- .../display/presentation/formatters.py | 21 +++++- .../display/weather_presenter.py | 26 ++++--- src/accessiweather/pirate_weather_client.py | 4 +- src/accessiweather/taskbar_icon_updater.py | 56 ++++++++++----- .../ui/dialogs/settings_dialog.py | 16 +++-- src/accessiweather/units.py | 60 ++++++++++++++++ src/accessiweather/utils/unit_utils.py | 52 ++++++++++++++ src/accessiweather/weather_client_base.py | 48 ++++++++++--- tests/test_settings_dialog_tray_text.py | 9 ++- tests/test_system_tray.py | 33 +++++++-- tests/test_weather_client.py | 31 ++++++++ 12 files changed, 347 insertions(+), 79 deletions(-) create mode 100644 src/accessiweather/units.py diff --git a/src/accessiweather/display/presentation/current_conditions.py b/src/accessiweather/display/presentation/current_conditions.py index 7b5c9a5f5..8b4682a01 100644 --- a/src/accessiweather/display/presentation/current_conditions.py +++ b/src/accessiweather/display/presentation/current_conditions.py @@ -15,7 +15,9 @@ TrendInsight, WeatherAlerts, ) +from ...units import DisplayUnitSystem from ...utils import TemperatureUnit +from ...utils.unit_utils import format_precipitation, format_wind_speed from ..priority_engine import PriorityEngine, WeatherCategory from ..weather_presenter import CurrentConditionsPresentation, Metric from .environmental import AirQualityPresentation @@ -40,6 +42,8 @@ def _build_basic_metrics( show_dewpoint: bool, show_visibility: bool, show_uv_index: bool, + *, + unit_system: DisplayUnitSystem | str | None = None, ) -> list[Metric]: """Build basic weather metrics (temperature, feels like, humidity, wind, dewpoint, etc.).""" # Format temperature with inline feels-like when there's a significant difference @@ -55,7 +59,7 @@ def _build_basic_metrics( if current.humidity is not None: metrics.append(Metric("Humidity", f"{current.humidity:.0f}%")) - wind_value = format_wind(current, unit_pref, precision=precision) + wind_value = format_wind(current, unit_pref, precision=precision, unit_system=unit_system) if wind_value: metrics.append(Metric("Wind", wind_value)) @@ -63,11 +67,21 @@ def _build_basic_metrics( if show_dewpoint and dewpoint_value: metrics.append(Metric("Dewpoint", dewpoint_value)) - pressure_value = format_pressure_value(current, unit_pref, precision=precision) + pressure_value = format_pressure_value( + current, + unit_pref, + precision=precision, + unit_system=unit_system, + ) if pressure_value: metrics.append(Metric("Pressure", pressure_value)) - visibility_value = format_visibility_value(current, unit_pref, precision=precision) + visibility_value = format_visibility_value( + current, + unit_pref, + precision=precision, + unit_system=unit_system, + ) if show_visibility and visibility_value: metrics.append(Metric("Visibility", visibility_value)) @@ -79,31 +93,26 @@ def _build_basic_metrics( metrics.append(Metric("Cloud cover", f"{current.cloud_cover:.0f}%")) if current.wind_gust_mph is not None: - if unit_pref == TemperatureUnit.CELSIUS: - gust_kph = current.wind_gust_kph or current.wind_gust_mph * 1.60934 - metrics.append(Metric("Wind gusts", f"{gust_kph:.0f} kph")) - elif unit_pref == TemperatureUnit.FAHRENHEIT: - metrics.append(Metric("Wind gusts", f"{current.wind_gust_mph:.0f} mph")) - else: - gust_kph = current.wind_gust_kph or current.wind_gust_mph * 1.60934 - metrics.append( - Metric("Wind gusts", f"{current.wind_gust_mph:.0f} mph ({gust_kph:.0f} kph)") - ) + gust_value = format_wind_speed( + current.wind_gust_mph, + unit=unit_pref, + wind_speed_kph=current.wind_gust_kph, + precision=0, + unit_system=unit_system, + ) + if unit_system is None: + gust_value = gust_value.replace("km/h", "kph") + metrics.append(Metric("Wind gusts", gust_value)) if current.precipitation_in is not None and current.precipitation_in > 0: - if unit_pref == TemperatureUnit.CELSIUS: - precip_mm = current.precipitation_mm or current.precipitation_in * 25.4 - metrics.append(Metric("Precipitation", f"{precip_mm:.{precision}f} mm")) - elif unit_pref == TemperatureUnit.FAHRENHEIT: - metrics.append(Metric("Precipitation", f"{current.precipitation_in:.{precision}f} in")) - else: - precip_mm = current.precipitation_mm or current.precipitation_in * 25.4 - metrics.append( - Metric( - "Precipitation", - f"{current.precipitation_in:.{precision}f} in ({precip_mm:.{precision}f} mm)", - ) - ) + precip_value = format_precipitation( + current.precipitation_in, + unit=unit_pref, + precipitation_mm=current.precipitation_mm, + precision=precision, + unit_system=unit_system, + ) + metrics.append(Metric("Precipitation", precip_value)) return metrics @@ -430,6 +439,7 @@ def build_current_conditions( hourly_forecast: HourlyForecast | None = None, air_quality: AirQualityPresentation | None = None, alerts: WeatherAlerts | None = None, + unit_system: DisplayUnitSystem | str | None = None, ) -> CurrentConditionsPresentation: """Create a structured presentation for the current weather using helper functions.""" title = f"Current conditions for {location.name}" @@ -467,7 +477,13 @@ def build_current_conditions( metrics: list[Metric] = [] metrics.extend( _build_basic_metrics( - current, unit_pref, precision, show_dewpoint, show_visibility, show_uv_index + current, + unit_pref, + precision, + show_dewpoint, + show_visibility, + show_uv_index, + unit_system=unit_system, ) ) diff --git a/src/accessiweather/display/presentation/formatters.py b/src/accessiweather/display/presentation/formatters.py index 51398f35e..5645d914c 100644 --- a/src/accessiweather/display/presentation/formatters.py +++ b/src/accessiweather/display/presentation/formatters.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta from ...models import CurrentConditions, ForecastPeriod, HourlyForecastPeriod +from ...units import DisplayUnitSystem from ...utils import ( TemperatureUnit, calculate_dewpoint, @@ -30,7 +31,11 @@ def format_temperature_pair( def format_wind( - current: CurrentConditions, unit_pref: TemperatureUnit, precision: int = 1 + current: CurrentConditions, + unit_pref: TemperatureUnit, + precision: int = 1, + *, + unit_system: DisplayUnitSystem | str | None = None, ) -> str | None: """Describe wind direction and speed or return calm when wind is negligible.""" if ( @@ -59,6 +64,7 @@ def format_wind( unit_pref, wind_speed_kph=current.wind_speed_kph, precision=precision, + unit_system=unit_system, ) if direction and speed: return f"{direction} at {speed}" @@ -100,6 +106,8 @@ def format_pressure_value( current: CurrentConditions, unit_pref: TemperatureUnit, precision: int = 1, + *, + unit_system: DisplayUnitSystem | str | None = None, ) -> str | None: """Format station pressure in the preferred unit, if available.""" if current.pressure_in is None and current.pressure_mb is None: @@ -108,13 +116,21 @@ def format_pressure_value( pressure_mb = current.pressure_mb if pressure_in is None and pressure_mb is not None: pressure_in = pressure_mb / 33.8639 - return format_pressure(pressure_in, unit_pref, pressure_mb=pressure_mb, precision=precision) + return format_pressure( + pressure_in, + unit_pref, + pressure_mb=pressure_mb, + precision=precision, + unit_system=unit_system, + ) def format_visibility_value( current: CurrentConditions, unit_pref: TemperatureUnit, precision: int = 1, + *, + unit_system: DisplayUnitSystem | str | None = None, ) -> str | None: """Format horizontal visibility taking unit preference into account.""" if current.visibility_miles is None and current.visibility_km is None: @@ -124,6 +140,7 @@ def format_visibility_value( unit_pref, visibility_km=current.visibility_km, precision=precision, + unit_system=unit_system, ) diff --git a/src/accessiweather/display/weather_presenter.py b/src/accessiweather/display/weather_presenter.py index 065e19efc..cbd5a4851 100644 --- a/src/accessiweather/display/weather_presenter.py +++ b/src/accessiweather/display/weather_presenter.py @@ -29,6 +29,7 @@ WeatherAlerts, WeatherData, ) +from ..units import resolve_display_unit_system, resolve_temperature_unit_preference from ..utils import TemperatureUnit, decode_taf_text from .presentation.environmental import AirQualityPresentation, build_air_quality_panel @@ -193,7 +194,7 @@ def __init__(self, settings: AppSettings): def present(self, weather_data: WeatherData) -> WeatherPresentation: """Build a structured presentation for the given weather data.""" - unit_pref = self._get_temperature_unit_preference() + unit_pref, unit_system = self._resolve_unit_preferences(weather_data.location) air_quality_panel = ( build_air_quality_panel( @@ -214,6 +215,7 @@ def present(self, weather_data: WeatherData) -> WeatherPresentation: hourly_forecast=weather_data.hourly_forecast, air_quality=air_quality_panel, alerts=weather_data.alerts, + unit_system=unit_system, ) if weather_data.current else None @@ -274,7 +276,7 @@ def present_current( ) -> CurrentConditionsPresentation | None: if not current or not current.has_data(): return None - unit_pref = self._get_temperature_unit_preference() + unit_pref, unit_system = self._resolve_unit_preferences(location) air_quality_panel = ( build_air_quality_panel(location, environmental, settings=self.settings) if environmental @@ -290,6 +292,7 @@ def present_current( hourly_forecast=hourly_forecast, air_quality=air_quality_panel, alerts=alerts, + unit_system=unit_system, ) def present_forecast( @@ -301,7 +304,7 @@ def present_forecast( ) -> ForecastPresentation | None: if not forecast or not forecast.has_data(): return None - unit_pref = self._get_temperature_unit_preference() + unit_pref, _unit_system = self._resolve_unit_preferences(location) return self._build_forecast( forecast, hourly_forecast, location, unit_pref, confidence=confidence ) @@ -329,6 +332,7 @@ def _build_current_conditions( hourly_forecast: HourlyForecast | None = None, air_quality: AirQualityPresentation | None = None, alerts: WeatherAlerts | None = None, + unit_system=None, ) -> CurrentConditionsPresentation: return build_current_conditions( current, @@ -340,6 +344,7 @@ def _build_current_conditions( hourly_forecast=hourly_forecast, air_quality=air_quality, alerts=alerts, + unit_system=unit_system, ) def _build_forecast( @@ -644,13 +649,14 @@ def _build_source_attribution( aria_label=aria_label, ) - def _get_temperature_unit_preference(self) -> TemperatureUnit: - unit_pref = (self.settings.temperature_unit or "both").lower() - if unit_pref in {"fahrenheit", "f"}: - return TemperatureUnit.FAHRENHEIT - if unit_pref in {"celsius", "c"}: - return TemperatureUnit.CELSIUS - return TemperatureUnit.BOTH + def _resolve_unit_preferences( + self, location: Location + ) -> tuple[TemperatureUnit, object | None]: + preference = getattr(self.settings, "temperature_unit", "both") + return ( + resolve_temperature_unit_preference(preference, location), + resolve_display_unit_system(preference, location), + ) def _format_timestamp(self, value: datetime) -> str: mode = getattr(self.settings, "time_display_mode", "local") diff --git a/src/accessiweather/pirate_weather_client.py b/src/accessiweather/pirate_weather_client.py index b01523e45..9353205f4 100644 --- a/src/accessiweather/pirate_weather_client.py +++ b/src/accessiweather/pirate_weather_client.py @@ -86,12 +86,12 @@ def __init__( api_key: Pirate Weather API key. user_agent: HTTP User-Agent header value. units: Unit system – "us" (°F, mph, in), "si" (°C, m/s, mm), - "ca" (°C, km/h, mm), or "uk2" (°C, mph, mm). + "ca" (°C, km/h, mm), or "uk"/"uk2" (°C, mph, mm). """ self.api_key = api_key self.user_agent = user_agent - self.units = units + self.units = "uk2" if units == "uk" else units self.timeout = 15.0 def _build_url(self, lat: float, lon: float) -> str: diff --git a/src/accessiweather/taskbar_icon_updater.py b/src/accessiweather/taskbar_icon_updater.py index b63bf4a04..aa4dd0160 100644 --- a/src/accessiweather/taskbar_icon_updater.py +++ b/src/accessiweather/taskbar_icon_updater.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any from .format_string_parser import FormatStringParser +from .units import resolve_display_unit_system, resolve_temperature_unit_preference from .utils.temperature_utils import ( TemperatureUnit, celsius_to_fahrenheit, @@ -142,6 +143,7 @@ def format_tooltip( return DEFAULT_TOOLTIP_TEXT try: + self._active_weather_data = weather_data data = self._extract_weather_variables( current, location_name, @@ -151,6 +153,8 @@ def format_tooltip( except Exception as exc: logger.debug("Failed to format tooltip: %s", exc) return DEFAULT_TOOLTIP_TEXT + finally: + self._active_weather_data = None def format_text( self, @@ -240,9 +244,11 @@ def _format_temperature(self, current: Any) -> str: if temp_f is None and temp_c is None: return PLACEHOLDER_NA - if self.temperature_unit in ("fahrenheit", "f"): + effective_unit = self._resolve_temperature_unit() + + if effective_unit == TemperatureUnit.FAHRENHEIT: return self._format_temp_value(temp_f, "F") - if self.temperature_unit in ("celsius", "c"): + if effective_unit == TemperatureUnit.CELSIUS: if temp_c is not None: return self._format_temp_value(temp_c, "C") return PLACEHOLDER_NA @@ -262,9 +268,11 @@ def _format_feels_like(self, current: Any) -> str: if feels_f is None and feels_c is None: return PLACEHOLDER_NA - if self.temperature_unit in ("fahrenheit", "f"): + effective_unit = self._resolve_temperature_unit() + + if effective_unit == TemperatureUnit.FAHRENHEIT: return self._format_temp_value(feels_f, "F") - if self.temperature_unit in ("celsius", "c"): + if effective_unit == TemperatureUnit.CELSIUS: if feels_c is not None: return self._format_temp_value(feels_c, "C") return PLACEHOLDER_NA @@ -282,14 +290,20 @@ def _format_temp_value(self, value: float | None, suffix: str) -> str: return PLACEHOLDER_NA return f"{value:.0f}{suffix}" - def _normalize_temperature_unit(self) -> TemperatureUnit: - """Normalize legacy short forms to the shared temperature unit enum.""" - normalized = (self.temperature_unit or "both").strip().lower() - if normalized in {"fahrenheit", "f"}: - return TemperatureUnit.FAHRENHEIT - if normalized in {"celsius", "c"}: - return TemperatureUnit.CELSIUS - return TemperatureUnit.BOTH + def _resolve_temperature_unit(self, location: Any | None = None) -> TemperatureUnit: + """Resolve the effective temperature unit for the active location.""" + resolved_location = location + if resolved_location is None and getattr(self, "_active_weather_data", None) is not None: + resolved_location = getattr(self._active_weather_data, "location", None) + return resolve_temperature_unit_preference(self.temperature_unit, resolved_location) + + def _resolve_display_unit_system(self, location: Any | None = None) -> str | None: + """Resolve the effective single-unit display system for the active location.""" + resolved_location = location + if resolved_location is None and getattr(self, "_active_weather_data", None) is not None: + resolved_location = getattr(self._active_weather_data, "location", None) + unit_system = resolve_display_unit_system(self.temperature_unit, resolved_location) + return unit_system.value if unit_system is not None else None def _format_numeric(self, value: float | int | None, suffix: str) -> str: """Format a numeric value with optional suffix.""" @@ -323,9 +337,10 @@ def _format_wind_speed(self, current: Any) -> str: precision = 0 if self.round_values else 1 return format_wind_speed( getattr(current, "wind_speed_mph", None), - unit=self._normalize_temperature_unit(), + unit=self._resolve_temperature_unit(), wind_speed_kph=getattr(current, "wind_speed_kph", None), precision=precision, + unit_system=self._resolve_display_unit_system(), ) def _format_pressure(self, current: Any) -> str: @@ -333,9 +348,10 @@ def _format_pressure(self, current: Any) -> str: precision = 0 if self.round_values else 2 return format_pressure( getattr(current, "pressure_in", None), - unit=self._normalize_temperature_unit(), + unit=self._resolve_temperature_unit(), pressure_mb=getattr(current, "pressure_mb", None), precision=precision, + unit_system=self._resolve_display_unit_system(), ) def _format_visibility(self, current: Any) -> str: @@ -343,9 +359,10 @@ def _format_visibility(self, current: Any) -> str: precision = 0 if self.round_values else 1 return format_visibility( getattr(current, "visibility_miles", None), - unit=self._normalize_temperature_unit(), + unit=self._resolve_temperature_unit(), visibility_km=getattr(current, "visibility_km", None), precision=precision, + unit_system=self._resolve_display_unit_system(), ) def _format_precipitation(self, current: Any) -> str: @@ -359,9 +376,10 @@ def _format_precipitation(self, current: Any) -> str: precision = 0 if self.round_values else 2 return format_precipitation( precip_in, - unit=self._normalize_temperature_unit(), + unit=self._resolve_temperature_unit(), precipitation_mm=getattr(current, "precipitation_mm", None), precision=precision, + unit_system=self._resolve_display_unit_system(), ) def _format_forecast_temperatures( @@ -412,9 +430,11 @@ def _format_forecast_temperature(self, value: float | None, unit_code: str | Non temp_f = value if normalized_unit == "F" else celsius_to_fahrenheit(value) temp_c = value if normalized_unit == "C" else fahrenheit_to_celsius(value) - if self.temperature_unit in ("fahrenheit", "f"): + effective_unit = self._resolve_temperature_unit() + + if effective_unit == TemperatureUnit.FAHRENHEIT: return self._format_temp_value(temp_f, "F") - if self.temperature_unit in ("celsius", "c"): + if effective_unit == TemperatureUnit.CELSIUS: return self._format_temp_value(temp_c, "C") return f"{temp_f:.0f}F/{temp_c:.0f}C" diff --git a/src/accessiweather/ui/dialogs/settings_dialog.py b/src/accessiweather/ui/dialogs/settings_dialog.py index 3ab3f517f..2900a0f86 100644 --- a/src/accessiweather/ui/dialogs/settings_dialog.py +++ b/src/accessiweather/ui/dialogs/settings_dialog.py @@ -157,6 +157,7 @@ def _create_display_tab(self): self._controls["temp_unit"] = wx.Choice( panel, choices=[ + "Auto (based on location)", "Fahrenheit only", "Celsius only", "Both (Fahrenheit and Celsius)", @@ -1319,8 +1320,15 @@ def _load_settings(self): # Display tab temp_unit = getattr(settings, "temperature_unit", "both") - temp_map = {"f": 0, "fahrenheit": 0, "c": 1, "celsius": 1, "both": 2} - self._controls["temp_unit"].SetSelection(temp_map.get(temp_unit, 2)) + temp_map = { + "auto": 0, + "f": 1, + "fahrenheit": 1, + "c": 2, + "celsius": 2, + "both": 3, + } + self._controls["temp_unit"].SetSelection(temp_map.get(temp_unit, 3)) self._controls["show_dewpoint"].SetValue(getattr(settings, "show_dewpoint", True)) self._controls["show_visibility"].SetValue(getattr(settings, "show_visibility", True)) @@ -1575,7 +1583,7 @@ def _save_settings(self) -> bool: try: # Map selections back to values source_values = ["auto", "nws", "openmeteo", "visualcrossing", "pirateweather"] - temp_values = ["f", "c", "both"] + temp_values = ["auto", "f", "c", "both"] forecast_duration_values = [3, 5, 7, 10, 14, 15] forecast_time_reference_values = ["location", "user_local"] time_mode_values = ["local", "utc", "both"] @@ -2977,7 +2985,7 @@ def _on_edit_taskbar_text_format(self, event): def _get_selected_temperature_unit(self) -> str: """Return the temperature unit selection currently shown in the dialog.""" - temp_values = ["f", "c", "both"] + temp_values = ["auto", "f", "c", "both"] selection = self._controls["temp_unit"].GetSelection() if selection < 0 or selection >= len(temp_values): return "both" diff --git a/src/accessiweather/units.py b/src/accessiweather/units.py new file mode 100644 index 000000000..8fe9d9d70 --- /dev/null +++ b/src/accessiweather/units.py @@ -0,0 +1,60 @@ +"""Helpers for resolving location-aware display unit systems.""" + +from __future__ import annotations + +from enum import Enum + +from .models import Location +from .utils.temperature_utils import TemperatureUnit + + +class DisplayUnitSystem(str, Enum): + """Supported single-unit display systems.""" + + US = "us" + UK = "uk" + CA = "ca" + SI = "si" + + +_COUNTRY_UNIT_SYSTEMS: dict[str, DisplayUnitSystem] = { + "US": DisplayUnitSystem.US, + "GB": DisplayUnitSystem.UK, + "CA": DisplayUnitSystem.CA, +} + + +def resolve_auto_unit_system(location: Location | None) -> DisplayUnitSystem: + """Return the auto-selected unit system for a location.""" + country_code = (getattr(location, "country_code", None) or "").upper() + return _COUNTRY_UNIT_SYSTEMS.get(country_code, DisplayUnitSystem.SI) + + +def resolve_temperature_unit_preference( + preference: str | None, + location: Location | None = None, +) -> TemperatureUnit: + """Resolve a stored unit preference to the effective temperature display mode.""" + normalized = (preference or "both").strip().lower() + if normalized in {"fahrenheit", "f"}: + return TemperatureUnit.FAHRENHEIT + if normalized in {"celsius", "c"}: + return TemperatureUnit.CELSIUS + if normalized == "auto": + return ( + TemperatureUnit.FAHRENHEIT + if resolve_auto_unit_system(location) == DisplayUnitSystem.US + else TemperatureUnit.CELSIUS + ) + return TemperatureUnit.BOTH + + +def resolve_display_unit_system( + preference: str | None, + location: Location | None = None, +) -> DisplayUnitSystem | None: + """Resolve a stored preference to an explicit display system when needed.""" + normalized = (preference or "both").strip().lower() + if normalized == "auto": + return resolve_auto_unit_system(location) + return None diff --git a/src/accessiweather/utils/unit_utils.py b/src/accessiweather/utils/unit_utils.py index ba9fee8b4..8a00adefb 100644 --- a/src/accessiweather/utils/unit_utils.py +++ b/src/accessiweather/utils/unit_utils.py @@ -7,16 +7,32 @@ import logging +from ..units import DisplayUnitSystem from .temperature_utils import TemperatureUnit logger = logging.getLogger(__name__) +def _normalize_unit_system(unit_system: DisplayUnitSystem | str | None) -> DisplayUnitSystem | None: + """Normalize optional unit-system hints.""" + if isinstance(unit_system, DisplayUnitSystem): + return unit_system + if isinstance(unit_system, str): + normalized = unit_system.strip().lower() + if normalized == "uk2": + normalized = "uk" + for candidate in DisplayUnitSystem: + if candidate.value == normalized: + return candidate + return None + + def format_wind_speed( wind_speed_mph: int | float | None, unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT, wind_speed_kph: int | float | None = None, precision: int = 1, + unit_system: DisplayUnitSystem | str | None = None, ) -> str: """ Format wind speed for display based on user preference. @@ -27,6 +43,7 @@ def format_wind_speed( unit: Temperature unit preference (used to determine display format) wind_speed_kph: Wind speed in kilometers per hour (if available) precision: Number of decimal places to display + unit_system: Optional explicit unit-system override for auto-per-location display Returns: ------- @@ -42,6 +59,17 @@ def format_wind_speed( elif wind_speed_mph is not None and wind_speed_kph is None: wind_speed_kph = wind_speed_mph * 1.60934 + normalized_system = _normalize_unit_system(unit_system) + if normalized_system == DisplayUnitSystem.US: + return f"{wind_speed_mph:.{precision}f} mph" + if normalized_system == DisplayUnitSystem.UK: + return f"{wind_speed_mph:.{precision}f} mph" + if normalized_system == DisplayUnitSystem.CA: + return f"{wind_speed_kph:.{precision}f} km/h" + if normalized_system == DisplayUnitSystem.SI: + wind_speed_mps = wind_speed_kph / 3.6 + return f"{wind_speed_mps:.{precision}f} m/s" + # Format based on user preference if unit == TemperatureUnit.FAHRENHEIT: return f"{wind_speed_mph:.{precision}f} mph" @@ -56,6 +84,7 @@ def format_pressure( unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT, pressure_mb: int | float | None = None, precision: int = 2, + unit_system: DisplayUnitSystem | str | None = None, ) -> str: """ Format pressure for display based on user preference. @@ -66,6 +95,7 @@ def format_pressure( unit: Temperature unit preference (used to determine display format) pressure_mb: Pressure in millibars/hPa (if available) precision: Number of decimal places to display + unit_system: Optional explicit unit-system override for auto-per-location display Returns: ------- @@ -81,6 +111,12 @@ def format_pressure( elif pressure_inhg is not None and pressure_mb is None: pressure_mb = pressure_inhg * 33.8639 + normalized_system = _normalize_unit_system(unit_system) + if normalized_system == DisplayUnitSystem.US: + return f"{pressure_inhg:.{precision}f} inHg" + if normalized_system in {DisplayUnitSystem.UK, DisplayUnitSystem.CA, DisplayUnitSystem.SI}: + return f"{pressure_mb:.{precision}f} hPa" + # Format based on user preference if unit == TemperatureUnit.FAHRENHEIT: return f"{pressure_inhg:.{precision}f} inHg" @@ -95,6 +131,7 @@ def format_visibility( unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT, visibility_km: int | float | None = None, precision: int = 1, + unit_system: DisplayUnitSystem | str | None = None, ) -> str: """ Format visibility for display based on user preference. @@ -105,6 +142,7 @@ def format_visibility( unit: Temperature unit preference (used to determine display format) visibility_km: Visibility in kilometers (if available) precision: Number of decimal places to display + unit_system: Optional explicit unit-system override for auto-per-location display Returns: ------- @@ -120,6 +158,12 @@ def format_visibility( elif visibility_miles is not None and visibility_km is None: visibility_km = visibility_miles * 1.60934 + normalized_system = _normalize_unit_system(unit_system) + if normalized_system in {DisplayUnitSystem.US, DisplayUnitSystem.UK}: + return f"{visibility_miles:.{precision}f} mi" + if normalized_system in {DisplayUnitSystem.CA, DisplayUnitSystem.SI}: + return f"{visibility_km:.{precision}f} km" + # Format based on user preference if unit == TemperatureUnit.FAHRENHEIT: return f"{visibility_miles:.{precision}f} mi" @@ -134,6 +178,7 @@ def format_precipitation( unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT, precipitation_mm: int | float | None = None, precision: int = 2, + unit_system: DisplayUnitSystem | str | None = None, ) -> str: """ Format precipitation for display based on user preference. @@ -144,6 +189,7 @@ def format_precipitation( unit: Temperature unit preference (used to determine display format) precipitation_mm: Precipitation in millimeters (if available) precision: Number of decimal places to display + unit_system: Optional explicit unit-system override for auto-per-location display Returns: ------- @@ -159,6 +205,12 @@ def format_precipitation( elif precipitation_inches is not None and precipitation_mm is None: precipitation_mm = precipitation_inches * 25.4 + normalized_system = _normalize_unit_system(unit_system) + if normalized_system == DisplayUnitSystem.US: + return f"{precipitation_inches:.{precision}f} in" + if normalized_system in {DisplayUnitSystem.UK, DisplayUnitSystem.CA, DisplayUnitSystem.SI}: + return f"{precipitation_mm:.{precision}f} mm" + # Format based on user preference if unit == TemperatureUnit.FAHRENHEIT: return f"{precipitation_inches:.{precision}f} in" diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py index 813b78f6b..2f67d4ed7 100644 --- a/src/accessiweather/weather_client_base.py +++ b/src/accessiweather/weather_client_base.py @@ -44,6 +44,7 @@ from .notifications.minutely_precipitation import parse_pirate_weather_minutely_block from .pirate_weather_client import PirateWeatherApiError, PirateWeatherClient from .services import EnvironmentalDataClient +from .units import resolve_auto_unit_system from .utils.retry import APITimeoutError, retry_with_backoff from .visual_crossing_client import VisualCrossingApiError, VisualCrossingClient from .weather_client_alerts import AlertAggregator @@ -613,18 +614,19 @@ async def _do_fetch_weather_data( if api_choice == "pirateweather": # Use Pirate Weather API try: - if not self.pirate_weather_client: + pirate_weather_client = self._pirate_weather_client_for_location(location) + if not pirate_weather_client: raise PirateWeatherApiError("Pirate Weather API key not configured") # Parallelize API calls for better performance current, forecast, hourly_forecast, alerts = await asyncio.gather( - self.pirate_weather_client.get_current_conditions(location), - self.pirate_weather_client.get_forecast( + pirate_weather_client.get_current_conditions(location), + pirate_weather_client.get_forecast( location, days=self._get_forecast_days_for_source(location, source="pirateweather"), ), - self.pirate_weather_client.get_hourly_forecast(location), - self.pirate_weather_client.get_alerts(location), + pirate_weather_client.get_hourly_forecast(location), + pirate_weather_client.get_alerts(location), ) weather_data.current = current @@ -876,15 +878,16 @@ async def fetch_vc(): # Fetch from Pirate Weather if configured async def fetch_pw(): - if not self.pirate_weather_client: + pirate_weather_client = self._pirate_weather_client_for_location(location) + if not pirate_weather_client: return (None, None, None, None) - current = await self.pirate_weather_client.get_current_conditions(location) - forecast = await self.pirate_weather_client.get_forecast( + current = await pirate_weather_client.get_current_conditions(location) + forecast = await pirate_weather_client.get_forecast( location, days=self._get_forecast_days_for_source(location, source="pirateweather"), ) - hourly = await self.pirate_weather_client.get_hourly_forecast(location) - alerts = await self.pirate_weather_client.get_alerts(location) + hourly = await pirate_weather_client.get_hourly_forecast(location) + alerts = await pirate_weather_client.get_alerts(location) return (current, forecast, hourly, alerts) # Fetch from all sources in parallel @@ -893,7 +896,7 @@ async def fetch_pw(): fetch_nws=fetch_nws() if is_us else None, fetch_openmeteo=fetch_openmeteo(), fetch_visualcrossing=fetch_vc() if self.visual_crossing_client else None, - fetch_pirateweather=fetch_pw() if self.pirate_weather_client else None, + fetch_pirateweather=fetch_pw() if self.pirate_weather_api_key else None, ) # Check if all sources failed @@ -1197,6 +1200,29 @@ def _location_key(location: Location) -> str: """Return a stable string key for a location (used for alert caching).""" return f"{location.latitude:.4f},{location.longitude:.4f}" + def _resolve_pirate_weather_units(self, location: Location) -> str: + """Resolve the Pirate Weather unit bundle for the given location.""" + preference = (getattr(self.settings, "temperature_unit", "both") or "both").strip().lower() + if preference == "auto": + unit_system = resolve_auto_unit_system(location) + return "uk" if unit_system.value == "uk" else unit_system.value + if preference in {"c", "celsius"}: + return "ca" + return "us" + + def _pirate_weather_client_for_location(self, location: Location) -> PirateWeatherClient | None: + """Return a Pirate Weather client configured for the location's effective unit system.""" + api_key = self.pirate_weather_api_key + if not api_key: + return None + + units = self._resolve_pirate_weather_units(location) + client = self._pirate_weather_client + if client is None or client.units != units: + client = PirateWeatherClient(api_key, self.user_agent, units=units) + self._pirate_weather_client = client + return client + def _is_us_location(self, location: Location) -> bool: """ Check if location is within the United States. diff --git a/tests/test_settings_dialog_tray_text.py b/tests/test_settings_dialog_tray_text.py index ee2e3d982..7a1e0cd3e 100644 --- a/tests/test_settings_dialog_tray_text.py +++ b/tests/test_settings_dialog_tray_text.py @@ -142,6 +142,13 @@ def test_save_settings_persists_tray_text_fields(): def test_get_selected_temperature_unit_uses_current_choice(): dialog = _make_dialog_for_settings(SimpleNamespace()) - dialog._controls["temp_unit"].SetSelection(1) + dialog._controls["temp_unit"].SetSelection(2) assert dialog._get_selected_temperature_unit() == "c" + + +def test_get_selected_temperature_unit_returns_auto_for_first_choice(): + dialog = _make_dialog_for_settings(SimpleNamespace()) + dialog._controls["temp_unit"].SetSelection(0) + + assert dialog._get_selected_temperature_unit() == "auto" diff --git a/tests/test_system_tray.py b/tests/test_system_tray.py index 91acad82d..660acfa4b 100644 --- a/tests/test_system_tray.py +++ b/tests/test_system_tray.py @@ -475,23 +475,48 @@ def unit_sensitive_weather_data(self): ) @pytest.mark.parametrize( - ("temperature_unit", "expected"), + ("temperature_unit", "location", "expected"), [ - ("f", "10.0 mph | 30.05 inHg | 10.0 mi | 1.00 in | 75F | 55F"), - ("c", "16.1 km/h | 1017.00 hPa | 16.1 km | 25.40 mm | 24C | 13C"), + ( + "f", + Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US"), + "10.0 mph | 30.05 inHg | 10.0 mi | 1.00 in | 75F | 55F", + ), + ( + "c", + Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US"), + "16.1 km/h | 1017.00 hPa | 16.1 km | 25.40 mm | 24C | 13C", + ), ( "both", + Location(name="Test City", latitude=40.0, longitude=-74.0, country_code="US"), "10.0 mph (16.1 km/h) | 30.05 inHg (1017.00 hPa) | 10.0 mi (16.1 km) | " "1.00 in (25.40 mm) | 75F/24C | 55F/13C", ), + ( + "auto", + Location(name="London", latitude=51.5, longitude=-0.12, country_code="GB"), + "10.0 mph | 1017.00 hPa | 10.0 mi | 25.40 mm | 24C | 13C", + ), + ( + "auto", + Location(name="Toronto", latitude=43.65, longitude=-79.38, country_code="CA"), + "16.1 km/h | 1017.00 hPa | 16.1 km | 25.40 mm | 24C | 13C", + ), + ( + "auto", + Location(name="Paris", latitude=48.86, longitude=2.35, country_code="FR"), + "4.5 m/s | 1017.00 hPa | 16.1 km | 25.40 mm | 24C | 13C", + ), ], ) def test_unit_aware_placeholders_follow_unit_preference( - self, unit_sensitive_weather_data, temperature_unit, expected + self, unit_sensitive_weather_data, temperature_unit, location, expected ): """Wind_speed, pressure, visibility, precip, high, and low honor unit preference.""" from accessiweather.taskbar_icon_updater import TaskbarIconUpdater + unit_sensitive_weather_data.location = location updater = TaskbarIconUpdater( text_enabled=True, format_string="{wind_speed} | {pressure} | {visibility} | {precip} | {high} | {low}", diff --git a/tests/test_weather_client.py b/tests/test_weather_client.py index 10aaade50..5b0e51310 100644 --- a/tests/test_weather_client.py +++ b/tests/test_weather_client.py @@ -116,6 +116,37 @@ def test_determine_api_choice_invalid_fallback(self, client, us_location): # Should fall back to NWS for US location assert choice == "nws" + @pytest.mark.parametrize( + ("country_code", "expected_units"), + [ + ("US", "us"), + ("GB", "uk"), + ("CA", "ca"), + ("FR", "si"), + ], + ) + def test_resolve_pirate_weather_units_for_auto(self, client, country_code, expected_units): + client.settings.temperature_unit = "auto" + + location = Location(name="Test", latitude=0.0, longitude=0.0, country_code=country_code) + + assert client._resolve_pirate_weather_units(location) == expected_units + + def test_pirate_weather_client_for_location_rebuilds_with_auto_units(self, client): + client.settings.temperature_unit = "auto" + client._pirate_weather_api_key = "test-key" + + london = Location(name="London", latitude=51.5, longitude=-0.12, country_code="GB") + paris = Location(name="Paris", latitude=48.86, longitude=2.35, country_code="FR") + + london_client = client._pirate_weather_client_for_location(london) + paris_client = client._pirate_weather_client_for_location(paris) + + assert london_client is not None + assert paris_client is not None + assert london_client.units == "uk2" + assert paris_client.units == "si" + class TestWeatherClientFetching: """Tests for weather data fetching.""" From 0328a964bac92ed4f52c8d7df6d661af6ff6528f Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 19 Mar 2026 01:38:14 +0000 Subject: [PATCH 2/2] fix: mock _pirate_weather_client_for_location in PW data source test --- tests/test_pw_coverage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_pw_coverage.py b/tests/test_pw_coverage.py index d86330d98..a2d3eb93c 100644 --- a/tests/test_pw_coverage.py +++ b/tests/test_pw_coverage.py @@ -572,6 +572,7 @@ async def test_pirateweather_data_source_successful_fetch(self, intl_location): mock_pw.get_hourly_forecast = AsyncMock(return_value=MagicMock()) mock_pw.get_alerts = AsyncMock(return_value=WeatherAlerts(alerts=[])) wc._pirate_weather_client = mock_pw + wc._pirate_weather_client_for_location = lambda loc: mock_pw result = await wc._do_fetch_weather_data(intl_location)