Реализация приложения — получение погоды с API OpenWeather

Отлично, у нас реализована структура и скелет приложения, а также полностью реализована логика получения текущих GPS-координат — в точном или округлённом варианте. Реализуем теперь получение по этим координатам значения погоды с использованием API-сервиса OpenWeather. Добавим шаблон URL для получения погоды в config.py:

USE_ROUNDED_COORDS = True
OPENWEATHER_API = "7549b3ff11a7b2f3cd25b56d21c83c6a"
OPENWEATHER_URL = (
    "https://api.openweathermap.org/data/2.5/weather?"
    "lat={latitude}&lon={longitude}&"
    "appid=" + OPENWEATHER_API + "&lang=ru&"
    "units=metric"
)

Значения широты и долготы будем потом подставлять в этот шаблон. Если нам понадобится изменить однажды этот шаблон URL для получения данных, мы сможем не искать его где-то глубоко в приложении, он лежит в конфиге. Все данные, которые предполагаются как конфигурационные, имеет смысл выносить в отдельное место, которое можно назвать конфигом или настройками приложения.

API-ключ для сервиса OpenWeather лучше сохранить в переменной окружения и не хранить в исходном коде проекта (тогда значение константы будет получаться как-то так: os.getenv("OPENWEATHER_API_KEY"), но сейчас мы этого делать не будем для упрощения запуска приложения.

Итак, реализация работы с сервисом погоды OpenWeather, weather_api_service.py:

from datetime import datetime
from dataclasses import dataclass
from enum import Enum
import json
from json.decoder import JSONDecodeError
import ssl
from typing import Literal, TypeAlias
import urllib.request
from urllib.error import URLError

from coordinates import Coordinates
import config
from exceptions import ApiServiceError

Celsius: TypeAlias = int

class WeatherType(str, Enum):
    THUNDERSTORM = "Гроза"
    DRIZZLE = "Изморось"
    RAIN = "Дождь"
    SNOW = "Снег"
    CLEAR = "Ясно"
    FOG = "Туман"
    CLOUDS = "Облачно"

@dataclass(slots=True, frozen=True)
class Weather:
    temperature: Celsius
    weather_type: WeatherType
    sunrise: datetime
    sunset: datetime
    city: str

def get_weather(coordinates: Coordinates) -> Weather:
    """Requests weather in OpenWeather API and returns it"""
    openweather_response = _get_openweather_response(
        longitude=coordinates.longitude, latitude=coordinates.latitude)
    weather = _parse_openweather_response(openweather_response)
    return weather

def _get_openweather_response(latitude: float, longitude: float) -> str:
    ssl._create_default_https_context = ssl._create_unverified_context
    url = config.OPENWEATHER_URL.format(
        latitude=latitude, longitude=longitude)
    try:
        return urllib.request.urlopen(url).read()
    except URLError:
        raise ApiServiceError

def _parse_openweather_response(openweather_response: str) -> Weather:
    try:
        openweather_dict = json.loads(openweather_response)
    except JSONDecodeError:
        raise ApiServiceError
    return Weather(
        temperature=_parse_temperature(openweather_dict),
        weather_type=_parse_weather_type(openweather_dict),
        sunrise=_parse_sun_time(openweather_dict, "sunrise"),
        sunset=_parse_sun_time(openweather_dict, "sunset"),
        city=_parse_city(openweather_dict)
    )

def _parse_temperature(openweather_dict: dict) -> Celsius:
    return round(openweather_dict["main"]["temp"])

def _parse_weather_type(openweather_dict: dict) -> WeatherType:
    try:
        weather_type_id = str(openweather_dict["weather"][0]["id"])
    except (IndexError, KeyError):
        raise ApiServiceError
    weather_types = {
        "1": WeatherType.THUNDERSTORM,
        "3": WeatherType.DRIZZLE,
        "5": WeatherType.RAIN,
        "6": WeatherType.SNOW,
        "7": WeatherType.FOG,
        "800": WeatherType.CLEAR,
        "80": WeatherType.CLOUDS
    }
    for _id, _weather_type in weather_types.items():
        if weather_type_id.startswith(_id):
            return _weather_type
    raise ApiServiceError

def _parse_sun_time(
        openweather_dict: dict,
        time: Literal["sunrise"] | Literal["sunset"]) -> datetime:
    return datetime.fromtimestamp(openweather_dict["sys"][time])

def _parse_city(openweather_dict: dict) -> str:
    return openweather_dict["name"]

if __name__ == "__main__":
    print(get_weather(Coordinates(latitude=55.7, longitude=37.6)))

Как и ранее, следуем подходу небольших функций, каждая из которых делает одно небольшое действие, а общий результат достигается за счёт компоновки этих небольших функций. Логику парсинга каждой нужной нам единицы информации выносим в отдельные небольшие функции — отдельно парсинг температуры, отдельно парсинг типа погоды и времени восхода и заката. Каждая функция названа глагольным словом — получить, распарсить и т. д. Напомню, что функция это ни что иное как именованный блок кода, этот блок кода что-то делает и потому его имеет смысл называть именно глаголом, который опишет это действие.

Тут стоит отметить, что для парсинга и одновременно валидации JSON-данных удобно использовать библиотеку Pydantic. О ней было видео на канале «Диджитализируй!». Здесь мы не стали её использовать из-за возможно некоторой её избыточности для нашей простой задачи, а также чтобы ограничиться только стандартной библиотекой Python.

Осталось реализовать «принтер», который выведет нужные нам значения погоды в консоль!