diff --git a/docs/notes.md b/docs/notes.md index 80209f92..a561666f 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -8,6 +8,10 @@ JMA 默认作为日本地点的本地 supplement,不替换全球主天气 provider;单独配置 `jma-jp` 时仍可作为唯一主来源,与本地 capability provider 的通用规则一致。JMA API 使用 forecast office code 而不是坐标查询;为支持多个日本地点并避免把东京的 `130000` 静默套到其他地区,office code 属于 `locations.json` 的地点配置,只有配置六位 code 时才自动启用 `jma-jp`,缺失时安全退回 Open-Meteo。名称与坐标齐全而跳过地理编码时,国家代码未知,显式 office code 足以选择日本默认;若解析结果已有非日本国家代码,则该地理事实优先,错误填写的 code 不得改写地区。这样增加了每地点配置成本,也要求地点跨预报区移动时同步更新 code;JMA 响应失败仍按 supplement 的预期失败边界丢弃,不阻断主天气上下文。若未来维护一份经审核的坐标/行政区到 office code 参考数据,才可把手工字段改为自动解析;在此之前不能用近似坐标或全局默认值猜测预报区。 +### 本地化参考数据边界 + +天气文档、空气质量、过敏原、QWeather 格式和投递 renderer 的多语言脚手架属于可独立维护的参考数据,因此集中存放在打包的 `localization.json`,而不是散落在 adapter 的 Python 常量中。`localization_table()` 在模块载入时校验完整表名、固定支持语种、每表必填字段、非空字符串和语言 alias,再返回进程级只读缓存;这让翻译修改保持为可审查的数据差异,并避免单次请求期间反复解析文件或让调用方污染共享值。取舍是 schema 变更仍需同步修改 Python 校验,任何无效翻译会让应用启动失败,且打包数据更新必须重启进程后才生效;这些约束对当前随应用发布、仅含少量内置语种的静态脚手架是有意的。若产品开始支持运行时安装语言包、租户级覆盖、无需重启的热更新,或支持语种和表规模使启动校验或内存复制成为可测量成本,应改用带版本和原子替换语义的专用本地化资源层,并重新定义 fallback 与缺失字段策略,不能直接把当前严格校验改成请求时容错。 + ### 镜像构建与发布串行边界 release tag 工作流构建的镜像会复用同一套镜像构建流程产出 `edge`。为了避免在 master 与 diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index bcbfb488..9a7d04b9 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -1,15 +1,41 @@ +from copy import deepcopy +from types import MappingProxyType +from typing import TypeGuard + import pytest from weather_briefing.air_quality import health_guidance from weather_briefing.reference_data import ( ReferenceDataError, load_reference_data, + localization_table, reference_string, reference_string_tuple, reference_value, ) +def _is_string_object_dict(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) and all(isinstance(key, str) for key in value) + + +def _mutable_localization_data() -> tuple[dict[str, object], dict[str, object], dict[str, object]]: + value = deepcopy(load_reference_data("localization.json")) + tables = value["tables"] + aliases = value["aliases"] + assert _is_string_object_dict(tables) + assert _is_string_object_dict(aliases) + return value, tables, aliases + + +def _localization_language(tables: dict[str, object], table_name: str, language: str) -> dict[str, object]: + table = tables[table_name] + assert _is_string_object_dict(table) + labels = table[language] + assert _is_string_object_dict(labels) + return labels + + def test_packaged_reference_data_is_available() -> None: assert reference_value("geography.json", "mainland_china_service_bounds", "latitude") assert reference_string_tuple( @@ -19,6 +45,8 @@ def test_packaged_reference_data_is_available() -> None: assert reference_string_tuple("content_cleaning.json", "default_remove_selectors") assert reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types") assert reference_string("provider_defaults.json", "qweather_allergen_index_type") == "7" + assert localization_table("weather_document")["ja"]["forecast"] == "天気予報" + assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息" def test_air_quality_guidance_covers_values_above_last_bounded_band() -> None: @@ -80,3 +108,135 @@ def read_text(self, encoding=None): with pytest.raises(ReferenceDataError, match="must be an object"): load_reference_data("test.json") + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ({"tables": [], "aliases": {}}, "every supported table"), + ({"tables": {}, "aliases": {}}, "every supported table"), + ( + { + "tables": { + name: {} for name in ("air_quality", "allergen", "briefing", "qweather", "weather_document") + }, + "aliases": {}, + }, + "every supported language", + ), + ], +) +def test_localization_table_rejects_incomplete_data(monkeypatch, value, message) -> None: + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match=message): + localization_table("briefing") + + +def test_localization_table_rejects_missing_fields(monkeypatch) -> None: + value, tables, _ = _mutable_localization_data() + english = _localization_language(tables, "briefing", "en") + del english["weather"] + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"): + localization_table("briefing") + + +def test_localization_table_rejects_unknown_alias_target(monkeypatch) -> None: + value, _, aliases = _mutable_localization_data() + briefing = aliases["briefing"] + assert _is_string_object_dict(briefing) + briefing["zh-Hans"] = "missing" + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Invalid localization alias: briefing:zh-Hans"): + localization_table("briefing") + + +def test_localization_table_rejects_whitespace_values(monkeypatch) -> None: + value, tables, _ = _mutable_localization_data() + _localization_language(tables, "briefing", "en")["weather"] = " " + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"): + localization_table("briefing") + + +def test_localization_tables_are_immutable() -> None: + localization_table.cache_clear() + table = localization_table("briefing") + + assert isinstance(table, MappingProxyType) + assert isinstance(table["en"], MappingProxyType) + assert isinstance(table["zh-Hans"], MappingProxyType) + assert localization_table("briefing")["en"]["weather"] == "Weather information" + assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息" + + +@pytest.mark.parametrize("aliases", ([], {"unknown": {}})) +def test_localization_table_rejects_invalid_alias_root(monkeypatch, aliases) -> None: + value, _, _ = _mutable_localization_data() + value["aliases"] = aliases + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="every supported table"): + localization_table("briefing") + + +def test_localization_table_rejects_unknown_table() -> None: + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Unknown localization table: missing"): + localization_table("missing") + + +def test_localization_table_rejects_non_object_table(monkeypatch) -> None: + value, tables, _ = _mutable_localization_data() + tables["briefing"] = [] + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Unknown localization table: briefing"): + localization_table("briefing") + + +def test_localization_table_rejects_non_object_labels(monkeypatch) -> None: + value, tables, _ = _mutable_localization_data() + briefing = tables["briefing"] + assert _is_string_object_dict(briefing) + briefing["en"] = [] + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"): + localization_table("briefing") + + +def test_localization_table_rejects_non_object_table_aliases(monkeypatch) -> None: + value, _, aliases = _mutable_localization_data() + aliases["briefing"] = [] + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Localization aliases must be an object: briefing"): + localization_table("briefing") + + +@pytest.mark.parametrize( + ("alias", "target"), + ((7, "zh-CN"), ("ZH", "zh-CN"), ("bad_tag", "zh-CN"), ("fr", 7), ("en", "en")), +) +def test_localization_table_rejects_invalid_aliases(monkeypatch, alias, target) -> None: + value, _, aliases = _mutable_localization_data() + aliases["briefing"] = {alias: target} + monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value) + localization_table.cache_clear() + + with pytest.raises(ReferenceDataError, match="Invalid localization alias: briefing"): + localization_table("briefing") diff --git a/weather_briefing/air_quality.py b/weather_briefing/air_quality.py index 789dffac..ad15046c 100644 --- a/weather_briefing/air_quality.py +++ b/weather_briefing/air_quality.py @@ -11,71 +11,10 @@ from .api_client import api_call_extensions from .languages import localized_labels from .models import AirQualitySnapshot, AirQualityTimeKind, SourceDocument -from .reference_data import ReferenceDataError, reference_value +from .reference_data import ReferenceDataError, localization_table, reference_value from .time_utils import parse_datetime_with_default_timezone -_AIR_QUALITY_FORMATS = { - "zh-CN": { - "separator": ":", - "unavailable": "不可用", - "forecast_time": "预报时段", - "observation_time": "观测时间", - "observation": "观测", - "forecast": "预报", - "time_kind": "时间类型:{kind}", - "aqi": "AQI:{aqi}(标准:{standard};类别:{category})", - "aqi_summary": "AQI:{aqi}({category})", - "pm25_aqi": "PM2.5 单项 AQI:{value}(标准:{standard})", - "pm25_summary": "PM2.5 单项 AQI:{value};浓度:{concentration}", - "pm25": "PM2.5 {concentration}", - "health": "健康提示:{guidance}", - }, - "zh-TW": { - "separator": ":", - "unavailable": "無法取得", - "forecast_time": "預報時段", - "observation_time": "觀測時間", - "observation": "觀測", - "forecast": "預報", - "time_kind": "時間類型:{kind}", - "aqi": "AQI:{aqi}(標準:{standard};類別:{category})", - "aqi_summary": "AQI:{aqi}({category})", - "pm25_aqi": "PM2.5 單項 AQI:{value}(標準:{standard})", - "pm25_summary": "PM2.5 單項 AQI:{value};濃度:{concentration}", - "pm25": "PM2.5 {concentration}", - "health": "健康提示:{guidance}", - }, - "en": { - "separator": ": ", - "unavailable": "Unavailable", - "forecast_time": "Forecast period", - "observation_time": "Observed at", - "observation": "Observation", - "forecast": "Forecast", - "time_kind": "Time type: {kind}", - "aqi": "AQI: {aqi} (Standard: {standard}; Category: {category})", - "aqi_summary": "AQI: {aqi} ({category})", - "pm25_aqi": "PM2.5 sub-index AQI: {value} (Standard: {standard})", - "pm25_summary": "PM2.5 sub-index AQI: {value}; concentration: {concentration}", - "pm25": "PM2.5 concentration: {concentration}", - "health": "Health guidance: {guidance}", - }, - "ja": { - "separator": ":", - "unavailable": "利用不可", - "forecast_time": "予報期間", - "observation_time": "観測時刻", - "observation": "観測", - "forecast": "予報", - "time_kind": "時刻種別:{kind}", - "aqi": "AQI:{aqi}(基準:{standard}、区分:{category})", - "aqi_summary": "AQI:{aqi}({category})", - "pm25_aqi": "PM2.5 個別 AQI:{value}(基準:{standard})", - "pm25_summary": "PM2.5 個別 AQI:{value}、濃度:{concentration}", - "pm25": "PM2.5 濃度:{concentration}", - "health": "健康上の注意:{guidance}", - }, -} +_AIR_QUALITY_FORMATS = localization_table("air_quality") class AirQualityError(RuntimeError): diff --git a/weather_briefing/allergen.py b/weather_briefing/allergen.py index e064281d..ab12b5b4 100644 --- a/weather_briefing/allergen.py +++ b/weather_briefing/allergen.py @@ -7,50 +7,9 @@ from .languages import localized_labels from .models import AllergenSnapshot, SourceDocument -from .reference_data import ReferenceDataError, reference_value +from .reference_data import ReferenceDataError, localization_table, reference_value -_ALLERGEN_FORMATS = { - "zh-CN": { - "separator": ":", - "unavailable": "不可用", - "observed_at": "观测时间", - "allergens": "花粉过敏原", - "overall": "总体等级", - "health": "健康提示", - "count": "花粉类型数", - "level": "- {name}:{concentration:g} 粒/m³({category})", - }, - "zh-TW": { - "separator": ":", - "unavailable": "無法取得", - "observed_at": "觀測時間", - "allergens": "花粉過敏原", - "overall": "整體等級", - "health": "健康提示", - "count": "花粉類型數", - "level": "- {name}:{concentration:g} 粒/m³({category})", - }, - "en": { - "separator": ": ", - "unavailable": "Unavailable", - "observed_at": "Observed at", - "allergens": "Pollen allergens", - "overall": "Overall level", - "health": "Health guidance", - "count": "Pollen type count", - "level": "- {name}: {concentration:g} grains/m³ ({category})", - }, - "ja": { - "separator": ":", - "unavailable": "利用不可", - "observed_at": "観測時刻", - "allergens": "花粉アレルゲン", - "overall": "総合レベル", - "health": "健康上の注意", - "count": "花粉種類数", - "level": "- {name}:{concentration:g} 粒/m³({category})", - }, -} +_ALLERGEN_FORMATS = localization_table("allergen") def allergen_guidance(concentration: float) -> tuple[str, str]: diff --git a/weather_briefing/data/localization.json b/weather_briefing/data/localization.json new file mode 100644 index 00000000..3e0b0ed9 --- /dev/null +++ b/weather_briefing/data/localization.json @@ -0,0 +1,233 @@ +{ + "aliases": { + "briefing": { + "zh": "zh-CN", + "zh-Hans": "zh-CN", + "zh-Hant": "zh-TW" + } + }, + "tables": { + "air_quality": { + "en": { + "aqi": "AQI: {aqi} (Standard: {standard}; Category: {category})", + "aqi_summary": "AQI: {aqi} ({category})", + "forecast": "Forecast", + "forecast_time": "Forecast period", + "health": "Health guidance: {guidance}", + "observation": "Observation", + "observation_time": "Observed at", + "pm25": "PM2.5 concentration: {concentration}", + "pm25_aqi": "PM2.5 sub-index AQI: {value} (Standard: {standard})", + "pm25_summary": "PM2.5 sub-index AQI: {value}; concentration: {concentration}", + "separator": ": ", + "time_kind": "Time type: {kind}", + "unavailable": "Unavailable" + }, + "ja": { + "aqi": "AQI:{aqi}(基準:{standard}、区分:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "forecast": "予報", + "forecast_time": "予報期間", + "health": "健康上の注意:{guidance}", + "observation": "観測", + "observation_time": "観測時刻", + "pm25": "PM2.5 濃度:{concentration}", + "pm25_aqi": "PM2.5 個別 AQI:{value}(基準:{standard})", + "pm25_summary": "PM2.5 個別 AQI:{value}、濃度:{concentration}", + "separator": ":", + "time_kind": "時刻種別:{kind}", + "unavailable": "利用不可" + }, + "zh-CN": { + "aqi": "AQI:{aqi}(标准:{standard};类别:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "forecast": "预报", + "forecast_time": "预报时段", + "health": "健康提示:{guidance}", + "observation": "观测", + "observation_time": "观测时间", + "pm25": "PM2.5 {concentration}", + "pm25_aqi": "PM2.5 单项 AQI:{value}(标准:{standard})", + "pm25_summary": "PM2.5 单项 AQI:{value};浓度:{concentration}", + "separator": ":", + "time_kind": "时间类型:{kind}", + "unavailable": "不可用" + }, + "zh-TW": { + "aqi": "AQI:{aqi}(標準:{standard};類別:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "forecast": "預報", + "forecast_time": "預報時段", + "health": "健康提示:{guidance}", + "observation": "觀測", + "observation_time": "觀測時間", + "pm25": "PM2.5 {concentration}", + "pm25_aqi": "PM2.5 單項 AQI:{value}(標準:{standard})", + "pm25_summary": "PM2.5 單項 AQI:{value};濃度:{concentration}", + "separator": ":", + "time_kind": "時間類型:{kind}", + "unavailable": "無法取得" + } + }, + "allergen": { + "en": { + "allergens": "Pollen allergens", + "count": "Pollen type count", + "health": "Health guidance", + "level": "- {name}: {concentration:g} grains/m³ ({category})", + "observed_at": "Observed at", + "overall": "Overall level", + "separator": ": ", + "unavailable": "Unavailable" + }, + "ja": { + "allergens": "花粉アレルゲン", + "count": "花粉種類数", + "health": "健康上の注意", + "level": "- {name}:{concentration:g} 粒/m³({category})", + "observed_at": "観測時刻", + "overall": "総合レベル", + "separator": ":", + "unavailable": "利用不可" + }, + "zh-CN": { + "allergens": "花粉过敏原", + "count": "花粉类型数", + "health": "健康提示", + "level": "- {name}:{concentration:g} 粒/m³({category})", + "observed_at": "观测时间", + "overall": "总体等级", + "separator": ":", + "unavailable": "不可用" + }, + "zh-TW": { + "allergens": "花粉過敏原", + "count": "花粉類型數", + "health": "健康提示", + "level": "- {name}:{concentration:g} 粒/m³({category})", + "observed_at": "觀測時間", + "overall": "整體等級", + "separator": ":", + "unavailable": "無法取得" + } + }, + "briefing": { + "en": { + "advice": "Advice", + "attribution": "(Sources: {sources})", + "detail_separator": ": ", + "disasters": "Natural disaster updates", + "html_source_separator": ", ", + "plain_source_separator": "; ", + "status_close": ")", + "status_open": " (", + "warnings": "Weather warnings", + "weather": "Weather information" + }, + "ja": { + "advice": "生活上の注意", + "attribution": "(出典:{sources})", + "detail_separator": ":", + "disasters": "自然災害情報", + "html_source_separator": "、", + "plain_source_separator": ";", + "status_close": ")", + "status_open": "(", + "warnings": "気象警報", + "weather": "気象情報" + }, + "zh-CN": { + "advice": "生活建议", + "attribution": "(来源:{sources})", + "detail_separator": ":", + "disasters": "自然灾害动态", + "html_source_separator": "、", + "plain_source_separator": ";", + "status_close": ")", + "status_open": "(", + "warnings": "气象预警", + "weather": "天气信息" + }, + "zh-TW": { + "advice": "生活建議", + "attribution": "(來源:{sources})", + "detail_separator": ":", + "disasters": "自然災害動態", + "html_source_separator": "、", + "plain_source_separator": ";", + "status_close": ")", + "status_open": "(", + "warnings": "氣象警報", + "weather": "天氣資訊" + } + }, + "qweather": { + "en": { + "day": "{date}: {day} to {night}; {minimum}-{maximum} °C; {wind}, force {scale}; relative humidity {humidity}%; forecast precipitation {precipitation} mm", + "lifestyle": "{name} ({category}): {text}", + "no_details": "No detailed advice", + "unknown": "Unknown" + }, + "ja": { + "day": "{date}:{day}から{night}、{minimum}~{maximum}℃、{wind}{scale}級、相対湿度{humidity}%、予想降水量{precipitation}mm", + "lifestyle": "{name}({category}):{text}", + "no_details": "詳しいアドバイスはありません", + "unknown": "不明" + }, + "zh-CN": { + "day": "{date}:{day}转{night},{minimum}~{maximum}℃,{wind}{scale}级,相对湿度{humidity}%,预计降水量{precipitation}毫米", + "lifestyle": "{name}({category}):{text}", + "no_details": "无详细建议", + "unknown": "未知" + }, + "zh-TW": { + "day": "{date}:{day}轉{night},{minimum}~{maximum}℃,{wind}{scale}級,相對濕度{humidity}%,預計降水量{precipitation}毫米", + "lifestyle": "{name}({category}):{text}", + "no_details": "無詳細建議", + "unknown": "未知" + } + }, + "weather_document": { + "en": { + "forecast": "Weather forecast", + "lifestyle": "Lifestyle and travel indices", + "lifestyle_count": "Lifestyle and travel index count", + "section_separator": ":", + "separator": ": ", + "summary": "Weather summary", + "unavailable": "Unavailable", + "updated_at": "Updated at" + }, + "ja": { + "forecast": "天気予報", + "lifestyle": "生活・外出指数", + "lifestyle_count": "生活・外出指数の件数", + "section_separator": ":", + "separator": ":", + "summary": "天気概要", + "unavailable": "利用不可", + "updated_at": "更新時刻" + }, + "zh-CN": { + "forecast": "天气预报", + "lifestyle": "生活与出行指数", + "lifestyle_count": "生活与出行指数项数", + "section_separator": ":", + "separator": ":", + "summary": "天气概览", + "unavailable": "不可用", + "updated_at": "更新时间" + }, + "zh-TW": { + "forecast": "天氣預報", + "lifestyle": "生活與出行指數", + "lifestyle_count": "生活與出行指數項數", + "section_separator": ":", + "separator": ":", + "summary": "天氣概覽", + "unavailable": "無法取得", + "updated_at": "更新時間" + } + } + } +} diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index fe6cbc38..1239aa08 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -3,12 +3,83 @@ from __future__ import annotations import json +from collections.abc import Mapping from functools import cache from importlib import resources from pathlib import PurePath -from typing import Any +from types import MappingProxyType +from typing import Any, TypeGuard from . import data +from .languages import normalize_language_tag + +_LOCALIZATION_FIELDS = { + "air_quality": frozenset( + { + "separator", + "unavailable", + "forecast_time", + "observation_time", + "observation", + "forecast", + "time_kind", + "aqi", + "aqi_summary", + "pm25_aqi", + "pm25_summary", + "pm25", + "health", + } + ), + "allergen": frozenset( + {"separator", "unavailable", "observed_at", "allergens", "overall", "health", "count", "level"} + ), + "briefing": frozenset( + { + "weather", + "warnings", + "disasters", + "advice", + "attribution", + "html_source_separator", + "plain_source_separator", + "status_open", + "status_close", + "detail_separator", + } + ), + "qweather": frozenset({"day", "lifestyle", "unknown", "no_details"}), + "weather_document": frozenset( + { + "separator", + "section_separator", + "unavailable", + "updated_at", + "forecast", + "lifestyle", + "summary", + "lifestyle_count", + } + ), +} +_LOCALIZATION_LANGUAGES = frozenset({"zh-CN", "zh-TW", "en", "ja"}) + + +def _is_normalized_language(value: object) -> bool: + if not isinstance(value, str): + return False + try: + return normalize_language_tag(value) == value + except ValueError: + return False + + +def _is_localization_labels(value: object, expected_fields: frozenset[str]) -> TypeGuard[dict[str, str]]: + return ( + isinstance(value, dict) + and set(value) == expected_fields + and all(isinstance(item, str) and item.strip() for item in value.values()) + ) class ReferenceDataError(RuntimeError): @@ -58,3 +129,41 @@ def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: joined_path = ".".join(path) raise ReferenceDataError(f"Reference data field must be a non-empty string list: {filename}:{joined_path}") return tuple(value) + + +@cache +def localization_table(name: str) -> Mapping[str, Mapping[str, str]]: + """Return one fully validated localized scaffold table.""" + tables = reference_value("localization.json", "tables") + aliases = reference_value("localization.json", "aliases") + if ( + not isinstance(tables, dict) + or set(tables) != set(_LOCALIZATION_FIELDS) + or not isinstance(aliases, dict) + or not set(aliases).issubset(_LOCALIZATION_FIELDS) + ): + raise ReferenceDataError("Localization data must contain every supported table") + table = tables.get(name) + if name not in _LOCALIZATION_FIELDS or not isinstance(table, dict): + raise ReferenceDataError(f"Unknown localization table: {name}") + if set(table) != _LOCALIZATION_LANGUAGES: + raise ReferenceDataError(f"Localization table must contain every supported language: {name}") + expected_fields = _LOCALIZATION_FIELDS[name] + validated: dict[str, Mapping[str, str]] = {} + for language, labels in table.items(): + if not _is_localization_labels(labels, expected_fields): + raise ReferenceDataError(f"Invalid localization fields: {name}:{language}") + validated[language] = MappingProxyType(dict(labels)) + table_aliases = aliases.get(name, {}) + if not isinstance(table_aliases, dict): + raise ReferenceDataError(f"Localization aliases must be an object: {name}") + for alias, target in table_aliases.items(): + if ( + not _is_normalized_language(alias) + or not isinstance(target, str) + or target not in validated + or alias in validated + ): + raise ReferenceDataError(f"Invalid localization alias: {name}:{alias}") + validated[alias] = validated[target] + return MappingProxyType(validated) diff --git a/weather_briefing/render.py b/weather_briefing/render.py index e0fa6bf9..2315798e 100644 --- a/weather_briefing/render.py +++ b/weather_briefing/render.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from html import escape, unescape from typing import Protocol @@ -16,62 +17,9 @@ RenderedMessage, SourceDocument, ) +from .reference_data import localization_table -_ZH_CN_BRIEFING_LABELS = { - "weather": "天气信息", - "warnings": "气象预警", - "disasters": "自然灾害动态", - "advice": "生活建议", - "attribution": "(来源:{sources})", - "html_source_separator": "、", - "plain_source_separator": ";", - "status_open": "(", - "status_close": ")", - "detail_separator": ":", -} -_ZH_TW_BRIEFING_LABELS = { - "weather": "天氣資訊", - "warnings": "氣象警報", - "disasters": "自然災害動態", - "advice": "生活建議", - "attribution": "(來源:{sources})", - "html_source_separator": "、", - "plain_source_separator": ";", - "status_open": "(", - "status_close": ")", - "detail_separator": ":", -} -_BRIEFING_LABELS = { - "zh": _ZH_CN_BRIEFING_LABELS, - "zh-CN": _ZH_CN_BRIEFING_LABELS, - "zh-Hans": _ZH_CN_BRIEFING_LABELS, - "zh-TW": _ZH_TW_BRIEFING_LABELS, - "zh-Hant": _ZH_TW_BRIEFING_LABELS, - "en": { - "weather": "Weather information", - "warnings": "Weather warnings", - "disasters": "Natural disaster updates", - "advice": "Advice", - "attribution": "(Sources: {sources})", - "html_source_separator": ", ", - "plain_source_separator": "; ", - "status_open": " (", - "status_close": ")", - "detail_separator": ": ", - }, - "ja": { - "weather": "気象情報", - "warnings": "気象警報", - "disasters": "自然災害情報", - "advice": "生活上の注意", - "attribution": "(出典:{sources})", - "html_source_separator": "、", - "plain_source_separator": ";", - "status_open": "(", - "status_close": ")", - "detail_separator": ":", - }, -} +_BRIEFING_LABELS = localization_table("briefing") _BRIEFING_LANGUAGE_SUPPORT = LanguageSupport( default="en", supported=tuple(_BRIEFING_LABELS), @@ -211,7 +159,7 @@ def _html_items( title: str, items: tuple[Conclusion | Advice, ...], source_links: dict[str, str], - labels: dict[str, str], + labels: Mapping[str, str], ) -> list[str]: if not items: return [] @@ -227,7 +175,7 @@ def _plain_items( title: str, items: tuple[Conclusion | Advice, ...], source_references: dict[str, str], - labels: dict[str, str], + labels: Mapping[str, str], ) -> list[str]: if not items: return [] @@ -240,7 +188,7 @@ def _plain_items( def _html_attribution( source_ids: tuple[str, ...], source_links: dict[str, str], - labels: dict[str, str], + labels: Mapping[str, str], ) -> str: sources = labels["html_source_separator"].join(dict.fromkeys(source_links[source_id] for source_id in source_ids)) return labels["attribution"].format(sources=sources) @@ -249,7 +197,7 @@ def _html_attribution( def _plain_attribution( source_ids: tuple[str, ...], source_references: dict[str, str], - labels: dict[str, str], + labels: Mapping[str, str], ) -> str: sources = labels["plain_source_separator"].join( dict.fromkeys(source_references[source_id] for source_id in source_ids) @@ -257,7 +205,7 @@ def _plain_attribution( return labels["attribution"].format(sources=sources) -def _briefing_labels(language: str) -> dict[str, str]: +def _briefing_labels(language: str) -> Mapping[str, str]: selected = _BRIEFING_LANGUAGE_SUPPORT.match(language) return _BRIEFING_LABELS[selected] diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index cb2bdd13..e86d9fca 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -28,7 +28,7 @@ SourceDocument, WeatherContextSnapshot, ) -from .reference_data import ReferenceDataError, reference_string, reference_string_tuple +from .reference_data import ReferenceDataError, localization_table, reference_string, reference_string_tuple from .time_utils import ( datetime_timezone_specifier, parse_datetime_with_default_timezone, @@ -42,87 +42,8 @@ ) OPEN_METEO_LANGUAGE_SUPPORT = LanguageSupport.fixed("zh-CN") -_WEATHER_DOCUMENT_LABELS = { - "zh-CN": { - "separator": ":", - "section_separator": ":", - "unavailable": "不可用", - "updated_at": "更新时间", - "forecast": "天气预报", - "lifestyle": "生活与出行指数", - "summary": "天气概览", - "lifestyle_count": "生活与出行指数项数", - }, - "zh-TW": { - "separator": ":", - "section_separator": ":", - "unavailable": "無法取得", - "updated_at": "更新時間", - "forecast": "天氣預報", - "lifestyle": "生活與出行指數", - "summary": "天氣概覽", - "lifestyle_count": "生活與出行指數項數", - }, - "en": { - "separator": ": ", - "section_separator": ":", - "unavailable": "Unavailable", - "updated_at": "Updated at", - "forecast": "Weather forecast", - "lifestyle": "Lifestyle and travel indices", - "summary": "Weather summary", - "lifestyle_count": "Lifestyle and travel index count", - }, - "ja": { - "separator": ":", - "section_separator": ":", - "unavailable": "利用不可", - "updated_at": "更新時刻", - "forecast": "天気予報", - "lifestyle": "生活・外出指数", - "summary": "天気概要", - "lifestyle_count": "生活・外出指数の件数", - }, -} - -_QWEATHER_FORMATS = { - "zh-CN": { - "day": ( - "{date}:{day}转{night},{minimum}~{maximum}℃,{wind}{scale}级," - "相对湿度{humidity}%,预计降水量{precipitation}毫米" - ), - "lifestyle": "{name}({category}):{text}", - "unknown": "未知", - "no_details": "无详细建议", - }, - "zh-TW": { - "day": ( - "{date}:{day}轉{night},{minimum}~{maximum}℃,{wind}{scale}級," - "相對濕度{humidity}%,預計降水量{precipitation}毫米" - ), - "lifestyle": "{name}({category}):{text}", - "unknown": "未知", - "no_details": "無詳細建議", - }, - "en": { - "day": ( - "{date}: {day} to {night}; {minimum}-{maximum} °C; {wind}, force {scale}; " - "relative humidity {humidity}%; forecast precipitation {precipitation} mm" - ), - "lifestyle": "{name} ({category}): {text}", - "unknown": "Unknown", - "no_details": "No detailed advice", - }, - "ja": { - "day": ( - "{date}:{day}から{night}、{minimum}~{maximum}℃、{wind}{scale}級、" - "相対湿度{humidity}%、予想降水量{precipitation}mm" - ), - "lifestyle": "{name}({category}):{text}", - "unknown": "不明", - "no_details": "詳しいアドバイスはありません", - }, -} +_WEATHER_DOCUMENT_LABELS = localization_table("weather_document") +_QWEATHER_FORMATS = localization_table("qweather") class WeatherContextError(RuntimeError):