Типизированный_Python_для_профессиональной_разработки. Профессиональной разработки
Скачать 3.38 Mb.
|
Реализация приложения — получение GPS координат Реализуем в первую очередь получение GPS координат, get_gps_coordinates.py : from weather_api_service import Weather def format_weather ( weather : Weather ) -> str : """Formats weather data in Тут будет печать данных погоды из структуры weather" from dataclasses import dataclass from subprocess import Popen , PIPE from exceptions import CantGetCoordinates @dataclass( slots = True , frozen = True ) class Coordinates : longitude : float latitude : float def get_gps_coordinates () -> Coordinates : """Returns current coordinates using MacBook GPS""" process = Popen ([ "whereami" ], stdout = PIPE ) ( output , err ) = process communicate () exit_code = process wait () if err is not None or exit_code != 0 : raise CantGetCoordinates output_lines = output decode (). strip (). lower (). split ( "\n" ) latitude = longitude = None for line in output_lines : if line startswith ( "latitude:" ): latitude = float ( line split ()[ 1 ]) Хочу обратить внимание тут вот на что. Если что-то пошло не такс процессом получения координат — мы не возвращаем какую-то ерунду вроде None . Мы возбуждаем (райзим, от англ. raise) исключение. Причём исключение не какое-то системное вроде ValueError , а наш собственный тип исключения, который мы назвали CantGetCoordinates и положили в специальный модуль, куда мы будем класть исключения Почему не ValueError , а свой тип исключений Чтобы разделять обычные питоновские ValueError от конкретно нашей ситуации с невозможностью получить координаты. Явное лучше неявного. Почему исключение, а не возврат None ? Потому что если у функции есть нормальный сценарий работы и ненормальный, то есть исключительный, то исключительный сценарий должен использовать исключения, а не возвращать какую-то ерунду вроде False , 0 , None , tuple() . Исключительная ситуация должна возбуждать исключение, и уже на уровне выше нашей функции мы должны решить, что с этой исключительной ситуацией делать. Код, который будет вызывать нашу функцию get_gps_coordinates , решит, что делать с исключительной ситуацией, на каком уровне и как эта ситуация должна быть обработана. Отлично. Функция отдаёт сейчас точные координаты, которые я не хочу раскрывать, давайте введём в приложение конфиг config.py ив нём зададим, использовать точные координаты или примерные. Я буду использовать примерные координаты. Погода от этого не изменится, просто в другой район города попаду line startswith ( "longitude:" ): longitude = float ( line split ()[ 1 ]) return Coordinates ( longitude = longitude , latitude = latitude ) if __name__ == "__main__" : ( get_gps_coordinates ()) class CantGetCoordinates ( Exception ): """Program can't get current GPS coordinates""" USE_ROUNDED_COORDS = True Отлично. Обратите внимание — мы не полагаемся здесь на тона какой строке будет значение широты и долготы в выдаче команды whereami . Мы ищем нужную строку во всех возвращаемых строках, не полагаясь на то, будут это первые строки или нет. Получается более надёжное решение на случай смены порядка строк в Теперь проведём рефакторинг, поделив большую, делающую слишком много всего функцию get_gps_coordinates на несколько небольших простых функций dataclasses import dataclass from subprocess import Popen , PIPE import config from exceptions import CantGetCoordinates @dataclass( slots = True , frozen = True ) class Coordinates : longitude : float latitude : float def get_gps_coordinates () -> Coordinates : """Returns current coordinates using MacBook GPS""" process = Popen ([ "whereami" ], stdout = PIPE ) output , err = process communicate () exit_code = process wait () if err is not None or exit_code != 0 : raise CantGetCoordinates output_lines = output decode (). strip (). lower (). split ( "\n" ) latitude = longitude = None for line in output_lines : if line startswith ( "latitude:" ): latitude = float ( line split ()[ 1 ]) if line startswith ( "longitude:" ): longitude = float ( line split ()[ 1 ]) if config USE_ROUNDED_COORDS : # Добавили округление координат latitude , longitude = map ( lambda c : round ( c , 1 ), [ latitude , longitude ]) return Coordinates ( longitude = longitude , latitude = latitude ) if __name__ == "__main__" : ( get_gps_coordinates ()) from dataclasses import dataclass from subprocess import Popen , PIPE from typing import Literal import config from exceptions import CantGetCoordinates @dataclass( slots = True , frozen = True ) class Coordinates : latitude : float longitude : float def get_gps_coordinates () -> Coordinates : """Returns current coordinates using MacBook GPS""" coordinates = _get_whereami_coordinates () return _round_coordinates ( coordinates ) def _get_whereami_coordinates () -> Coordinates : whereami_output = _get_whereami_output () coordinates = _parse_coordinates ( whereami_output ) return coordinates def _get_whereami_output () -> bytes : process = Popen ([ "whereami" ], stdout = PIPE ) output , err = process communicate () exit_code = process wait () if err is not None or exit_code != 0 : raise CantGetCoordinates return output def _parse_coordinates ( whereami_output : bytes ) -> Coordinates : try : output = whereami_output decode (). strip (). lower (). split ( "\n" ) except UnicodeDecodeError : raise CantGetCoordinates return Coordinates ( latitude = _parse_coord ( output , "latitude" ), longitude = _parse_coord ( output , "longitude" ) ) def _parse_coord ( output : list [ str ], coord_type : Literal [ "latitude" ] | Literal [ "longitude" ]) -> float : for line in output : if line startswith ( f" { coord_type } :" ): return _parse_float_coordinate ( line split ()[ 1 ]) else : Кода стало больше, функций стало больше, но код стал проще читаться и будет проще сопровождаться. Если бы мы сейчас писали тесты, то убедились бы ещё ив том, что этот код легче обложить тестами, чем предыдущий вариант с одной большой функцией, делающей всё подряд. Функции, имена которых начинаются с подчёркивания — не предназначены для вызова извне модуля, то есть они вызываются только соседними функциями модуля Почему много коротких функций это лучше, чем одна большая функция Потому что для того, чтобы понять, что происходит внутри функции на 50 строк, надо прочитать строк. А если эти 50 строк разбить на пару меньших функций и понятным образом эти пару функций назвать, тонам понадобится прочесть всего пару строк с вызовами этой пары функций и всё. Прочесть пару строк легче, чем 50. А если нам нужны детали реализации какой-то из этих меньших функций, мы всегда можем вне провалиться и посмотреть, что внутри. Функция get_gps_coordinates тут максимально проста — она получает координаты и затем округляет их и возвращает, всё. Два вызова понятно названных функций вместо длинного сложного кода, как было раньше. Также обратите внимание — абсолютно все функции типизированы, все принимаемые аргументы функций типизированы и все возвращаемые значения тоже типизированы. Причём типизированы максимально конкретными типами CantGetCoordinates def _parse_float_coordinate ( value : str ) -> float : try : return float ( value ) except ValueError : raise CantGetCoordinates def _round_coordinates ( coordinates : Coordinates ) -> Coordinates : if not config USE_ROUNDED_COORDS : return coordinates return Coordinates ( * map ( lambda c : round ( c , 1 ), [ coordinates latitude , coordinates longitude ] )) if __name__ == "__main__" : ( get_gps_coordinates ()) Эта логика реализована без классов, на обычных функциях. Это нормально. Ненужно использовать ООП просто для того, чтобы у вас были классы. Оттого, что мы обернём несколько описанных здесь функций в класс — никакого нового полезного качества в нашем коде не появится, просто вместо функций будет класс. В таком случае вовсе ненужно использовать классы. Обратите внимание также, как в функции _parse_float_coordinate обработана ошибка, которая может возникать, если вдруг координаты не получается привести из строки к типу float — мы возбуждаем (райзим) исключение своего типа. В любой ситуации, когда нам не удалось получить координаты из результатов команды whereami мы получаем такое исключение и можем обработать (или не обрабатывать) его в коде, который будет вызывать нашу верхнеуровневую функцию get_gps_coordinates . Про работу с исключениями более подробно мы поговорим в отдельном материале. Реализация приложения — получение погоды с Отлично, у нас реализована структура и скелет приложения, а также полностью реализована логика получения текущих GPS координат — в точном или округлённом варианте. Реализуем теперь получение по этим координатам значения погоды с использованием API сервиса OpenWeather. Добавим шаблон URL для получения погоды в Значения широты и долготы будем потом подставлять в этот шаблон. Если нам понадобится изменить однажды этот шаблон URL для получения данных, мы сможем не искать его где-то глубоко в приложении, он лежит в конфиге. Все данные, которые предполагаются как конфигурационные, имеет смысл выносить в отдельное место, которое можно назвать конфигом или настройками приложения ключ для сервиса OpenWeather лучше сохранить в переменной окружения и не хранить в исходном коде проекта (тогда значение константы будет получаться как = 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" ) то так 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 import urllib request from urllib error import URLError from coordinates import Coordinates import config from exceptions import ApiServiceError Celsius = int class WeatherType ( str , Enum ): THUNDERSTORM Гроза DRIZZLE Изморось RAIN Дождь SNOW Снег CLEAR Ясно FOG Туман CLOUDS Облачно 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 : Как и ранее, следуем подходу небольших функций, каждая из которых делает одно небольшое действие, а общий результат достигается за счёт компоновки этих небольших функций. Логику парсинга каждой нужной нам единицы информации выносим в отдельные небольшие функции — отдельно парсинг температуры, отдельно парсинг типа погоды и времени восхода и заката. Каждая функция названа глагольным словом — получить, распарсить и тд. Напомню, что функция это ничто иное как именованный блок кода, этот блок кода что-то делает и потому его имеет смысл называть именно глаголом, который опишет это действие. Тут стоит отметить, что для парсинга и одновременно валидации JSON данных удобно использовать библиотеку Pydantic . О ней было видео на канале «Диджитализируй!». Здесь мы не стали её использовать из-за возможно некоторой её избыточности для нашей простой задачи, а также чтобы ограничиться только стандартной библиотекой Осталось реализовать принтер, который выведет нужные нам значения погоды в консоль! Реализация приложения — принтер погоды Итак, файл weather_formatter.py : return openweather_dict [ "name" ] if __name__ == "__main__" : ( get_weather ( Coordinates ( latitude = 55.7 , longitude = 37.6 ))) from weather_api_service import Weather def format_weather ( weather : Weather ) -> str : """Formats weather data in string""" return ( f" { weather city } , температура { weather temperature } °C, " f" { weather Восход { weather sunrise Закат { weather sunset strftime ( '%H:%M' )} \n" ) if __name__ == "__main__" : from datetime import datetime from weather_api_service import WeatherType print ( format_weather ( Weather ( temperature = 25 , weather_type = WeatherType CLEAR , sunrise = datetime fromisoformat ( "2022-05-03 04:00:00" ), sunset = datetime fromisoformat ( "2022-05-03 20:25:00" ), Обратите внимание на печать типа погоды — weather.weather_type . Так можно, потому что мы отнаследовали WeatherType от str и Enum , а не только от Enum . Если бы мы отнаследовали WeatherType только от Enum , то для получения строкового значения нужно было бы напрямую обратиться к атрибуту value , вот так: weather.weather_type.value При необходимости выводить на печать значения как-то иначе, всегда можно это реализовать водном месте приложения. Как всегда обратите внимание, здесь реализован блок if __name__ == "__main__": , который позволяет тестировать код при непосредственно прямом вызове этого файла python3.10 weather_formatter.py . При импорте функции format_weather код в этом блоке выполнен не будет. Обработка исключений В процессе работы приложения могут возникать 2 вида исключений, которые мы заложили в приложении — что-то может пойти не такс, через который мы получаем текущие GPS координаты. Его может не быть в системе или по какой-то причине он может выдать результат не того формата, что мы ожидаем. В таком случае возбуждается исключение Также что-то может пойти не так при запросе погоды по координатам. Тогда возбуждается исключение ApiServiceError . Обработаем и его. Файл weather : city = "Moscow" ))) #!/usr/bin/env python3.10 from exceptions import ApiServiceError , CantGetCoordinates from coordinates import get_gps_coordinates from weather_api_service import get_weather from weather_formatter import format_weather def main (): try : coordinates = get_gps_coordinates () except Не смог получить GPS координаты exit ( 1 ) try : weather = get_weather ( coordinates ) except Не смог получить погоду в API сервиса погоды exit ( 1 ) ( format_weather ( weather )) if __name__ == "__main__" : main () |