Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 与
Expand Down
160 changes: 160 additions & 0 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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")
65 changes: 2 additions & 63 deletions weather_briefing/air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
45 changes: 2 additions & 43 deletions weather_briefing/allergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading