├── .gitignore ├── README.md ├── config.py ├── coordinates.py ├── exceptions.py ├── history.py ├── weather ├── weather_api_service.py └── weather_formatter.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | env 4 | *.json 5 | *.txt 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Программа показывает погоду по текущим GPS координатам. Координаты берутся 2 | из программы `whereami`, которая работает (и работает отлично!) на компьютерах 3 | Mac. [О whereami](https://github.com/robmathers/WhereAmI). 4 | 5 | Получение погоды по координатам происходит в сервисе 6 | [OpenWeather](https://openweathermap.org/api). 7 | 8 | Для запуска используйте python 3.10 (внешние библиотеки не требуются для работы 9 | приложения), в `config.py` проставьте API ключ для доступа к OpenWeather и 10 | запустите: 11 | 12 | 13 | ```bash 14 | ./weather 15 | ``` 16 | 17 | Файл `weather` — исполнимый файл с python кодом, его можно открыть посмотреть. 18 | 19 | Данный материал подготовлен в качестве примера к [видео](https://www.youtube.com/watch?v=dKxiHlZvULQ) и [книге 20 | «Типизированный Python»](https://t.me/t0digital/151). 21 | 22 | 23 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | USE_ROUNDED_COORDS = False 2 | OPENWEATHER_API = "" # paste API token here 3 | OPENWEATHER_URL = ( 4 | "https://api.openweathermap.org/data/2.5/weather?" 5 | "lat={latitude}&lon={longitude}&" 6 | "appid=" + OPENWEATHER_API + "&lang=ru&" 7 | "units=metric" 8 | ) 9 | -------------------------------------------------------------------------------- /coordinates.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from subprocess import Popen, PIPE 3 | from typing import Literal 4 | 5 | import config 6 | from exceptions import CantGetCoordinates 7 | 8 | 9 | @dataclass(slots=True, frozen=True) 10 | class Coordinates: 11 | latitude: float 12 | longitude: float 13 | 14 | 15 | def get_gps_coordinates() -> Coordinates: 16 | """Returns current coordinates using MacBook GPS""" 17 | coordinates = _get_whereami_coordinates() 18 | return _round_coordinates(coordinates) 19 | 20 | def _get_whereami_coordinates() -> Coordinates: 21 | whereami_output = _get_whereami_output() 22 | coordinates = _parse_coordinates(whereami_output) 23 | return coordinates 24 | 25 | def _get_whereami_output() -> bytes: 26 | process = Popen(["whereami"], stdout=PIPE) 27 | output, err = process.communicate() 28 | exit_code = process.wait() 29 | if err is not None or exit_code != 0: 30 | raise CantGetCoordinates 31 | return output 32 | 33 | def _parse_coordinates(whereami_output: bytes) -> Coordinates: 34 | try: 35 | output = whereami_output.decode().strip().lower().split("\n") 36 | except UnicodeDecodeError: 37 | raise CantGetCoordinates 38 | return Coordinates( 39 | latitude=_parse_coord(output, "latitude"), 40 | longitude=_parse_coord(output, "longitude") 41 | ) 42 | 43 | def _parse_coord( 44 | output: list[str], 45 | coord_type: Literal["latitude"] | Literal["longitude"]) -> float: 46 | for line in output: 47 | if line.startswith(f"{coord_type}:"): 48 | return _parse_float_coordinate(line.split()[1]) 49 | else: 50 | raise CantGetCoordinates 51 | 52 | def _parse_float_coordinate(value: str) -> float: 53 | try: 54 | return float(value) 55 | except ValueError: 56 | raise CantGetCoordinates 57 | 58 | def _round_coordinates(coordinates: Coordinates) -> Coordinates: 59 | if not config.USE_ROUNDED_COORDS: 60 | return coordinates 61 | return Coordinates(*map( 62 | lambda c: round(c, 1), 63 | [coordinates.latitude, coordinates.longitude] 64 | )) 65 | 66 | 67 | if __name__ == "__main__": 68 | print(get_gps_coordinates()) 69 | -------------------------------------------------------------------------------- /exceptions.py: -------------------------------------------------------------------------------- 1 | class CantGetCoordinates(Exception): 2 | """Program can't get current GPS coordinates""" 3 | 4 | class ApiServiceError(Exception): 5 | """Program can't current weather""" 6 | -------------------------------------------------------------------------------- /history.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | from pathlib import Path 4 | from typing import TypedDict, Protocol 5 | 6 | from weather_api_service import Weather 7 | from weather_formatter import format_weather 8 | 9 | 10 | class WeatherStorage(Protocol): 11 | """Interface for any storage saving weather""" 12 | def save(self, weather: Weather) -> None: 13 | raise NotImplementedError 14 | 15 | 16 | class PlainFileWeatherStorage: 17 | """Store weather in plain text file""" 18 | def __init__(self, file: Path): 19 | self._file = file 20 | 21 | def save(self, weather: Weather) -> None: 22 | now = datetime.now() 23 | formatted_weather = format_weather(weather) 24 | with open(self._file, "a") as f: 25 | f.write(f"{now}\n{formatted_weather}\n") 26 | 27 | 28 | class HistoryRecord(TypedDict): 29 | date: str 30 | weather: str 31 | 32 | 33 | class JSONFileWeatherStorage: 34 | """Store weather in JSON file""" 35 | def __init__(self, jsonfile: Path): 36 | self._jsonfile = jsonfile 37 | self._init_storage() 38 | 39 | def save(self, weather: Weather) -> None: 40 | history = self._read_history() 41 | history.append({ 42 | "date": str(datetime.now()), 43 | "weather": format_weather(weather) 44 | }) 45 | self._write(history) 46 | 47 | def _init_storage(self) -> None: 48 | if not self._jsonfile.exists(): 49 | self._jsonfile.write_text("[]") 50 | 51 | def _read_history(self) -> list[HistoryRecord]: 52 | with open(self._jsonfile, "r") as f: 53 | return json.load(f) 54 | 55 | def _write(self, history: list[HistoryRecord]) -> None: 56 | with open(self._jsonfile, "w") as f: 57 | json.dump(history, f, ensure_ascii=False, indent=4) 58 | 59 | 60 | def save_weather(weather: Weather, storage: WeatherStorage) -> None: 61 | """Saves weather in the storage""" 62 | storage.save(weather) 63 | -------------------------------------------------------------------------------- /weather: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.10 2 | from pathlib import Path 3 | 4 | from coordinates import get_gps_coordinates 5 | from exceptions import ApiServiceError, CantGetCoordinates 6 | from weather_api_service import get_weather 7 | from weather_formatter import format_weather 8 | from history import save_weather, JSONFileWeatherStorage 9 | 10 | 11 | def main(): 12 | try: 13 | coordinates = get_gps_coordinates() 14 | except CantGetCoordinates: 15 | print("Не удалось получить GPS координаты") 16 | exit(1) 17 | try: 18 | weather = get_weather(coordinates) 19 | except ApiServiceError: 20 | print(f"Не удалось получить погоду по координатам {coordinates}") 21 | exit(1) 22 | print(format_weather(weather)) 23 | 24 | save_weather( 25 | weather, 26 | JSONFileWeatherStorage(Path.cwd() / "history.json") 27 | ) 28 | 29 | 30 | if __name__ == "__main__": 31 | main() 32 | -------------------------------------------------------------------------------- /weather_api_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dataclasses import dataclass 3 | from enum import Enum 4 | import json 5 | from json.decoder import JSONDecodeError 6 | import ssl 7 | from typing import Literal, TypeAlias 8 | import urllib.request 9 | from urllib.error import URLError 10 | 11 | from coordinates import Coordinates 12 | import config 13 | from exceptions import ApiServiceError 14 | 15 | Celsius: TypeAlias = float 16 | 17 | class WeatherType(str, Enum): 18 | THUNDERSTORM = "Гроза" 19 | DRIZZLE = "Изморось" 20 | RAIN = "Дождь" 21 | SNOW = "Снег" 22 | CLEAR = "Ясно" 23 | FOG = "Туман" 24 | CLOUDS = "Облачно" 25 | 26 | @dataclass(slots=True, frozen=True) 27 | class Weather: 28 | temperature: Celsius 29 | weather_type: WeatherType 30 | sunrise: datetime 31 | sunset: datetime 32 | city: str 33 | 34 | def get_weather(coordinates: Coordinates) -> Weather: 35 | """Requests weather in OpenWeather API and returns it""" 36 | openweather_response = _get_openweather_response( 37 | longitude=coordinates.longitude, latitude=coordinates.latitude) 38 | weather = _parse_openweather_response(openweather_response) 39 | return weather 40 | 41 | def _get_openweather_response(latitude: float, longitude: float) -> str: 42 | ssl._create_default_https_context = ssl._create_unverified_context 43 | url = config.OPENWEATHER_URL.format( 44 | latitude=latitude, longitude=longitude) 45 | try: 46 | return urllib.request.urlopen(url).read() 47 | except URLError: 48 | raise ApiServiceError 49 | 50 | def _parse_openweather_response(openweather_response: str) -> Weather: 51 | try: 52 | openweather_dict = json.loads(openweather_response) 53 | except JSONDecodeError: 54 | raise ApiServiceError 55 | return Weather( 56 | temperature=_parse_temperature(openweather_dict), 57 | weather_type=_parse_weather_type(openweather_dict), 58 | sunrise=_parse_sun_time(openweather_dict, "sunrise"), 59 | sunset=_parse_sun_time(openweather_dict, "sunset"), 60 | city=_parse_city(openweather_dict) 61 | ) 62 | 63 | def _parse_temperature(openweather_dict: dict) -> Celsius: 64 | return round(openweather_dict["main"]["temp"]) 65 | 66 | def _parse_weather_type(openweather_dict: dict) -> WeatherType: 67 | try: 68 | weather_type_id = str(openweather_dict["weather"][0]["id"]) 69 | except (IndexError, KeyError): 70 | raise ApiServiceError 71 | weather_types = { 72 | "1": WeatherType.THUNDERSTORM, 73 | "3": WeatherType.DRIZZLE, 74 | "5": WeatherType.RAIN, 75 | "6": WeatherType.SNOW, 76 | "7": WeatherType.FOG, 77 | "800": WeatherType.CLEAR, 78 | "80": WeatherType.CLOUDS 79 | } 80 | for _id, _weather_type in weather_types.items(): 81 | if weather_type_id.startswith(_id): 82 | return _weather_type 83 | raise ApiServiceError 84 | 85 | def _parse_sun_time( 86 | openweather_dict: dict, 87 | time: Literal["sunrise"] | Literal["sunset"]) -> datetime: 88 | return datetime.fromtimestamp(openweather_dict["sys"][time]) 89 | 90 | def _parse_city(openweather_dict: dict) -> str: 91 | try: 92 | return openweather_dict["name"] 93 | except KeyError: 94 | raise ApiServiceError 95 | 96 | 97 | if __name__ == "__main__": 98 | print(get_weather(Coordinates(latitude=55.7, longitude=37.6))) 99 | -------------------------------------------------------------------------------- /weather_formatter.py: -------------------------------------------------------------------------------- 1 | from weather_api_service import Weather 2 | 3 | def format_weather(weather: Weather) -> str: 4 | """Formats weather data in string""" 5 | return (f"{weather.city}, температура {weather.temperature}°C, " 6 | f"{weather.weather_type}\n" 7 | f"Восход: {weather.sunrise.strftime('%H:%M')}\n" 8 | f"Закат: {weather.sunset.strftime('%H:%M')}\n") 9 | 10 | 11 | if __name__ == "__main__": 12 | from datetime import datetime 13 | from weather_api_service import WeatherType 14 | print(format_weather(Weather( 15 | temperature=25, 16 | weather_type=WeatherType.CLEAR, 17 | sunrise=datetime.fromisoformat("2022-05-03 04:00:00"), 18 | sunset=datetime.fromisoformat("2022-05-03 20:25:00"), 19 | city="Moscow" 20 | ))) 21 | --------------------------------------------------------------------------------