├── .gitignore ├── src ├── images │ ├── cover.pdf │ ├── cover.png │ ├── ide_err.png │ ├── bat_history.png │ ├── weather_app.png │ ├── bat_history2.png │ ├── ide_autocomlete.png │ ├── mypy_example01.png │ ├── mypy_example02.png │ ├── whereami_screenshot.png │ └── validate_user_error_in_pycharm.png ├── weather │ ├── run.md │ ├── dict.md │ ├── typeddict.md │ ├── exceptions.md │ ├── realize-printer.md │ ├── literal.md │ ├── namedtuple.md │ ├── skeleton.md │ ├── alias.md │ ├── dataclass.md │ ├── structure.md │ ├── analysis.md │ ├── intro.md │ ├── realize-openweather.md │ ├── enum.md │ ├── realize-coordinates.md │ └── interfaces.md ├── additional │ ├── intro.md │ ├── optional.md │ ├── callable.md │ ├── generics.md │ ├── stub.md │ ├── question.md │ └── containers.md ├── resume │ ├── course.md │ ├── intro.md │ └── changelog.md ├── type-hinting │ ├── zen.md │ ├── ide.md │ ├── intro.md │ ├── interp.md │ ├── readability.md │ └── early-errors-catching.md ├── mypy │ └── intro.md ├── SUMMARY.md └── intro │ └── intro.md ├── book.toml ├── LICENSE.md ├── Makefile ├── README.md ├── Dockerfile └── theme └── index.hbs /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | book 3 | *.zip 4 | .obsidian 5 | -------------------------------------------------------------------------------- /src/images/cover.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/cover.pdf -------------------------------------------------------------------------------- /src/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/cover.png -------------------------------------------------------------------------------- /src/images/ide_err.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/ide_err.png -------------------------------------------------------------------------------- /src/images/bat_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/bat_history.png -------------------------------------------------------------------------------- /src/images/weather_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/weather_app.png -------------------------------------------------------------------------------- /src/images/bat_history2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/bat_history2.png -------------------------------------------------------------------------------- /src/images/ide_autocomlete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/ide_autocomlete.png -------------------------------------------------------------------------------- /src/images/mypy_example01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/mypy_example01.png -------------------------------------------------------------------------------- /src/images/mypy_example02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/mypy_example02.png -------------------------------------------------------------------------------- /src/images/whereami_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/whereami_screenshot.png -------------------------------------------------------------------------------- /src/images/validate_user_error_in_pycharm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexey-goloburdin/typed-python-book/HEAD/src/images/validate_user_error_in_pycharm.png -------------------------------------------------------------------------------- /src/weather/run.md: -------------------------------------------------------------------------------- 1 | # Проверяем работу приложения 2 | 3 | Всё готово, вжух! Проверяем работу приложения: 4 | 5 | ![](../images/weather_app.png) 6 | 7 | Отлично! 8 | -------------------------------------------------------------------------------- /src/additional/intro.md: -------------------------------------------------------------------------------- 1 | # Ещё о практических аспектах типизации 2 | 3 | Поговорим о других аспектах использования типизации в Python. Они так же 4 | важны, как и всё обсуждаемое выше, не пропускайте эту главу. 5 | -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Alexey Goloburdin"] 3 | language = "ru" 4 | multilingual = false 5 | src = "src" 6 | title = "Типизированный Python для профессиональной разработки" 7 | 8 | [output.html] 9 | 10 | [output.pdf] -------------------------------------------------------------------------------- /src/resume/course.md: -------------------------------------------------------------------------------- 1 | # Об образовательной программе Диджитализируй! 2 | Сейчас готовится к перезапуску курс [Основы компьютерных и веб-технологий с Python](https://course01.to.digital/). Курс — отличный, на Stepik Awards был признан лучший платным курсом 2021. Подписывайся, чтобы не пропустить старт. Новое издание курса будет на 30% больше и полезнее! 3 | 4 | А если ты читаешь это позднее июня 2022, то вполне вероятно, что курс уже вышел. Беги по ссылке! 5 | 6 | -------------------------------------------------------------------------------- /src/additional/optional.md: -------------------------------------------------------------------------------- 1 | # Опциональные данные 2 | 3 | Для указания опциональных данных можно пользоваться вертикальной чертой: 4 | 5 | ```python 6 | def print_hello(name: str | None=None) -> None: 7 | print(f"hello, {name}" if name is not None else "hello anon!") 8 | ``` 9 | 10 | Здесь параметр `name` функции `print_hello` является опциональным, что отражено а) в type hinting (напомню, вертикальная черта в подсказках типов означает ИЛИ) б) задано значение по умолчанию None. 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Исключительные права на книгу принадлежат автору Алексею Голобурдину. 2 | 3 | Разрешается: 4 | 5 | * загружать книгу, читать книгу, печатать книгу без изменений 6 | * в любом виде распространять ссылку на книгу https://t.me/t0digital/151 7 | * присылать запросы на изменения книги путём Merge Request на платформе GitHub 8 | * использование материалов книги с указанием автора и ссылки на книгу https://t.me/t0digital/151 9 | 10 | Не разрешается: 11 | 12 | * продавать книгу 13 | * распространять версии книги, отличающиеся от исходной версии, приведённой в этом GitHub репозитории и по ссылке https://t.me/t0digital/151 14 | * распростронять книгу не в виде ссылки на её актуальную версию https://t.me/t0digital/151 15 | 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env makefile 2 | IMAGE_NAME := tuod/book-renderer 3 | USER_ID := $$(id -u) 4 | GROUP_ID := $$(id -g) 5 | VERSION := 0.0.$(shell date '+%Y%m%d%H%M%S') 6 | 7 | all: pull render 8 | 9 | deploy: build publish 10 | 11 | pull: 12 | @docker pull ${IMAGE_NAME}:latest 13 | 14 | build: 15 | @docker build \ 16 | --build-arg USER=${USER} \ 17 | --build-arg USER_ID=${USER_ID} \ 18 | --build-arg GROUP_ID=${GROUP_ID} \ 19 | -t ${IMAGE_NAME}:latest . 20 | 21 | publish: 22 | @docker tag ${IMAGE_NAME}:latest ${IMAGE_NAME}:${VERSION} 23 | @docker push ${IMAGE_NAME}:${VERSION} 24 | @docker push ${IMAGE_NAME}:latest 25 | 26 | render: 27 | @docker run -i -v ${PWD}:/data -w /data \ 28 | --user ${USER_ID}:${GROUP_ID} \ 29 | --rm ${IMAGE_NAME}:latest bash -c "mdbook build" 30 | -------------------------------------------------------------------------------- /src/type-hinting/zen.md: -------------------------------------------------------------------------------- 1 | # Zen of Python 2 | 3 | **Явное лучше неявного**. Когда у нас явно и конкретно указан тип данных — это хорошо и это соответствует дзену. Когда же нам приходится неявно домысливать, что тут, наверное, (*наверное!*) строка или `int` — это плохо и это не соответствует дзену. 4 | 5 | **Простое лучше сложного.** Подсказки типов это просто, во всяком случае точно проще, чем попытки описать тип данных в докстринге функции в том или ином формате. 6 | 7 | **Удобочитаемость важна**. Опять же, обсудили выше в примере с юзером, в котором непонятно какая структура без явной типизации. 8 | 9 | **Должен быть один — и желательно всего один — способ это сделать.** Это как раз механизм type hinting. Не надо описывать типы в докстрингах или в формате какого-то внешнего инструмента с птичьим языком. Есть родной механизм в языке программирования, который решает задачу описания типов данных. 10 | -------------------------------------------------------------------------------- /src/additional/callable.md: -------------------------------------------------------------------------------- 1 | # Вызываемые объекты 2 | 3 | Как известно функции в Python это обычные объекты, которые можно передавать в другие функции, возвращать из других функций и т. п., поэтому для них тоже есть свой тип `Callable`: 4 | 5 | ```python 6 | from typing import Callable 7 | 8 | def mysum(a: int, b: int) -> int: 9 | return a + b 10 | 11 | 12 | def process_operation(operation: Callable[[int, int], int], 13 | a: int, b: int) -> int: 14 | return operation(a, b) 15 | 16 | print(process_operation(mysum, 1, 5)) # 6 17 | ``` 18 | 19 | Здесь для аргумента `operation` функции `process_operation` проставлен тип `Callable[[int, int], int]`. Здесь `[int, int]` — это типы аргументов функции `operation`, получается, что у этой функции должно быть два аргумента и они оба должны иметь тип `int`. Последний `int` в определении типа `Callable[[int, int], int]` обозначает тип возвращаемого функцией значения. 20 | -------------------------------------------------------------------------------- /src/resume/intro.md: -------------------------------------------------------------------------------- 1 | # Резюме 2 | 3 | Грамотное использование type hinting и осознанный выбор классов отделяет код новичка от кода растущего профессионала. Пользуйся подсказками типов, продумывай структуру твоего приложения и используемые типы данных, и тогда твои решения будут красивыми, приятно читаемыми, легко поддерживаемыми и надёжными. 4 | 5 | Ты дочитал досюда и разобрался с материалом? Отлично, молодец! Думаю, тебе стоит прислать нам резюме на почту: [join@to.digital](mailto:join@to.digital) 6 | 7 | ## Контакты 8 | 9 | У нас много хороших материалов в YouTube, если вдруг ты ещё не подписан — 10 | [YouTube-канал Диджитализируй!](https://www.youtube.com/channel/UC9MK8SybZcrHR3CUV4NMy2g) 11 | 12 | Много оперативной и текстовой полезной информации — в [Telegram-канале](https://t.me/t0digital)! 13 | 14 | Также, конечно, есть [VK-группа](https://vk.com/digitalize.team) и [Дзен](https://zen.yandex.ru/id/6235d32cb64df01e6e78c883). 15 | -------------------------------------------------------------------------------- /src/type-hinting/ide.md: -------------------------------------------------------------------------------- 1 | # Помощь IDE при разработке 2 | 3 | Если мы пользуемся подсказками типов, то наша IDE уже на этапе написания кода будет подсказывать нам места с потенциальными ошибками, о чем говорилось выше, то есть мы на самом самом раннем этапе некоторые типы ошибок предотвратим, что хорошо, но еще и разрабатывать нам будет удобнее, потому что будет работать автодополнение и все плюшки нашей IDE. В реальности использовать код, который написан с типами, гораздо проще, приятнее, удобнее и быстрее, чем код без явно указанных типов, так как, если типы известны нашей IDE, то она всячески будет помогать нам, делать автодополнения, предлагать сразу правильные методы, выполнять проверки и так далее. 4 | 5 | > При использовании подсказок типов IDE в значительной степени помогает писать код, в полной мере работают подсказки, автодополнения и т. д. Чтобы IDE действительно помогала нам писать код, пользоваться подсказками типов просто необходимо. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Типизированный Python для профессиональной разработки 2 | 3 | 4 | 5 | Здесь лежат md-файлы книги «Типизированный Python для профессиональной разработки». 6 | Книга собирается в [HTML](https://to.digital/typed-python) с помощью 7 | [mdbook](https://rust-lang.github.io/mdBook/). 8 | 9 | * [Книга в красивом PDF](https://t.me/t0digital/151). PDF был собран с 10 | помощью [Obsidian.md](https://obsidian.md/), в нём же происходило написание 11 | материала. 12 | * [Веб-версия](https://to.digital/typed-python/) 13 | * [Видео версия](https://www.youtube.com/watch?v=dKxiHlZvULQ) 14 | 15 | --- 16 | 17 | Самостоятельный рендер в HTML и PDF (движком chrome) можно выполнить командой ```make``` в корне репозитория. Должен быть установлен докер. 18 | На выходе в корне репозитория появится каталог book. 19 | Тестировалось на Ubuntu 22.10. 20 | -------------------------------------------------------------------------------- /src/weather/dict.md: -------------------------------------------------------------------------------- 1 | # Обычный словарь dict 2 | 3 | Вторым вариантом структуры, которой тут можно воспользоваться — это словарь, просто обычный `dict`: 4 | 5 | ```python 6 | # Совсем плохо! Что за dict, что внутри в нём? 7 | def get_gps_coordinates() -> dict: 8 | return {"longitude": 10, "latitude": 20} 9 | 10 | # Так лучше, хотя бы прописаны типы для ключей и значений 11 | def get_gps_coordinates() -> dict[str, float]: 12 | return {"longitude": 10, "latitude": 20} 13 | 14 | coords = get_gps_coordinates() 15 | print(coords["longitude"]) # IDE не покажет опечатку в `longitude` 16 | ``` 17 | 18 | Как видно, при вводе ключа словаря `longitude` IDE нам не подсказывает и нет никакой проверки на опечатки. Если мы опечатаемся в ключе словаря, то эта ошибка может дойти до рантайма и уже в рантайме упадёт ошибка `KeyError`. Хочется, чтобы IDE и статический анализатор кода вроде `mypy`, о котором поговорим позднее, помогали нам, а чтобы они нам помогали, надо чётко прописывать типы данных и `dict` это не то, что нам нужно. 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | ENV LANG C 4 | ENV DEBIAN_FRONTEND noninteractive 5 | 6 | RUN apt update; \ 7 | apt install -y \ 8 | build-essential \ 9 | pkg-config \ 10 | libssl-dev \ 11 | curl; \ 12 | curl -s https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ 13 | -o google-chrome-stable_current_amd64.deb \ 14 | && dpkg -i google-chrome-stable_current_amd64.deb 2>/dev/null \ 15 | || echo; \ 16 | apt -fy install; \ 17 | dpkg -i google-chrome-stable_current_amd64.deb; \ 18 | apt-get clean all; 19 | 20 | ARG USER_ID 21 | ARG GROUP_ID 22 | ARG USER 23 | 24 | RUN groupadd -g ${GROUP_ID} docker \ 25 | && adduser --uid ${USER_ID} --gid ${GROUP_ID} --disabled-password --gecos '' ${USER} 26 | 27 | USER ${USER} 28 | 29 | # Install Rust and cargo 30 | RUN curl -sSf https://sh.rustup.rs | sh -s -- -y -q --profile minimal; 31 | 32 | ENV PATH="/home/${USER}/.cargo/bin:${PATH}" 33 | 34 | RUN cargo install mdbook \ 35 | && cargo install mdbook-pdf \ 36 | && cargo install cargo-cache; \ 37 | cargo cache -a 38 | 39 | WORKDIR /data 40 | -------------------------------------------------------------------------------- /src/weather/typeddict.md: -------------------------------------------------------------------------------- 1 | # TypedDict 2 | 3 | Есть еще специальный типизированный `dict`. Если почему-то хочется иметь доступ к данным именно как к словарю, а не как к классу (то есть писать `coordinates["latitude"]` вместо `coordinates.latitude`), то можно воспользоваться типизированным словарём: 4 | 5 | ```python 6 | from typing import TypedDict 7 | 8 | class Coordinates(TypedDict): 9 | longitude: float 10 | latitude: float 11 | 12 | c = Coordinates(longitude=10, latitude=20) 13 | print(c["longitude"]) # Работает автодополнение в IDE 14 | print(c["longitudeRRR"]) # IDE покажет ошибку 15 | ``` 16 | 17 | Я на практике не вижу большого смысла пользоваться именно типизированным словарём, но в целом можно найти какие-то сценарии для его использования. Например, уже есть много кода, который использует нашу структуру как словарь, но мы хотим добавить в эту структуру типизацию и при этом не переписывать код, который уже использует эту структуру. В таком сценарии как раз имеет смысл воспользоваться `TypedDict`. 18 | 19 | А когда мы пишем новый код, то именованные кортежи или датаклассы это наиболее часто используемые варианты. О датаклассах мы как раз сейчас и поговорим! 20 | -------------------------------------------------------------------------------- /src/resume/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Здесь приведены редакции этой книги и изменения в каждой. Последнюю, актуальную версию книги в формате PDF и EPUB можно скачать здесь: [t.me/t0digital/151](t.me/t0digital/151), а также читать на данном сайте. 4 | 5 | **20 марта 2023** Изменения: 6 | 7 | - Исправлены опечатки 8 | 9 | **8 июня 2022** Изменения: 10 | 11 | - Добавлена информация о TypeAlias 12 | - Исправлены найденные опечатки и ошибки 13 | 14 | **27 мая 2022 16:00** Изменения: 15 | 16 | - Обновлена глава о `Literal` 17 | - Добавлена информация о `pyright` — анализаторе кода в дополнение к `mypy` 18 | - Добавлены рекомендации в главу «Контейнеры — Iterable, Sequence, Mapping и другие» 19 | - Исправлены найденные опечатки 20 | 21 | **27 мая 2022 04:00** Изменения: 22 | 23 | - Добавилась информация о протоколах `typing.Protocol` в главе об интерфейсах 24 | - Добавилась информация о параметрах датаклассов `slots` и `frozen` 25 | - Добавлен пример в главу «Интерпретатор не проверяет подсказки типов» 26 | - Расширена глава о `Enum`, добавлено про наследование от `str` 27 | - Расширена глава «Подсказки типов нужны только в функциях?» 28 | - Исправлены найденные опечатки 29 | 30 | **26 мая 2022** Первая версия книги. 31 | -------------------------------------------------------------------------------- /src/weather/exceptions.md: -------------------------------------------------------------------------------- 1 | # Обработка исключений 2 | 3 | В процессе работы приложения могут возникать 2 вида исключений, которые мы заложили в приложении — что-то может пойти не так с `whereami`, через который мы получаем текущие GPS-координаты. Его может не быть в системе или по какой-то причине он может выдать результат не того формата, что мы ожидаем. В таком случае возбуждается исключение `CantGetCoordinates`. 4 | 5 | Также что-то может пойти не так при запросе погоды по координатам. Тогда возбуждается исключение `ApiServiceError`. Обработаем и его. Файл `weather`: 6 | 7 | ```python 8 | #!/usr/bin/env python3.10 9 | from exceptions import ApiServiceError, CantGetCoordinates 10 | from coordinates import get_gps_coordinates 11 | from weather_api_service import get_weather 12 | from weather_formatter import format_weather 13 | 14 | 15 | def main(): 16 | try: 17 | coordinates = get_gps_coordinates() 18 | except CantGetCoordinates: 19 | print("Не смог получить GPS-координаты") 20 | exit(1) 21 | try: 22 | weather = get_weather(coordinates) 23 | except ApiServiceError: 24 | print("Не смог получить погоду в API-сервиса погоды") 25 | exit(1) 26 | print(format_weather(weather)) 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | ``` 32 | 33 | -------------------------------------------------------------------------------- /src/additional/generics.md: -------------------------------------------------------------------------------- 1 | # Дженерики 2 | 3 | Что если мы хотим написать обобщённую функцию, которая принимает на вход итерируемую структуру, то есть структуру, по которой можно итерироваться, и возвращает результат первой итерации? 4 | 5 | ```python 6 | from typing import TypeVar, Iterable 7 | 8 | T = TypeVar("T") 9 | 10 | def first(iterable: Iterable[T]) -> T | None: 11 | for element in iterable: 12 | return element 13 | 14 | print(first(["one", "two"])) # one 15 | print(first((100, 200))) # 200 16 | ``` 17 | 18 | Как видите, типом данных в этой итерируемой структуре `iterable` могут быть любые данные, а наши type hinting в функции `first` говорят буквально, что функция принимает на вход итерабельную структуру данных, каждый элемент которой имеет тип `T`, и функция возвращает тот же тип `T`. Тип `T` при этом может быть любым. 19 | 20 | Это так называемые дженерики, то есть обобщённые типы. 21 | 22 | Причём имя `T` здесь это пример, он часто используется именно так, `T`, от *Type*, но название типа может быть и любым другим. 23 | 24 | Помимо дженериков можно сохранять отдельные типы для лучшей читаемости кода и подсказки читателю, что именно за данные здесь хранятся: 25 | 26 | ```python 27 | from dataclasses import dataclass 28 | 29 | Phone = str 30 | 31 | @dataclass 32 | class User: 33 | user_id: int 34 | phone: Phone 35 | 36 | def get_user_phone(user: User) -> Phone: 37 | return user.phone 38 | ``` 39 | 40 | Мы уже использовали это в коде приложения погоды, когда задавали псевдоним для градусов Цельсия. 41 | -------------------------------------------------------------------------------- /src/additional/stub.md: -------------------------------------------------------------------------------- 1 | # Stub файлы и работа с нетипизированными библиотеками 2 | 3 | Важно понимать, что type hinting работает не только для аргументов функций и возвращаемых значений. Мы можем просто создать переменную и указать ей тип: 4 | 5 | ```python 6 | book: str = "Тополек мой в красной косынке" 7 | ``` 8 | 9 | В таком сценарии это избыточно — IDE и статический анализатор кода и так видят, что в переменной `book` хранится значение типа `str`. Однако в таком сценарии: 10 | 11 | ```python 12 | book: str = find_book_in_library("Тополек мой в красной косынке") 13 | ``` 14 | 15 | функция поиска книги `find_book_in_library` может быть не нашей функцией, а функцией какой-то внешней библиотеки, которая не использует подсказки типов. То есть для функции может быть не проставлен тип возвращаемого значения. Чтобы IDE и статический анализатор знали, что тип данных, который будет храниться в `book`, это именно `str`, можно таким образом подсказать инструментам о верном типе. Иногда это бывает очень полезно, когда библиотека не использует подсказки типов, а возвращаемый тип данных какой-то сложный и мы хотим, чтобы IDE и mypy нам помогали анализировать наш код и типы. 16 | 17 | В то же время в Python существует механизм так называемых стаб-файлов, которые позволяют типизировать в том числе внешние библиотеки. Например, для django есть пакет в pip, который называется `django-stubs`. О стаб-файлах есть [видео](https://www.youtube.com/watch?v=KofihAoSp2U) на канале Диджитализируй!. 18 | 19 | Если вы используете нетипизированную библиотеку — можно поискать готовые стаб-файлы для неё, чтобы воспользоваться преимуществами типизированного Python. 20 | -------------------------------------------------------------------------------- /src/weather/realize-printer.md: -------------------------------------------------------------------------------- 1 | # Реализация приложения — принтер погоды 2 | 3 | Итак, файл `weather_formatter.py`: 4 | 5 | ```python 6 | from weather_api_service import Weather 7 | 8 | def format_weather(weather: Weather) -> str: 9 | """Formats weather data in string""" 10 | return (f"{weather.city}, температура {weather.temperature}°C, " 11 | f"{weather.weather_type}\n" 12 | f"Восход: {weather.sunrise.strftime('%H:%M')}\n" 13 | f"Закат: {weather.sunset.strftime('%H:%M')}\n") 14 | 15 | if __name__ == "__main__": 16 | from datetime import datetime 17 | from weather_api_service import WeatherType 18 | print(format_weather(Weather( 19 | temperature=25, 20 | weather_type=WeatherType.CLEAR, 21 | sunrise=datetime.fromisoformat("2022-05-03 04:00:00"), 22 | sunset=datetime.fromisoformat("2022-05-03 20:25:00"), 23 | city="Moscow" 24 | ))) 25 | ``` 26 | 27 | Обратите внимание на печать типа погоды — `weather.weather_type`. Так можно, потому что мы отнаследовали `WeatherType` от `str` и `Enum`, а не только от `Enum`. Если бы мы отнаследовали `WeatherType` только от `Enum`, то для получения строкового значения нужно было бы напрямую обратиться к атрибуту `value`, вот так: `weather.weather_type.value` . 28 | 29 | При необходимости выводить на печать значения как-то иначе, всегда можно это реализовать в одном месте приложения. Как всегда обратите внимание, здесь реализован блок `if __name__ == "__main__":`, который позволяет тестировать код при непосредственно прямом вызове этого файла `python3.10 weather_formatter.py`. При импорте функции `format_weather` код в этом блоке выполнен не будет. 30 | -------------------------------------------------------------------------------- /src/type-hinting/intro.md: -------------------------------------------------------------------------------- 1 | # Type hinting 2 | 3 | Что делает любая программа? Оперирует данными, то есть какие-то данные принимает на вход, какие-то данные отдаёт на выход, а внутри данные как-то трансформирует, обрабатывает и передаёт в разные функции, классы, модули и так далее. И весь вопрос в том, в каком виде и формате программа внутри себя эти данные передаёт! То есть — какие типы данных для этого используются. Часто одни и те же данные можно передавать внутри приложения строкой, списком, кортежем, словарём и массой других способов. 4 | 5 | Как все мы знаем, Python это язык с динамической типизацией. Что означает динамическая типизация? Что тип переменной определяется не в момент создания переменной, а в момент присваивания значения этой переменной. Мы можем сохранить в переменную строку, потом число, потом список, и это будет работать. Фактически интерпретатор Python сам выводит типы данных и мы их нигде не указываем, вообще не думаем об этом — просто используем то, что нам нужно в текущий момент. 6 | 7 | ```python 8 | user = "Пётр" 9 | user = 120560 10 | user = { 11 | "name": "Пётр", 12 | "username": "petr@email.com", 13 | "id": 120560 14 | } 15 | user = ("Пётр", "petr@email.com", 120560) 16 | ``` 17 | 18 | Так зачем же вводить type hinting в язык с динамической типизацией? А я напомню, что в Python сейчас есть type hinting, то есть подсказки типов, они же есть в PHP, а в JS даже разработали TypeScipt, отдельный язык программирования, который является надстройкой над JS и вводит типизацию. Зачем это всё делается, для чего? Вроде скриптовые языки, не надо писать типы, думать о них, и всё прекрасно, а тут раз — и вводят какие-то типы данных. 19 | 20 | Зачем в динамически типизированном языке вводить явное указание типов? 21 | -------------------------------------------------------------------------------- /src/weather/literal.md: -------------------------------------------------------------------------------- 1 | # Словарь с Literal ключами 2 | 3 | Плюс в описанном выше словаре ключом является строка, получается — любая строка? Но нет, в реальности не любая, а только одна из двух строк — `longitude` или `latitude`. Это можно отразить в type hinting с помощью `Literal`: 4 | 5 | ```python 6 | from typing import Literal 7 | 8 | def get_gps_coordinates() -> dict[Literal["longitude"] | Literal["latitude"], float]: 9 | return {"longitude": 10, "latitude": 20} 10 | 11 | print( 12 | get_gps_coordinates()["longitude"] 13 | ) 14 | 15 | print( 16 | get_gps_coordinates()["longitudeRRR"] # Тут IDE покажет ошибку! 17 | ) 18 | ``` 19 | 20 | `Literal` позволяет указать не просто какой-то тип вроде `str`, а позволяет указать конкретное значение этого типа. В данном случае у нас ключом может быть либо строка со значением `"longitude"`, либо строка со значением `"latitude"`. 21 | 22 | Вот эта вертикальная черта обозначает ИЛИ, то есть или тип слева от черты, или тип справа от черты. Это синтаксис Python 3.10, в предыдущих версиях Python нужно было импортировать из `typing` специальный тип `Union`, который делал то же самое. Сейчас можно просто пользоваться вертикальной чертой для того, чтобы задать несколько возможных типов для переменной. 23 | 24 | В случае именно `Literal` для указания нескольких возможных значений можно обойтись без объединения нескольких типов через вертикальную черту и задать тип следующим образом: 25 | 26 | ```python 27 | def get_gps_coordinates() -> dict[Literal["longitude", "latitude"], float]: 28 | return {"longitude": 10, "latitude": 20} 29 | ``` 30 | 31 | А если тип переменной представляет из себя, например, число или строку, тогда эти типы объединяются через вертикальную черту: 32 | 33 | ```python 34 | age: int | str 35 | ``` 36 | 37 | Кстати, *literally* — по-русски означает «буквально». То есть, когда нам надо буквально задать конкретные значения в типе, мы можем это сделать при помощи типа `Literal`. 38 | 39 | В целом в таком формате использовать словарь здесь можно, но мне больше нравится вариант с именованным кортежем из этих двух вариантов. 40 | -------------------------------------------------------------------------------- /src/weather/namedtuple.md: -------------------------------------------------------------------------------- 1 | # NamedTuple — именованный кортеж 2 | 3 | Можно воспользоваться именованным кортежем `NamedTuple`. В Python есть именованные кортежи в составе пакета `collections` и в составе `typing`. Чтобы можно было указать полям кортежа типы мы, конечно, воспользуемся импортом из `typing`: 4 | 5 | ```python 6 | from typing import NamedTuple 7 | 8 | class Coordinates(NamedTuple): 9 | latitude: float 10 | longitude: float 11 | 12 | def get_gps_coordinates() -> Coordinates: 13 | """Returns current coordinates using MacBook GPS""" 14 | return Coordinates(10, 20) 15 | ``` 16 | 17 | Именованные кортежи — такие же кортежи, как и обычные `tuple`, но каждый элемент кортежа имеет имя, по которому мы можем к нему обращаться. Обращаться по имени ведь проще, чем по индексу. Индекс `0` нам мало что говорит о данных, которые лежат в этом индексе, а имя `longitude` прямо говорит нам, что тут хранится географическая долгота. 18 | 19 | Теперь пользоваться кодом проще и разночтений никаких нет: 20 | 21 | ```python 22 | coordinates = get_gps_coordinates() 23 | print(f"Широта:", coordinates.latitude) # Печать широты 24 | print(f"Долгота:", coordinates.longitudeRRR) # IDE подсветит ошибку опечатки 25 | ``` 26 | 27 | В редакторе кода срабатывает автокомплит (*autocomplete*), то есть автодополнение кода. Мы начинаем набирать `coordinates.lat` и редактор подсказывает нам, что здесь должно быть `latitude`, можно просто выбрать то, что подсказывает редактор и ускорить набор текста, заодно устранив шанс возникновения опечаток: 28 | 29 | ![](../images/ide_autocomlete.png) 30 | 31 | А ещё, если по какой-то причине опечатки всё же возникли, то редактор подсветит нам места с такими проблемами: 32 | 33 | 34 | ![](../images/ide_err.png) 35 | 36 | При этом такой именованный кортеж по-прежнему является кортежем, то есть им можно пользоваться и так, с распаковкой: 37 | 38 | ```python 39 | latitude, longitude = get_gps_coordinates() 40 | ``` 41 | 42 | А также, как и в случае с обычным кортежем, нельзя изменять значения элементов кортежа: 43 | 44 | ```python 45 | coordinates = get_gps_coordinates() 46 | coordinates.latitude = 10 # IDE подсветит ошибку тут 47 | ``` 48 | 49 | -------------------------------------------------------------------------------- /src/mypy/intro.md: -------------------------------------------------------------------------------- 1 | # Статические анализаторы mypy и pyright 2 | 3 | `mypy` это инструмент, который устанавливается отдельно как pip-пакет и запускается в проекте как часть тестов или CI/CD процесса. Перед сборкой и раскаткой приложения на сервер запускается проверка исходного Python-кода с `mypy` и если `mypy` находит ошибки, то процесс останавливается, разработчики исправляют найденные ошибки и процесс повторяется. Это приводит к тому, что до продакшн, то есть до рантайма и до живых пользователей соответственно ошибок долетает меньше, потому что многое выявляется на более ранних этапах. 4 | 5 | В директории проекта создадим и активируем виртуальное окружение, установим в него `mypy` и запустим проверку нашего кода: 6 | 7 | ```bash 8 | python3.10 -m venv env 9 | . ./env/bin/activate 10 | pip install mypy 11 | mypy ./weather 12 | ``` 13 | 14 | ![](../images/mypy_example01.png) 15 | 16 | Как видим, `mypy` не нашёл проблем в нашем коде. Внесём специально ошибку в код и убедимся, что `mypy` её найдёт: 17 | 18 | ![](../images/mypy_example02.png) 19 | 20 | Запуск `mypy` можно встроить в процесс CI/CD, чтобы процесс разворачивания приложения на серверах не запускался, если проверки `mypy` не прошли. Таким образом до runtime не смогут дойти ошибки, связанные с некорректным использованием типов данных, и это здорово — надёжность приложения значительно возрастает! 21 | 22 | И ещё важно отметить, что используя mypy, вы можете проверять корректность своих тайп-хинтингов, которые вы указали. Пока учишься могут быть вопросы, правильно ли указан тип — вот можно указать тип у параметра функции, вызвать эту функцию с данными и посмотреть, как поведёт себя проверятор типов, встроенный в IDE, и как поведёт себя `mypy`. 23 | 24 | Помимо `mypy` пользуется популярностью анализатор `pyright`. Они работают по-разному, например, такой код валиден с точки зрения `mypy` (и с точки зрения анализатора, встроенного в PyCharm IDE), но невалиден с точки зрения `pyright`: 25 | 26 | ```python 27 | class User: 28 |     def __init__(self): 29 |         self.name: str = "Petr" 30 | 31 |     def yo(self): 32 |         self.name = {} 33 | ``` 34 | 35 | Анализаторы кода продолжают развиваться и дорабатываться, они неидеальны, но проставлять подсказки типов — обязательное условие для серьёзных проектов. Анализаторы кода достаточно умны уже сейчас и станут ещё умнее в будущем. 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/type-hinting/interp.md: -------------------------------------------------------------------------------- 1 | # Интерпретатор не проверяет подсказки типов 2 | 3 | Важно! Здесь стоит отметить, что подсказки типов именно что подсказки. Они не проверяются интерпретатором Python. Они читаются людьми, это подсказка для людей, они читаются IDE, это подсказка для IDE, они могут читаться специальными средствами статического анализа кода вроде `mypy`, это подсказка для них. Но сам интерпретатор не проверяет типы. Если вы укажете type hinting для атрибута функции как `int`, а сами передадите строку — интерпретатор не свалит здесь ошибку, для него в этом не будет проблемы. Имейте это в виду. 4 | 5 | ```python 6 | def plus_two(num: int): 7 | print("мы в функции plus_two") 8 | return num + 2 9 | 10 | print(plus_two(5)) 11 | # мы в функции plus_two 12 | # 7 13 | ``` 14 | 15 | Мы имеем функцию `plus_two`, которая к переданному аргументу `num` типа `int` прибавляет число `2` и возвращает результат. В этом примере приведено правильное использование этой функции, она вызывается с целочисленным аргументом `5`. Программа работает корректно. 16 | 17 | Теперь вызовем функцию с неправильным типом аргумента: 18 | 19 | ```python 20 | print(plus_two("5")) 21 | # мы в функции plus_two 22 | # TypeError: can only concatenate str (not "int") to str 23 | ``` 24 | 25 | С точки зрения проверки типов эта программа некорректна и на строке, где мы неправильным образом вызываем функцию `plus_two`, у нас покажется ошибка в нашем редакторе кода, также эту ошибку типов покажет и статический анализатор кода вроде `mypy`. 26 | 27 | Но интерпретатор именно эту ошибку не заметит. Он не проверяет типы, указанные в type hinting. Обратите внимание — несмотря на то, что функция вызывается явно с неправильным типом данных аргумента `num`, она всё равно запускается, так как `print("мы в функции plus_two")` срабатывает. Функция запускается и «падает» уже тогда, когда мы пытаемся сложить строку `"5"` и число `2`. 28 | 29 | Python — это по-прежнему язык с динамической типизацией, а подсказки типов являются именно что подсказками для разработчика, IDE и анализатора кода, эти подсказки призваны упростить жизнь разработчику и снизить количество ошибок в рантайме. Интерпретатор на подсказки типов внимания не обращает. 30 | 31 | **Итак, подводя промежуточный итог**: подсказки типов это очень важно и вам точно следует их изучить и ими пользоваться. А как подсказками типов пользоваться и какие есть варианты — поговорим подробнее дальше. 32 | 33 | -------------------------------------------------------------------------------- /src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | 2 | [О книге](./intro/intro.md) 3 | 4 | # Типизация в Python — зачем? 5 | 6 | - [Type hinting](./type-hinting/intro.md) 7 | - [Раннее выявление ошибок](./type-hinting/early-errors-catching.md) 8 | - [Читаемость, понятность и поддерживаемость кода](./type-hinting/readability.md) 9 | - [Помощь IDE при разработке](./type-hinting/ide.md) 10 | - [Zen of Python](./type-hinting/zen.md) 11 | - [Но — интерпретатор не проверяет подсказки типов!](./type-hinting/interp.md) 12 | 13 | # Практика 14 | 15 | - [Пишем программу погоды](./weather/intro.md) 16 | - [Накидываем структуру приложения](./weather/structure.md) 17 | - [Пишем каркас приложения](./weather/skeleton.md) 18 | - [NamedTuple — именованный кортеж](./weather/namedtuple.md) 19 | - [Обычный словарь dict](./weather/dict.md) 20 | - [Словарь с Literal ключами](./weather/literal.md) 21 | - [TypedDict](./weather/typeddict.md) 22 | - [Dataclass](./weather/dataclass.md) 23 | - [Alias для типа](./weather/alias.md) 24 | - [Enum](./weather/enum.md) 25 | - [Реализация приложения — получение GPS-координат](./weather/realize-coordinates.md) 26 | - [Реализация приложения — получение погоды с API OpenWeather](./weather/realize-openweather.md) 27 | - [Реализация приложения — принтер погоды](./weather/realize-printer.md) 28 | - [Обработка исключений](./weather/exceptions.md) 29 | - [Проверяем работу приложения](./weather/run.md) 30 | - [Использование интерфейсов и протоколов](./weather/interfaces.md) 31 | - [Анализ получившейся архитектуры кода](./weather/analysis.md) 32 | 33 | # Подробнее о типах 34 | 35 | - [Статические анализаторы mypy и pyright](./mypy/intro.md) 36 | - [Ещё о практических аспектах типизации](./additional/intro.md) 37 | - [Ещё о практических аспектах типизации](./additional/intro.md) 38 | - [Опциональные данные](./additional/optional.md) 39 | - [Контейнеры — Iterable, Sequence, Mapping и другие](./additional/containers.md) 40 | - [Дженерики](./additional/generics.md) 41 | - [Вызываемые объекты](./additional/callable.md) 42 | - [Stub-файлы и работа с нетипизированными библиотеками](./additional/stub.md) 43 | - [Подсказки типов нужны только в функциях?](./additional/question.md) 44 | 45 | # Резюме 46 | 47 | - [Резюме](./resume/intro.md) 48 | - [Об образовательной программе Диджитализируй!](./resume/course.md) 49 | - [Changelog](./resume/changelog.md) 50 | -------------------------------------------------------------------------------- /src/weather/skeleton.md: -------------------------------------------------------------------------------- 1 | # Пишем каркас приложения 2 | 3 | Итак, накидывать функционал можно по-разному. Есть множество подходов. Например, есть TDD, *Test Driven Development*, согласно которому надо сначала написать много-много тестов, они все сначала падают с ошибками, а потом просто постепенно мы пишем код, который заставляет эти тесты постепенно работать. 4 | 5 | Мы по TDD сейчас не пойдём и тесты писать не будем, но в целом можете иметь в голове такой подход. Мы начнём постепенно с реализации, думая каждый раз, в каком слое должна лежать реализуемая сейчас функция или класс. 6 | 7 | Файл `weather`: 8 | 9 | ```python 10 | #!/usr/bin/env python3.10 11 | from coordinates import get_gps_coordinates 12 | from weather_api_service import get_weather 13 | from weather_formatter import format_weather 14 | 15 | 16 | def main(): 17 | coordinates = get_gps_coordinates() 18 | weather = get_weather(coordinates) 19 | print(format_weather(weather)) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | ``` 25 | 26 | То есть фактическая реализация логики будет инкапсулирована, то есть заключена в отдельные Python-модули `coordinates`, `weather_api_service` и `weather_formatter`, а модуль `weather` будет просто точкой входа в приложение, запускающей логику. 27 | 28 | Обратите внимание — при таком подходе у нас изначально не получится ситуации, что вся логика написана в одной каше, например, вообще без функций или в одной длинной километровой функции. Мы подумали о слоях нашего приложения, для каждого слоя создали отдельное место, в нашем случае Python-модуль, но впоследствии можно расширить до Python-пакета, и теперь будем создавать функции, в которые ляжет бизнес-логика нашего приложения. 29 | 30 | Файл `coordinates.py`: 31 | 32 | ```python 33 | def get_gps_coordinates(): 34 | """Returns current coordinates using MacBook GPS""" 35 | pass 36 | ``` 37 | 38 | И вот тут было бы неплохо подумать, какой формат данных вернёт эта чудесная функция. Она очевидно должна вернуть координаты. Координаты это широта и долгота, то есть два числа. Какие есть варианты? 39 | 40 | Самый простой вариант — просто tuple с двумя float числами: 41 | 42 | ```python 43 | def get_gps_coordinates() -> tuple[float, float]: 44 | """Returns current coordinates using MacBook GPS""" 45 | pass 46 | ``` 47 | 48 | Так, хорошо... А широта это нулевой элемент кортежа, а долгота это первый элемент, да? Или наоборот? Насколько хорошо, что приходится додумывать или читать внутренний код функции, чтобы понять, что она возвращает? В этом нет ничего хорошего. Надо явным образом прописать тип, чтобы разночтений не было и все типы были понятны по сигнатуре функции. 49 | -------------------------------------------------------------------------------- /src/additional/question.md: -------------------------------------------------------------------------------- 1 | # Подсказки типов нужны только в функциях? 2 | 3 | В [чате Telegram-канала](https://t.me/t0digital) задали отличный вопрос — подсказки типов имеет смысл ставить только для аргументов функций и возвращаемых значений или вообще для всех переменных? 4 | 5 | И действительно. Как лучше? 6 | 7 | В большинстве сценариев подсказок типов достаточно только для аргументов и результатов функций. Если в нашем коде все функции типизированы таким образом, то получается, что IDE и статический анализатор кода понимают тип любой переменной в коде и могут выполнять все проверки. 8 | 9 | Объявляя переменную, мы либо задаём её значение в явном виде (и тогда тип переменной равен типу значения): 10 | 11 | ```python 12 | age = 33 13 | user = User(username="Иннокентий") 14 | ``` 15 | 16 | Здесь тип переменной `age` равен типу значения `33`, то есть `int`, а тип переменной `user` равен `User`. 17 | 18 | Второй вариант создания переменной — присваивание ей значения, которое возвращается функцией: 19 | 20 | ```python 21 | user = get_user_by_username("Иннокентий") 22 | ``` 23 | 24 | Если все функции в нашем коде типизированы, в том числе и функция `get_user_by_username`, то и в таких сценариях тип переменной очевиден. Какой тип данных функция возвращает, такой тип данных у переменной и будет. 25 | 26 | Получается, что если все функции, используемые в коде, типизированы, то как правило нет смысла проставлять типы для обычных переменных. 27 | 28 | Однако, иногда бывает так, что мы используем внешнюю библиотеку, функции которой нетипизированы. Если функция `get_user_by_username` в примере выше это функция внешней библиотеки и она нетипизирована, то IDE и статический анализатор кода не знают, какой тип данных вернёт эта функция и потому не знают, какой тип будет у переменной `user`. Тогда можно подсказать инструментам, явно указав тип: 29 | 30 | ```python 31 | user: User = get_user_by_username("Иннокентий") 32 | ``` 33 | 34 | Теперь IDE и статический анализатор будут знать тип переменной и смогут выполнять все проверки. Отлично! 35 | 36 | Ещё один сценарий, при котором полезно задать переменной тип — когда мы инициализируем переменную пустым значением, но хотим указать, данные какого конкретно типа там будут. 37 | 38 | Например, в конструкторе класса мы инициализируем атрибут с начальным значением `{}`, то есть пустой словарь, но указываем, что в этом словаре ключами будут строки, значениями числа. То есть мы уточняем тип данных, сужаем его с просто словаря до словаря с конкретным типом ключей и значений: 39 | 40 | ```python 41 | class SomeClass: 42 | def __init__(self): 43 | self._some_dict: dict[str, int] = {} 44 | 45 | def some_method(self): 46 | self._some_dict["some_key"] = 123 # Всё ок по типам 47 | self._some_dict[123] = "some_key" # Ошибка типов! 48 | ``` 49 | 50 | -------------------------------------------------------------------------------- /src/type-hinting/readability.md: -------------------------------------------------------------------------------- 1 | # Читаемость, понятность и поддерживаемость кода 2 | 3 | Вернёмся к нашей функции: 4 | 5 | ```python 6 | def validate_user(user): 7 | """Проверяет юзера, райзит исключение, если с ним что-то не так""" 8 | validate_user_on_server(user) 9 | check_username(user) 10 | check_birthday(user) 11 | ``` 12 | 13 | Представим, что ты пока не очень опытный программист, который только пришел в компанию, и тебе дали задачу добавить ещё одну проверку по юзеру, чтобы валидацию проходили только пользователи, созданные вчера или раньше. Ты этот код очевидно не писал, видишь его впервые и как тут всё работает ещё не знаешь. 14 | 15 | Тут `user` — это что? Это [словарь key-value](https://docs.python.org/3/tutorial/datastructures.html#dictionaries)? Это [ORM](https://ru.wikipedia.org/wiki/ORM)-объект? Это [Pydantic](https://pydantic-docs.helpmanual.io/) модель? У этого юзера тут есть поле `created_at`, дата создания, или нет? Оно нам в нашей задаче ведь нужно будет. 16 | 17 | Как ответить на эти вопросы? Перелопачивать код, который вызывает эту нашу функцию `validate_user`. А там тоже непонятно, что в `user` лежит. Там 100500 функций выше, и где и когда там появляется `user` и что в нём лежит — большой-большой вопрос; плюс мы нашли 2 сценария, в одном наша функция вызывается с `dict`'ом, то есть `user` это словарь, а в другом сценарии функция вызывается с ORM-моделью, и возможно еще какой-то код вызывает еще как-то иначе нашу горе-функцию `validate_user`. Вот как с этим жить? Вам может понадобиться конкретно перелопатить весь проект, чтобы понять, как добавить абсолютно простейшую проверку. 18 | 19 | А если бы здесь был такой код — то все вопросы решились бы мгновенно: 20 | 21 | ```python 22 | from dataclasses import dataclass 23 | import datetime 24 | 25 | @dataclass 26 | class User: 27 | username: int 28 | created_at: datetime.datetime 29 | birthday: datetime.datetime | None 30 | 31 | def validate_user(user: User): 32 | """Проверяет юзера, райзит исключение, если с ним что-то не так""" 33 | validate_user_on_server(user) 34 | check_username(user) 35 | check_birthday(user) 36 | ``` 37 | 38 | Тут понятно, чем является структура `user`. Тут очевидно, что это класс и у него есть такие-то атрибуты, дата создания юзера, юзернейм и дата рождения юзера. Причем даты хранятся тут не как строки, а как datetime, то есть все вопросы у нас мгновенно снимаются. 39 | 40 | Чтение кода значительно облегчилось. Нам понятно, что за данные в `user`, у нас больше нет вопросов, как их обработать. Если вы хотите, чтобы вашим кодом было приятно пользоваться — подсказки типов это обязательный инструмент для вас. Что код принимает на вход? Что он отдаёт на выход? На эти вопросы отвечают подсказки типов. 41 | 42 | > Подсказки типов значительно улучшают читаемость кода и облегчают его сопровождение и поддержку. 43 | -------------------------------------------------------------------------------- /src/weather/alias.md: -------------------------------------------------------------------------------- 1 | # Alias для типа 2 | 3 | После нашего отступления о структурах продолжим накидывать каркас приложения. 4 | 5 | В `coordinates.py` оставим структуру `dataclass` с параметрами `slots` и `frozen`, потому что не предусматривается изменение координат, которые вернёт нам GPS-датчик на ноутбуке. 6 | 7 | ```python 8 | from dataclasses import dataclass 9 | 10 | @dataclass(slots=True, frozen=True) 11 | class Coordinates: 12 | longitude: float 13 | latitude: float 14 | 15 | def get_gps_coordinates() -> Coordinates: 16 | return Coordinates(longitude=10, latitude=20) 17 | ``` 18 | 19 | Возвращаемое значение вставили пока, просто чтобы не ругались проверки в редакторе. Потом напишем реализацию, которая запросит координаты у команды `whereami`, распарсит её результаты и вернёт как результат функции `get_gps_coordinates`. 20 | 21 | Составим `weather_api_service.py`: 22 | 23 | ```python 24 | from coordinates import Coordinates 25 | 26 | def get_weather(coordinates: Coordinates): 27 | """Requests weather in OpenWeather API and returns it""" 28 | pass 29 | ``` 30 | 31 | Так, какой тип у погоды будет возвращаться? Тут главное не смотреть на формат данных в API-сервисе, потому что сервис и формат данных в нём вторичны, первичны наши потребности. Какие данные нам нужны? Нам нужна температура за бортом, наше место, общая характеристика погоды — ясно/неясно/снег/дождь и т. п., а также мне лично ещё интересно, во сколько сегодня восход солнца и закат солнца. Вот эти данные нам нужны, их пусть функция `get_weather` и возвращает. В каком формате? 32 | 33 | Так, ну давайте думать. Просто `tuple`? Точно нет. Вообще есть мнение, что если мы хотим использовать `tuple`, то стоит использовать `NamedTuple`, потому что в нём явно данные будут названы. Поэтому возможно `NamedTuple`. 34 | 35 | Просто `dict`? Точно нет. Не будет нормальных проверок в IDE и статическом анализаторе, не будет подсказок, и читателю кода непонятно, что там внутри словаря. `TypedDict`? Лучше, но мне нравится доставать данные как атрибуты класса, а не как ключи словаря. Поэтому `TypedDict` тоже не будем брать. 36 | 37 | Может `dataclass`? Можно. 38 | 39 | Итого `NamedTuple` или `dataclass`? Оба варианта ок, можно выбрать любой вариант, я, пожалуй, тут выберу `dataclass` с параметрами `frozen` и `slots` просто потому что распаковывать структуру как кортеж нам незачем, а по памяти `dataclass` с такими параметрами даже эффективнее кортежа. 40 | 41 | ```python 42 | from dataclasses import dataclass 43 | from datetime import datetime 44 | from typing import TypeAlias 45 | 46 | from coordinates import Coordinates 47 | 48 | Celsius: TypeAlias = int 49 | 50 | @dataclass(slots=True, frozen=True) 51 | class Weather: 52 | temperature: Celsius 53 | weather_type: str # Подумаем, как хранить описание погоды 54 | sunrise: datetime 55 | sunset: datetime 56 | city: str 57 | 58 | def get_weather(coordinates: Coordinates): 59 | """Requests weather in OpenWeather API and returns it""" 60 | pass 61 | ``` 62 | 63 | Обратите внимание, как я обошёлся с температурой. Можно было прописать тип напрямую `int`, но я сделал *alias*, то есть псевдоним, для `int` с названием `Celsius` и теперь понятно, что у нас температура тут будет именно в градусах Цельсия, а не Фаренгейта или Кельвина. Тип `TypeAlias` как раз предназначен для указания таких *алиасов* типов. 64 | 65 | Также, если какая-то функция будет принимать на вход или возвращать температуру, то мы тоже укажем для температуры там конкретный тип `Celsius`, а не общий непонятный `int`. 66 | -------------------------------------------------------------------------------- /src/weather/dataclass.md: -------------------------------------------------------------------------------- 1 | # Dataclass 2 | 3 | Ещё один вариант задания структуры — `dataclass`: 4 | 5 | ```python 6 | from dataclasses import dataclass 7 | 8 | @dataclass 9 | class Coordinates: 10 | longitude: float 11 | latitude: float 12 | 13 | def get_gps_coordinates() -> Coordinates: 14 | return Coordinates(10, 20) 15 | ``` 16 | 17 | Это обычный класс, это не именованный кортеж, распаковывать его как кортеж уже нельзя, и также он не ведет себя как кортеж с точки зрения изменения каждого элемента. Это обычный класс. 18 | 19 | С ним работают проверки в IDE, автодополнения — это, пожалуй, самая часто используемая структура для таких задач: 20 | 21 | ```python 22 | print(get_gps_coordinates().latitude) # Автодополнение IDE для атрибута 23 | print(get_gps_coordinates().latitudeRRR) # IDE подсветит опечатку 24 | ``` 25 | 26 | Когда использовать `NamedTuple`, когда `dataclass`? Как мы поймём чуть позже, сценарий именованных кортежей — это сценарий распаковки. Когда нам нужно использовать структуру именно как кортеж, тогда стоит задать её как `NamedTuple`. В остальных сценариях имеет смысл предпочесть `dataclass`. 27 | 28 | Давайте сравним количество памяти, которое занимает в оперативке именованный кортеж и датакласс. Для того, чтобы узнать, сколько памяти занимает переменная, воспользуемся библиотекой [Pympler](https://pympler.readthedocs.io/en/latest/). 29 | 30 | ```python 31 | from dataclasses import dataclass 32 | from typing import NamedTuple 33 | 34 | from pympler import asizeof 35 | 36 | @dataclass 37 | class CoordinatesDT: 38 | longitude: float 39 | latitude: float 40 | 41 | class CoordinatesNT(NamedTuple): 42 | longitude: float 43 | latitude: float 44 | 45 | 46 | coordinates_dt = CoordinatesDT(longitude=10.0, latitude=20.0) 47 | coordinates_nt = CoordinatesNT(longitude=10.0, latitude=20.0) 48 | 49 | print("dataclass", asizeof.asized(coordinates_dt).size) # 328 bytes 50 | print("namedtuple:", asizeof.asized(coordinates_nt).size) # 104 bytes 51 | ``` 52 | 53 | То есть, как видим, именованный кортеж занимает значительно меньше памяти в оперативке, чем `dataclass`, в данном примере в 3 раза. Это понятно, то как по своей сути это более простая структура данных, её нельзя менять и потому именованный кортеж можно эффективно хранить в памяти. 54 | 55 | В то же время, если мы используем `dataclass` просто как фиксированную структуру для хранения неизменяемых данных, то можно сделать и его более эффективным: 56 | 57 | ```python 58 | from dataclasses import dataclass 59 | from pympler import asizeof 60 | 61 | 62 | @dataclass(slots=True, frozen=True) 63 | class CoordinatesDT2: 64 | longitude: float 65 | latitude: float 66 | 67 | coordinates_dt2 = CoordinatesDT2(longitude=10.0, latitude=20.0) 68 | print("dataclass with frozen and slots:", asizeof.asized(coordinates_dt2).size) 69 | # dataclass with frozen and slots: 96 bytes 70 | ``` 71 | 72 | Обрати внимание — такая структура неизменна, как и кортеж (благодаря флагу `frozen=True`), то есть не получится после определения экземпляра класса изменить его атрибуты. Флаг `slots=True` автоматически добавляет `__slots__` нашему датаклассу (более быстрый доступ к атрибутам и более эффективное хранение в памяти). 73 | 74 | Таким образом, как мы видим по нашему тесту, по памяти такой `dataclass` получается даже эффективнее кортежа. Кортеж можно использовать, если вам важно использовать его с распаковкой, например, таким образом: 75 | 76 | ```python 77 | latitude, longitude = coordinates_nt 78 | ``` 79 | 80 | Экземпляр датакласса, очевидно, с распаковкой работать не будет, так как это не кортеж. 81 | 82 | -------------------------------------------------------------------------------- /src/weather/structure.md: -------------------------------------------------------------------------------- 1 | # Накидываем структуру приложения 2 | 3 | Итак, первое, что надо сделать — подумать, из каких слоёв будет состоять наше приложение. Бросаться писать код сразу не надо. Давайте подумаем, что будет делать наша программа, вот просто перечислим, не думая пока о коде, функциях, классах, о том, как именно мы будем это реализовывать, а просто подумаем, что будет делать программа, из каких функциональных блоков она будет состоять. 4 | 5 | Итак, наша программа погоды должна: 6 | 7 | * уметь получать текущие координаты устройства 8 | * запрашивать по этим координатам где-то погоду, в нашем случае на OpenWeather, но потенциально было бы здорово, если бы была возможность потом подцепить и какой-то другой сервис, если понадобится 9 | * результаты работы этого погодного сервиса надо распарсить, то есть разобрать, чтобы выдернуть оттуда нужные нам данные 10 | * и, наконец, наши данные надо отобразить в терминале. 11 | 12 | Получается, 4 блока тут есть, причём второй и третий функции мы можем объединить в один верхнеуровневый слой получения погоды из внешнего сервиса. Итого мы имеем следующие слои работы приложения: 13 | 14 | 1. Слой, запускающий приложение и связывающий остальные слои 15 | 2. Получение текущих координат 16 | 3. Получение по координатам погоды 17 | 4. Печать погоды 18 | 19 | Отлично. 20 | 21 | Создаём директорию и накидываем туда слои нашего приложения. Сразу создаём структуру. У нас есть 4 слоя нашего приложения, создадим под них сразу Python-модули, чтобы логика каждого слоя лежала сразу в них. 22 | 23 | * `weather` — входная точка приложения, сделаем её исполнимым файлом без расширения `.py`, чтобы можно было запускать её без указания интерпретатора 24 | * `coordinates.py` — получение текущих GPS-координат ноутбука 25 | * `weather_api_service.py` — работа с внешним сервисом прогноза погоды 26 | * `weather_formatter.py` — форматирование погоды, то есть «сборка» строки с данными погоды (например, для последующей печати этой строки в терминале) 27 | 28 | Создаём директорию для проекта, в моём случае `weather-yt`, переходим в неё и создаём в ней пустой файл `weather`, добавляем этому файлу права на выполнение с помощью `chmod`, и затем открываем этот файл в редакторе кода, в моём случае в `nvim`: 29 | 30 | ```bash 31 | mkdir weather-yt && cd $_ 32 | true > weather 33 | chmod +x weather 34 | nvim weather 35 | ``` 36 | 37 | Зададим заглушку в файле `weather`: 38 | 39 | ```python 40 | #!/usr/bin/env python3.10 41 | print("Hello world") 42 | ``` 43 | 44 | Первая строка называется [шебанг](https://ru.wikipedia.org/wiki/%D0%A8%D0%B5%D0%B1%D0%B0%D0%BD%D0%B3_(Unix)), при помощи чего будет запускаться текущий файл. В нашем случае файл будет запускаться с помощью интерпретатора `python3.10`. Убедитесь, что у вас есть такой интерпретатор в системе, что путь к нему добавлен в переменную окружения `PATH`, убедитесь, что `python3.10` успешно запускается. Мы будем использовать здесь возможности актуальной на сегодня версии Python — 3.10. Проверим работу приложения: 45 | 46 | ```bash 47 | ./weather 48 | ``` 49 | 50 | Отлично! Точка входа в приложение готова. Теперь сделаем, чтобы она запускалась откуда угодно из системы, прокинув симлинк (ярлык) на этот исполнимый файл в `/usr/local/bin/`: 51 | 52 | ```bash 53 | sudo ln -s $(pwd)/weather /usr/local/bin/ 54 | ``` 55 | 56 | Отлично, теперь мы можем узнавать погоду (запускать `weather`) из любой директории в системе. 57 | 58 | Создаём остальные модули приложения: 59 | 60 | ```bash 61 | true > coordinates.py 62 | true > weather_api_service.py 63 | true > weather_formatter.py 64 | ls -l 65 | ``` 66 | 67 | Итак, у нас есть структура приложения, начинаем накидывать функционал. 68 | -------------------------------------------------------------------------------- /src/weather/analysis.md: -------------------------------------------------------------------------------- 1 | # Анализ получившейся архитектуры кода 2 | 3 | Давайте посмотрим свежим взглядом на получившуюся архитектуру кода. 4 | 5 | Мы имеем 4 слоя приложения: 6 | 7 | 1. Модуль `weather`, запускающий приложение и связывающий остальные слои. Важно обратить внимание: этот файл не содержит никакой логики реализации, никакой бизнес-логики. Это точка входа в приложение. Она не знает ничего о деталях реализации всех остальных нижележащих слоёв приложения. 8 | 2. Модуль `coordinates` отвечает за получение координат из внешней команды `whereami`. Сюда инкапсулирована вся логика по работе с координатами. Эта логика ничего не знает о том, для чего эти координаты будут использованы затем в приложении. Модуль определяет структуру данных для хранения и передачи в приложение координат. 9 | 10 | Если нам понадобится получать координаты откуда-то иначе — мы перепишем логику этого модуля, никак не затронув все остальные модули приложения. Связь этого модуля с остальными — слабая, и это хорошо. 11 | 3. Модуль `weather_api_service` инкапсулирует в себе логику получения погоды по координатам. Он не знает, откуда были получены координаты, поступившие в этот модуль. Он не знает, как будет использоваться погода дальше в приложении. В этом модуле определена структура для хранения и передачи данных погоды в приложение. 12 | 13 | Если нам понадобится получать погоду в другом API-сервисе — мы заменим логику этого модуля и это никак не затронет остальные модули приложения. Связь этого модуля с остальными — слабая, и это хорошо. 14 | 4. Модуль `weather_formatter` отвечает за преобразование погоды в строку. Он ничего не знает о том, откуда погода была получена, была ли она получена по координатам GPS или по названию населённого пункта или как-то иначе, он не знает ничего кроме того, как преобразовать данные погоды в строку, всё. Связь этого модуля с остальными слабая, и это хорошо. В любой момент мы можем изменить логику форматирования данных погоды (добавив в неё иконки погоды, например), никак не затронув при этом все остальные модули приложения. 15 | 5. Модуль `history` инкапсулирует в себе логику по сохранению истории погоды. Этот модуль также независим от остальных модулей. Более того, реализована гибкая схема смены хранилища на любое другое через механизм интерфейсов. Ответственность за то, какое хранилище будет использовано для сохранения данных, лежит вовне этого модуля. Можно, например, данные погоды днём сохранять в текстовый плоский txt-файл, а данные ночной погоды — в JSON. Для этого не придётся ничего менять в самом модуле `history`. Чем меньше поводов менять код какого-то модуля, класса, функции — тем лучше. 16 | 17 | Получается, что мы реализовали всё приложение в виде слабозависимых друг от друга модулей. При этом эти модули могут использоваться и в составе других приложений, они *reusable*, то есть переиспользуемые. Скажем, модуль получения GPS-координат может использоваться в программе вычисления расстояния от текущей точки, где мы находимся, до Рима. Почему нет. Для этого не понадобится изменять этот модуль. Отлично! 18 | 19 | Если мы откроем код любого модуля, любой функции — нам сразу станет понятно, какие данные принимаются на вход и какие возвращаются на выход, причём понятно максимально точно. Это: 20 | 21 | 1. Облегчает чтение кода — все типы данных в явном виде и максимально конкретно прописаны, не надо их предугадывать. 22 | 2. Гарантируется отсутствие ошибок использования типов — IDE подсветит, если мы что-то используем не так, как нужно; также на ошибки укажет инструмент статического анализа вроде `mypy`, о котором мы поговорим ниже. 23 | 3. IDE поможет писать код всем, использующим наши разработанные модули. Будет работать автодополнение по полям и методам классов с учетом типов, которые мы указали. 24 | 25 | Финальный вариант исходного кода программы размещён на Github: https://github.com/alexey-goloburdin/weather 26 | 27 | -------------------------------------------------------------------------------- /src/weather/intro.md: -------------------------------------------------------------------------------- 1 | # Пишем программу погоды 2 | 3 | Итак, давайте напишем консольную программу, которая будет показывать текущую погоду по нашим координатам. Чтоб не по IP-адресу как-то пытаться неточно вычислять местоположение, а именно по текущим реальным GPS-координатам. Чтобы программа показывала температуру за бортом, идёт ли там дождь/снег и время восхода-заката солнца. Для съёмки видео важно понимать, во сколько сегодня восход или закат солнца, чтобы ориентироваться на освещённость за окном. 4 | 5 | Итак, в первую очередь, нам надо понять, как получить доступ к текущим координатам, есть ли такая возможность. Решение будет для MacBook, [гуглим](https://www.google.com/search?q=python+mac+get+gps+coordinates): `python mac get gps coordinates`. [Первая ссылка](https://stackoverflow.com/questions/42831672/python-get-gps-location-on-macos) говорит о программе whereami, которая печатает текущие координаты в консоль 6 | 7 | ```bash 8 | whereami 9 | ``` 10 | 11 | ![](../images/whereami_screenshot.png) 12 | 13 | Отлично, теперь мы можем получать наши текущие координаты, отправить их в какой-то сервис погоды через API, получить оттуда погоду и отобразить её. 14 | 15 | Команда работает по аналогии с `whoami` — та показывает, «кто я», а вот команда `whereami` показывает, «где я»:). 16 | 17 | Давайте найдём какой-то сервис погоды. Поисковый запрос `API прогноз погоды` привёл меня на проект [OpenWeather](https://home.openweathermap.org/). У них есть бесплатный доступ. Еще есть Яндекс погода в России, Gismeteo, но там, насколько я понял, для получения API-ключа надо куда-то писать на почту, для наших целей это слишком долго. Воспользуемся OpenWeather. 18 | 19 | Запрос на получение погоды по примерно моим координатам: 20 | 21 | ```bash 22 | http https://api.openweathermap.org/data/2.5/weather\?lat\=55.7\&lon\=37.5\&appid\=7549b3ff11a7b2f3cd25b56d21c83c6a\&lang\=ru\&units\=metric 23 | ``` 24 | 25 | `httpie` — это удобная утилита работы с веб-сервисами, такая вариация на тему `curl`, можно установить на Mac OS с помощью [brew](https://brew.sh/index_ru) командой `brew install httpie`. Она выводит в раскрашенном виде JSON, например, что удобно. 26 | 27 | API-ключ, использующийся в запросе, получается сразу после регистрации, но активируется в течение, может быть, минут десяти. 28 | 29 | Результат запрос: 30 | 31 | ```json 32 | { 33 | "base": "stations", 34 | "clouds": { 35 | "all": 61 36 | }, 37 | "cod": 200, 38 | "coord": { 39 | "lat": 55.7, 40 | "lon": 37.5 41 | }, 42 | "dt": 1651521003, 43 | "id": 529334, 44 | "main": { 45 | "feels_like": 9.26, 46 | "grnd_level": 993, 47 | "humidity": 74, 48 | "pressure": 1013, 49 | "sea_level": 1013, 50 | "temp": 10.25, 51 | "temp_max": 12.01, 52 | "temp_min": 8.55 53 | }, 54 | "name": "Moscow", 55 | "sys": { 56 | "country": "RU", 57 | "id": 47754, 58 | "sunrise": 1651455877, 59 | "sunset": 1651511306, 60 | "type": 2 61 | }, 62 | "timezone": 10800, 63 | "visibility": 10000, 64 | "weather": [ 65 | { 66 | "description": "облачно с прояснениями", 67 | "icon": "04n", 68 | "id": 803, 69 | "main": "Clouds" 70 | } 71 | ], 72 | "wind": { 73 | "deg": 180, 74 | "gust": 8.08, 75 | "speed": 2.69 76 | } 77 | } 78 | ``` 79 | 80 | Так, отлично, мы умеем находить текущие координаты и умеем по ним получать температуру, состояние погоды — дождь/снег/облака, а также получать время восхода и заката солнца. 81 | 82 | Давайте напишем программу для этого! Чтобы запускаешь её и она писала наше местоположение и выводила эти данные — температуру, характеристику погоды (снег/облака/туман) и время восхода заката солнца. 83 | -------------------------------------------------------------------------------- /src/intro/intro.md: -------------------------------------------------------------------------------- 1 | # Типизированный Python для профессиональной разработки 2 | 3 | [![](../images/cover.png)](https://t.me/t0digital/151) 4 | 5 | *Алексей Голобурдин, 6 | команда Диджитализируй!* 7 | 8 | *обложка — [Васильев Никита](https://vk.com/vasiliev.narisoval), nikita.vasiliev@math.msu.ru* 9 | 10 | > **PDF версию книги** можно бесплатно скачать здесь: [t.me/t0digital/151](https://t.me/t0digital/151) 11 | 12 | Цель этой книги — помочь тебе научиться писать более красивые, надёжные и легко сопровождаемые программы на Python. То, о чём мы здесь будем говорить, это не начальный уровень владения языком, предполагается, что ты уже минимально умеешь программировать, но хочешь научиться делать это лучше. 13 | 14 | И это — отличная цель, к которой мы вместе будем двигаться на протяжении ближайших часов! 15 | 16 | Этот материал есть также в видео формате на моём YouTube — [«Диджитализируй!»](https://www.youtube.com/watch?v=dKxiHlZvULQ). 17 | 18 |
19 | 20 |
21 | 22 | Также обращаю внимание, что на момент написания этих строк готовится перезапуск моего авторского курса «Основы компьютерных и веб-технологий на Python» [course01.to.digital](https://course01.to.digital/), запуск планируется в июне 2022, если ты читаешь этот материал позже, то вполне вероятно, что на курс уже снова можно записаться. 23 | 24 | Итак! 25 | 26 | Часто в учебниках и курсах по Python не уделяют должного внимания типизации и некоторым структурам, в то время как они очень важны и могут значительно, просто драматически улучшить твой код. 27 | 28 | В ревью кода начинающих разработчиков часто видны результаты того, что в учебных материалах не уделяется отдельное внимание вопросам типизации. В коде не используются подсказки типов, используются неправильно, не лучшим образом выбираются типы для разных данных в приложении и так далее. Качество программы и её надёжность страдают — а это гораздо более важные параметры, чем многие поначалу думают. Поначалу кажется, что я написал программу, она в моих идеальных условиях работает и этого достаточно. Но нет, этого недостаточно. 29 | 30 | Наличие функциональности это одно, а надёжность этой функциональности и качество реализации этой функциональности это совсем другое. Наличие функциональности это когда вы видите обувь и думаете — о, отлично, можно её надеть и пойти в ней куда-то. А надёжность и качество реализации этой функциональности это про то, что у вас не треснет подошва где-то на улице, в обувь не будет попадать вода, обувь не будет натирать вам ноги, она не потеряет быстро приличный вид, а также это про то, что обувь легка в эксплуатации, её можно легко протереть, её можно ремонтировать и многое другое. 31 | 32 | То, что мы написали программу и она имеет функциональность — это вовсе не означает, что программа действительно хороша. В этой небольшой книге мы поговорим о том, как разрабатывать, думая не только о функциональности, но и о качестве и надёжности её реализации. 33 | 34 | > Оглавление есть в самом PDF документе. В программе просмотра PDF можно найти раздел Оглавление и открыть его. 35 | 36 | Мы поговорим о типизации в Python, поговорим о нескольких структурах и встроенных типах: 37 | 38 | - `NamedTuple`, 39 | - `dataclass`, 40 | - `TypedDict`, 41 | - `Enum`, 42 | - `Literal`, 43 | - `Union`, `Optional`, 44 | - `Iterable`, `Sequence`, `Mapping`, 45 | - `Callable`, 46 | - `TypeVar`, `TypeAlias` и др. 47 | 48 | Напишем приложение погоды, используя эти типы и поясняя по ходу некоторые архитектурные моменты выбора того или иного подхода. [Смотри видео версию этой книги на YouTube](https://www.youtube.com/channel/UC9MK8SybZcrHR3CUV4NMy2g) и читай обязательно до конца. 49 | 50 | Обещаю, что после проработки этого материала твой код больше никогда не будет прежним. Буквально — драматическое улучшение кода гарантировано. Как пишут в англоязычных книжках, *dramatic improvement*! 51 | 52 | Поднимаемые вопросы актуальны, кстати, не только для Python, говорить мы будем о нём, но аналогичные подходы применимы и к PHP, TypeScript и тд. Подходы к написанию качественного ПО схожи для разных языков программирования, выступающих просто инструментом реализации задумок автора кода. 53 | 54 | Говорить мы здесь будем о версии Python 3.10. В предыдущих версиях Python некоторые аспекты работают чуть иначе (нужно импортировать некоторые типы из `typing`, например), но это не столь критично. 55 | 56 | > Самое время подписаться: 57 | > [YouTube](https://www.youtube.com/channel/UC9MK8SybZcrHR3CUV4NMy2g) | [Telegram](https://t.me/t0digital) | [VK](https://vk.com/digitalize.team) 58 | 59 | Начать нужно с разговора о самой типизации и о том, почему этому нужно уделять тщательное внимание. Итак, подсказки типов Python или, что то же самое, type hinting. 60 | 61 | -------------------------------------------------------------------------------- /src/weather/realize-openweather.md: -------------------------------------------------------------------------------- 1 | # Реализация приложения — получение погоды с API OpenWeather 2 | 3 | Отлично, у нас реализована структура и скелет приложения, а также полностью реализована логика получения текущих GPS-координат — в точном или округлённом варианте. Реализуем теперь получение по этим координатам значения погоды с использованием API-сервиса OpenWeather. Добавим шаблон URL для получения погоды в `config.py`: 4 | 5 | ```python 6 | USE_ROUNDED_COORDS = True 7 | OPENWEATHER_API = "7549b3ff11a7b2f3cd25b56d21c83c6a" 8 | OPENWEATHER_URL = ( 9 | "https://api.openweathermap.org/data/2.5/weather?" 10 | "lat={latitude}&lon={longitude}&" 11 | "appid=" + OPENWEATHER_API + "&lang=ru&" 12 | "units=metric" 13 | ) 14 | ``` 15 | 16 | Значения широты и долготы будем потом подставлять в этот шаблон. Если нам понадобится изменить однажды этот шаблон URL для получения данных, мы сможем не искать его где-то глубоко в приложении, он лежит в конфиге. Все данные, которые предполагаются как конфигурационные, имеет смысл выносить в отдельное место, которое можно назвать конфигом или настройками приложения. 17 | 18 | API-ключ для сервиса OpenWeather лучше сохранить в переменной окружения и не хранить в исходном коде проекта (тогда значение константы будет получаться как-то так: `os.getenv("OPENWEATHER_API_KEY")`, но сейчас мы этого делать не будем для упрощения запуска приложения. 19 | 20 | Итак, реализация работы с сервисом погоды OpenWeather, `weather_api_service.py`: 21 | 22 | ```python 23 | from datetime import datetime 24 | from dataclasses import dataclass 25 | from enum import Enum 26 | import json 27 | from json.decoder import JSONDecodeError 28 | import ssl 29 | from typing import Literal, TypeAlias 30 | import urllib.request 31 | from urllib.error import URLError 32 | 33 | from coordinates import Coordinates 34 | import config 35 | from exceptions import ApiServiceError 36 | 37 | Celsius: TypeAlias = int 38 | 39 | class WeatherType(str, Enum): 40 | THUNDERSTORM = "Гроза" 41 | DRIZZLE = "Изморось" 42 | RAIN = "Дождь" 43 | SNOW = "Снег" 44 | CLEAR = "Ясно" 45 | FOG = "Туман" 46 | CLOUDS = "Облачно" 47 | 48 | @dataclass(slots=True, frozen=True) 49 | class Weather: 50 | temperature: Celsius 51 | weather_type: WeatherType 52 | sunrise: datetime 53 | sunset: datetime 54 | city: str 55 | 56 | def get_weather(coordinates: Coordinates) -> Weather: 57 | """Requests weather in OpenWeather API and returns it""" 58 | openweather_response = _get_openweather_response( 59 | longitude=coordinates.longitude, latitude=coordinates.latitude) 60 | weather = _parse_openweather_response(openweather_response) 61 | return weather 62 | 63 | def _get_openweather_response(latitude: float, longitude: float) -> str: 64 | ssl._create_default_https_context = ssl._create_unverified_context 65 | url = config.OPENWEATHER_URL.format( 66 | latitude=latitude, longitude=longitude) 67 | try: 68 | return urllib.request.urlopen(url).read() 69 | except URLError: 70 | raise ApiServiceError 71 | 72 | def _parse_openweather_response(openweather_response: str) -> Weather: 73 | try: 74 | openweather_dict = json.loads(openweather_response) 75 | except JSONDecodeError: 76 | raise ApiServiceError 77 | return Weather( 78 | temperature=_parse_temperature(openweather_dict), 79 | weather_type=_parse_weather_type(openweather_dict), 80 | sunrise=_parse_sun_time(openweather_dict, "sunrise"), 81 | sunset=_parse_sun_time(openweather_dict, "sunset"), 82 | city=_parse_city(openweather_dict) 83 | ) 84 | 85 | def _parse_temperature(openweather_dict: dict) -> Celsius: 86 | return round(openweather_dict["main"]["temp"]) 87 | 88 | def _parse_weather_type(openweather_dict: dict) -> WeatherType: 89 | try: 90 | weather_type_id = str(openweather_dict["weather"][0]["id"]) 91 | except (IndexError, KeyError): 92 | raise ApiServiceError 93 | weather_types = { 94 | "1": WeatherType.THUNDERSTORM, 95 | "3": WeatherType.DRIZZLE, 96 | "5": WeatherType.RAIN, 97 | "6": WeatherType.SNOW, 98 | "7": WeatherType.FOG, 99 | "800": WeatherType.CLEAR, 100 | "80": WeatherType.CLOUDS 101 | } 102 | for _id, _weather_type in weather_types.items(): 103 | if weather_type_id.startswith(_id): 104 | return _weather_type 105 | raise ApiServiceError 106 | 107 | def _parse_sun_time( 108 | openweather_dict: dict, 109 | time: Literal["sunrise"] | Literal["sunset"]) -> datetime: 110 | return datetime.fromtimestamp(openweather_dict["sys"][time]) 111 | 112 | def _parse_city(openweather_dict: dict) -> str: 113 | return openweather_dict["name"] 114 | 115 | if __name__ == "__main__": 116 | print(get_weather(Coordinates(latitude=55.7, longitude=37.6))) 117 | ``` 118 | 119 | Как и ранее, следуем подходу небольших функций, каждая из которых делает одно небольшое действие, а общий результат достигается за счёт компоновки этих небольших функций. Логику парсинга каждой нужной нам единицы информации выносим в отдельные небольшие функции — отдельно парсинг температуры, отдельно парсинг типа погоды и времени восхода и заката. Каждая функция названа глагольным словом — получить, распарсить и т. д. Напомню, что функция это ни что иное как именованный блок кода, этот блок кода что-то делает и потому его имеет смысл называть именно глаголом, который опишет это действие. 120 | 121 | Тут стоит отметить, что для парсинга и одновременно валидации JSON-данных удобно использовать библиотеку [Pydantic](https://pydantic-docs.helpmanual.io/). О ней было [видео](https://www.youtube.com/watch?v=dOO3GmX6ukU) на канале «Диджитализируй!». Здесь мы не стали её использовать из-за возможно некоторой её избыточности для нашей простой задачи, а также чтобы ограничиться только стандартной библиотекой Python. 122 | 123 | Осталось реализовать «принтер», который выведет нужные нам значения погоды в консоль! 124 | 125 | -------------------------------------------------------------------------------- /src/additional/containers.md: -------------------------------------------------------------------------------- 1 | # Контейнеры — Iterable, Sequence, Mapping и другие 2 | 3 | Как указать тип для контейнера с данными, например, для списка юзеров? 4 | 5 | ```python 6 | from datetime import datetime 7 | from dataclasses import dataclass 8 | 9 | @dataclass 10 | class User: 11 | birthday: datetime 12 | 13 | users = [ 14 | User(birthday=datetime.fromisoformat("1988-01-01")), 15 | User(birthday=datetime.fromisoformat("1985-07-29")), 16 | User(birthday=datetime.fromisoformat("2000-10-10")) 17 | ] 18 | 19 | def get_younger_user(users: list[User]) -> User: 20 | if not users: raise ValueError("empty users!") 21 | sorted_users = sorted(users, key=lambda x: x.birthday) 22 | return sorted_users[0] 23 | 24 | print(get_younger_user(users)) 25 | # User(birthday=datetime.datetime(1985, 7, 29, 0, 0)) 26 | ``` 27 | 28 | До последних версий Python список для указания типа надо было импортировать из `typing`, но сейчас можно `list` не импортировать и просто сразу использовать, что удобно. То есть Python продолжает движение в сторону ещё более простого и удобного использования подсказок типов. 29 | 30 | Обратите внимание — технически можно указать просто `users: list`, но тогда IDE и статический анализатор кода вроде `mypy` не будут знать, что находится внутри этого списка, и это нехорошо. Мы же изначально знаем, что там именно тип данных `User`, объекты класса `User`, и, значит, это надо в явном виде указать. 31 | 32 | Так, отлично, а давайте подумаем, а обязательно ли функция поиска самого молодого юзера должна принимать на вход именно список юзеров? Ведь по сути главное, чтобы просто можно было проитерироваться по пользователям. Может, мы захотим потом передать сюда не список пользователей, а кортеж с пользователями, или еще что-то? Если мы передадим вместо списка кортеж — будет ошибка типов сейчас: 33 | 34 | ```python 35 | from datetime import datetime 36 | from dataclasses import dataclass 37 | 38 | @dataclass 39 | class User: 40 | birthday: datetime 41 | 42 | users = ( # сменили на tuple 43 | User(birthday=datetime.fromisoformat("1988-01-01")), 44 | User(birthday=datetime.fromisoformat("1985-07-29")), 45 | User(birthday=datetime.fromisoformat("2000-10-10")) 46 | ) 47 | 48 | def get_younger_user(users: list[User]) -> User: 49 | """Возвращает самого молодого пользователя из списка""" 50 | sorted_users = sorted(users, key=lambda x: x.birthday) 51 | return sorted_users[0] 52 | 53 | 54 | print(get_younger_user(users)) # тут видна ошибка в pyright! 55 | ``` 56 | 57 | Код работает (повторимся, что интерпретатор не проверяет типы в type hinting), но проверка типов в редакторе (и `mypy`) ругается, это нехорошо. 58 | 59 | Если мы посмотрим [документацию](https://docs.python.org/3/library/functions.html#sorted) по функции `sorted`, то увидим, что первый элемент там назван *iterable*, то есть итерируемый, то, по чему можно проитерироваться. То есть мы можем передать любую итерируемую структуру: 60 | 61 | ```python 62 | from typing import Iterable 63 | 64 | def get_younger_user(users: Iterable[User]) -> User | None: 65 | if not users: return None 66 | sorted_users = sorted(users, key=lambda x: x.birthday) 67 | return sorted_users[0] 68 | ``` 69 | 70 | И теперь всё в порядке. Мы можем передать любую итерируемую структуру, элементами которой являются экземпляры `User`. 71 | 72 | А если нам надо обращаться внутри функции по индексу к элементам последовательности? Подойдёт ли `Iterable`? Нет, так как `Iterable` подразумевает возможность итерироваться по контейнеру, то есть обходить его в цикле, но это не предполагает обязательной возможности обращаться по индексу. Для этого есть `Sequence`: 73 | 74 | ```python 75 | from typing import Sequence 76 | 77 | def get_younger_user(users: Sequence[User]) -> User | None: 78 | """Возвращает самого молодого пользователя из списка""" 79 | if not users: return None 80 | print(users[0]) 81 | sorted_users = sorted(users, key=lambda x: x.birthday) 82 | return sorted_users[0] 83 | ``` 84 | 85 | Теперь всё в порядке. В `Sequence` можно обращаться к элементам по индексу. 86 | 87 | Ещё один важный вопрос тут. А зачем использовать `Iterable` или `Sequence`, если можно просто перечислить разные типы контейнеров? Ну их же ограниченное количество — там `list`, `tuple`, `set`, `dict.` Для чего нам тогда общие типы `Iterable` и `Sequence`? 88 | 89 | На самом деле таких типов контейнеров, по которым можно итерироваться, вовсе не ограниченное число. Например, можно создать свой контейнер, по которому можно будет итерироваться, но при этом этот тип не будет наследовать ничего из вышеперечисленного типа `list`, `dict` и т. п.: 90 | 91 | ```python 92 | from typing import Sequence 93 | 94 | class Users: 95 | def __init__(self, users: Sequence[User]): 96 | self._users = users 97 | 98 | def __getitem__(self, key: int) -> User: 99 | return self._users[key] 100 | 101 | users = Users(( # сменили на tuple 102 | User(birthday=datetime.fromisoformat("1988-01-01")), 103 | User(birthday=datetime.fromisoformat("1985-07-29")), 104 | User(birthday=datetime.fromisoformat("2000-10-10")) 105 | )) 106 | 107 | for u in users: 108 | print(u) 109 | ``` 110 | 111 | Способов создать такую структуру, по которой можно итерироваться или обращаться по индексам, в Python много, это один из способов. Важно просто понимать, что если вам надо показать структуру, по которой, например, можно итерироваться, то не стоит ограничивать набор таких структур простым перечислением списка, кортежа и чего-то ещё. Используйте обобщённые типы, созданные специально для этого, например, `Iterable` или `Sequence`, потому что они покроют действительно всё, в том числе и свои кастомные (самописные) реализации контейнеров. 112 | 113 | Ну и напоследок — как определить тип словаря, ключами которого являются строки, а значениями, например, объекты типа `User`: 114 | 115 | ```python 116 | some_users_dict: dict[str, User] = { 117 | "alex": User(birthday=datetime.fromisoformat("1990-01-01")), 118 | "petr": User(birthday=datetime.fromisoformat("1988-10-23")) 119 | } 120 | ``` 121 | 122 | И также, если нет смысла ограничиваться именно словарём и подойдёт любая структура, к которой можно обращаться по ключам — то есть обобщённый тип `Mapping`: 123 | 124 | ```python 125 | from typing import Mapping 126 | 127 | def smth(some_users: Mapping[str, User]) -> None: 128 | print(some_users["alex"]) 129 | 130 | smth({ 131 | "alex": User(birthday=datetime.fromisoformat("1990-01-01")), 132 | "petr": User(birthday=datetime.fromisoformat("1988-10-23")) 133 | }) 134 | 135 | ``` 136 | 137 | > **Важно:** по возможности вместо указания в типах `list`, `dict` и т. п. указывай классы `Iterable`, `Sequence`, `Mapping`. 138 | > 139 | > Во-первых, это позволит менять конкретные реализации, удовлетворяющие условию итерабельности, доступа по индексу или доступа по ключу соответственно, решение получится более гибким. 140 | > 141 | > Во-вторых, анализатор кода `mypy` будет [лучше работать с такими типами данных](https://mypy.readthedocs.io/en/stable/common_issues.html#invariance-vs-covariance), что позволит избежать некоторых осложнений. 142 | > 143 | > Если от контейнера требуется итерабельность (чтобы по данным в контейнере можно было итерироваться, то есть проходить в цикле), то стоит указать `Iterable`, который гарантирует именно итерабельность, вместо того, чтобы указывать одни из возможных реализаций итерабельных контейнеров вроде `list` или `tuple`, несущих помимо собственно итерабельности и другие свойства. 144 | > 145 | > Если от контейнера требуется доступ по индексу, то стоит указать `Sequence`, а не одну из возможных реализаций вроде `list`. 146 | > 147 | > Наконец, если требуется доступ по ключу, то следует указать `Mapping`. 148 | 149 | Пару слов стоит сказать про кортежи, если размер кортежа важен и мы хотим его прямо указать в типе, то это можно сделать так: 150 | 151 | ```python 152 | three_ints = tuple[int, int, int] 153 | ``` 154 | 155 | Если количество элементов неизвестно — можно так: 156 | 157 | ```python 158 | tuple_ints = tuple[int, ...] 159 | ``` 160 | -------------------------------------------------------------------------------- /src/type-hinting/early-errors-catching.md: -------------------------------------------------------------------------------- 1 | # Раннее выявление ошибок 2 | 3 | Первое — это ранее выявление ошибок. Есть у нас некая программа и есть в этой некой программе ошибка. Когда мы можем её выявить? Мы можем выявить её на этапе написания программы, мы можем выявить её на этапе подготовки программы к разворачиванию на сервере, или мы можем выявить её на этапе runtime, то есть когда программа уже опубликована на сервере, ей пользуются пользователи. 4 | 5 | Как вы думаете, на каком этапе лучше выявлять ошибки? Очевидно — чем раньше, тем лучше. Если ошибки долетают до пользователей, то это максимально плохо. Почему? 6 | 7 | Во-первых, потому что пользователи недовольны, а раз пользователи недовольны, то много денег мы с нашим программным продуктом не заработаем, так как люди не будут охотно его покупать и рекомендовать другим. К тому же очень неприятно, что мы занимаемся любимым делом, активно трудимся, реализуем сложные алгоритмы, а результатом нашего труда пользователи недовольны. И винить-то объективно некого, кроме нас самих. Непорядочек, непорядочек! 8 | 9 | Во-вторых, недовольные пользователи обращаются в техподдержку, создают тикеты, которые спускаются потом на разработку — это всё тратит деньги компании. Если ничего не ломается, то обращений в поддержку мало, тикетов мало, а разработчики заняты разработкой новых фичей продукта, а не постоянными правками отвалившейся старой логики. Постоянные поломки это постоянные финансовые потери. 10 | 11 | В-третьих, из-за ошибок, которые видят пользователи, компания несёт репутационные потери. Пользователи пишут негативные отзывы, они легко гуглятся другими потенциальными пользователями, СМИ, инвесторами, всё это в конечном итоге негативно влияет и на капитализацию компании, и на возможности привлечения инвестиций, и на чистую прибыль компании, если она вообще есть. 12 | 13 | Если мы хотим быть профессиональными высокооплачиваемыми специалистами, то наша задача — генерировать через результаты нашей работы радость и прибыль, а не поток проблем и убытков. 14 | 15 | Поэтому важнейшая задача для нас — сделать так, чтобы до пользователей не доходило ни одной ошибки. Для достижения этой цели нужен системный подход, одной внимательности в процессе программирования мало. Нужна выверенная система, алгоритм действий, инструментарий, который не позволит ошибкам дойти до пользователей. 16 | 17 | Какой это может быть инструментарий? Это могут быть автотесты. Однако первый принцип тестирования гласит, что *тестирование может показать наличие дефектов в программе, но не доказать их отсутствие*. Тесты это хорошо, но не на одних только тестах всё держится. Чем больше капканов для разных видов ошибок расставлено, тем надёжнее программа и крепче сон разработчика. А что, в конце концов, может быть важнее крепкого, здорового сна разработчика? 18 | 19 | Помимо автотестов (и ручного тестирования людьми) можно проверять корректность использования типов специальными инструментами. Например, компилятор Rust — прооосто красавчик! Он на этапе компиляции выявляет огромное количество проблем и попросту не даёт скомпилировать программу, если видит в ней места с ошибками. Какая-то функция может вернуть успешный результат или условный `null` и вызывающая функция не обрабатывает сценарий с `null`? Вот тебе потенциальная серьёзная ошибка. Компилятор об этом скажет и вам придется сделать всё красиво, обработать все такие моменты и они не дойдут до рантайма. 20 | 21 | Штука в том, что в случае с динамически типизированными языками вроде Python, очень сложно написать инструментарий, который бы выполнял проверки по типам, потому что в каждый конкретный момент времени непонятно какой тип в переменной. И для того, чтобы этому инструментарию помогать, вводят подсказки типов в Python, PHP или типизацию в JavaScript в виде отдельного языка TypeScript, компилирующегося в JavaScript. Это то, что помогает выявлять ошибки на этапе до runtime. Либо на этапе подготовки сборки программы, либо даже на этапе написания программы непосредственно в редакторе кода. 22 | 23 | Инструмент видит, что вот здесь такой-то тип данных, и если он используется некорректно, то инструмент покажет ошибку и эта ошибка не уйдёт в рантайм и пользователи не словят эту ошибку, а мы как разработчик не схлопочем по макушке от руководства. Прекрасно? Прекрасно! 24 | 25 | То есть, ещё раз, первое, для чего вводят подсказки типов — как можно более ранее выявление ошибок, в идеале в редакторе кода в вашей IDE, либо хотя бы на этапе проверки программы перед её сборкой и публикацией на сервер. 26 | 27 | На википедии есть прекрасная страница про системы типов, [Type system](https://en.wikipedia.org/wiki/Type_system#:~:text=The%20main%20purpose%20of%20a,connected%20in%20a%20consistent%20way.): 28 | 29 | > The main purpose of a type system is to reduce possibilities for bugs in computer programs by defining interfaces between different parts of a computer program, and then checking that the parts have been connected in a consistent way. 30 | 31 | То есть главной целью системы типов является уменьшение вероятности ошибок в компьютерных программах путём определения интерфейсов между разными частями программы и последующей проверки, что эти части соединены друг с другом правильным образом через эти интерфейсы. Под интерфейсами тут подразумеваются собственно типы данных, например, какие-то конкретные классы, которые описывают конкретные поля и методы. 32 | 33 | Допустим, у нас есть вот такая функция: 34 | 35 | ```python 36 | def validate_user(user): 37 | """Проверяет юзера, райзит исключение, если с ним что-то не так""" 38 | validate_user_on_server(user) 39 | check_username(user) 40 | check_birthday(user) 41 | ``` 42 | 43 | Под `user` тут подразумевается объект юзера, например, [ORM-объект](https://ru.wikipedia.org/wiki/ORM), то есть запись из базы данных, преобразованная в специальный Python-объект. Человек, написавший код, это знает. В момент написания кода знает. Через месяц он об этом совершенно точно забудет, а человек, не писавший этот код, об этом знать вовсе не может. По сигнатуре функции `def validate_user(user)` нельзя понять, какой тип данных ожидается в `user`, но при этом очень легко сделать предположение об этом типе — и ошибиться. 44 | 45 | Спустя пол года дописывается какой-то новый код и функция `validate_user` в нём внезапно начинает вызываться с аргументом `user`, который равен не ORM-объекту, а числу — просто потому что совсем неочевидно, что в `user` на самом деле подразумевается не число: 46 | 47 | ```python 48 | user_id = 123 49 | validate_user(user_id) 50 | ``` 51 | 52 | Этот код упадёт только в рантайме. Потому что IDE или статический анализатор кода не смогут понять, что тут есть какая-то ошибка. 53 | 54 | Как сделать так, чтобы мы узнали об ошибке до этапа рантайма? Явным образом указать тип для атрибута `user`, например, если это экземпляр датакласса `User`, то так (о датаклассах мы поговорим подробнее дальше): 55 | 56 | ```python 57 | from dataclasses import dataclass 58 | import datetime 59 | 60 | @dataclass 61 | class User: 62 | username: str 63 | created_at: datetime.datetime 64 | birthday: datetime.datetime | None 65 | 66 | def validate_user(user: User): 67 | """Проверяет юзера, райзит исключение, если с ним что-то не так""" 68 | validate_user_on_server(user) 69 | check_username(user) 70 | check_birthday(user) 71 | ``` 72 | 73 | Датакласс определяет просто структуру данных с полями `username`, `created_at` и `birthday`, причём тип поля `username` — строка, тип `created_at` — `datetime`, а `birthday` хранит `None` или значение типа `datetime`. 74 | 75 | И теперь такой код: 76 | 77 | ```python 78 | user_id = 123 79 | validate_user(user_id) 80 | ``` 81 | 82 | подкрасится ошибкой уже в IDE на этапе написания кода (например, в PyCharm или настроенном VS Code или `nvim`), а также код упадёт с ошибкой в статическом анализаторе кода, который запустится при сборке проекта перед его публикацией на сервер. 83 | 84 | ![](../images/validate_user_error_in_pycharm.png) 85 | 86 | Получается, что наша программа стала надёжнее, так как пользователи не увидят многие ошибки, они будут выявлены и исправлены на ранних этапах. Да, при этом надо писать типы, вводить их, думать, но кому сейчас легко:). Нам платят деньги за качественный софт, а качественный софт это в первую очередь надёжный софт, то есть софт, который не падает в рантайме с непонятными пользователю ошибками вроде `AttributeError`. 87 | 88 | > Цена исправления ошибки в программе тем меньше, чем раньше этап, на котором эта ошибка выявлена. Главная причина введения системы типов — уменьшение вероятности возникновения ошибок в рантайме, то есть при эксплуатации приложения. 89 | -------------------------------------------------------------------------------- /src/weather/enum.md: -------------------------------------------------------------------------------- 1 | # Enum 2 | 3 | Дальше, как быть с полем `weather_type`? Что за строка там будет? Хочется, чтобы там была не просто любая строка, а строго один из заранее заданных вариантов. Тут мы будем хранить описание погоды — ясно, туманно, дождливо и т. п. Для этих целей существует структура `Enum`. Её название происходит от слова *Enumeration*, *перечисление*. Когда нам нужно перечислить какие-то заранее заданные варианты значений, то `Enum` это та самая структура, которая нам нужна. 4 | 5 | Давайте создадим структуру типов погоды, отнаследовав её от `Enum` и заполнив всеми возможными типами погоды, которые мы возьмём из справочника с [OpenWeather](https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2): 6 | 7 | ```python 8 | from datetime import datetime 9 | from enum import Enum 10 | 11 | class WeatherType(Enum): 12 | THUNDERSTORM = "Гроза" 13 | DRIZZLE = "Изморось" 14 | RAIN = "Дождь" 15 | SNOW = "Снег" 16 | CLEAR = "Ясно" 17 | FOG = "Туман" 18 | CLOUDS = "Облачно" 19 | 20 | @dataclass(slots=True, frozen=True) 21 | class Weather: 22 | temperature: Celsius 23 | weather_type: WeatherType 24 | sunrise: datetime 25 | sunset: datetime 26 | city: str 27 | ``` 28 | 29 | Каждый конкретный тип погоды описывается через атрибут `WeatherType`: 30 | 31 | ```python 32 | print(WeatherType.CLEAR) # WeatherType.CLEAR 33 | print(WeatherType.CLEAR.value) # Ясно 34 | print(WeatherType.CLEAR.name) # CLEAR 35 | ``` 36 | 37 | В чём фишка `Enum`? Зачем наследовать наш класс от `Enum`, почему бы просто не сделать класс с такими же атрибутами класса? Допустим, у нас есть функция `print_weather_type`, которая печатает погоду: 38 | 39 | ```python 40 | from enum import Enum 41 | 42 | class WeatherType(Enum): 43 | THUNDERSTORM = "Гроза" 44 | DRIZZLE = "Изморось" 45 | RAIN = "Дождь" 46 | SNOW = "Снег" 47 | CLEAR = "Ясно" 48 | FOG = "Туман" 49 | CLOUDS = "Облачно" 50 | 51 | def print_weather_type(weather_type: WeatherType) -> None: 52 | print(weather_type.value) 53 | 54 | print_weather_type(WeatherType.CLOUDS) # Облачно 55 | ``` 56 | 57 | Как видите, тип для аргумента функции `weather_type` указан как `WeatherType`. А передаём туда мы не экземпляр `WeatherType`, а `WeatherType.CLOUDS`, при этом наш «проверятор» кода в IDE не ругается, ему всё нравится. Дело в том, что: 58 | 59 | ```python 60 | print( 61 | isinstance(WeatherType.CLOUDS, WeatherType) 62 | ) # True 63 | ``` 64 | 65 | То есть `WeatherType.CLOUDS` является экземпляром самого типа `WeatherType`, и это позволяет нам таким образом использовать этот класс в подсказке типов. В функцию `print_weather_type` можно передать только то, что явным образом перечислено в `Enum` структуре `WeatherType` и ничего больше. 66 | 67 | Если мы уберём наследование от `Enum`, то IDE сразу скажет нам о несоответствии типов: 68 | 69 | ```python 70 | from enum import Enum 71 | 72 | class WeatherType: # Убрали наследование от Enum 73 | THUNDERSTORM = "Гроза" 74 | DRIZZLE = "Изморось" 75 | RAIN = "Дождь" 76 | SNOW = "Снег" 77 | CLEAR = "Ясно" 78 | FOG = "Туман" 79 | CLOUDS = "Облачно" 80 | 81 | def print_weather_type(weather_type: WeatherType) -> None: 82 | print(weather_type) # Вместо weather_type.value 83 | 84 | print_weather_type(WeatherType.CLOUDS) # IDE подсветит ошибку типов 85 | ``` 86 | 87 | Здесь `WeatherType.CLOUDS` — это обычная строка со значением `"Облачно"`, тип `str`, а не `WeatherType`. Тип `str` и тип `WeatherType` — разные, поэтому IDE определит и подсветит эту ошибку несоответствия типов. 88 | 89 | В этом особенность `Enum`. Цель этой структуры — задавать перечисление возможных вариантов значений. 90 | 91 | Ещё по `Enum` можно итерироваться в цикле, что иногда может быть удобно: 92 | 93 | ```python 94 | for weather_type in WeatherType: 95 | print(weather_type.name, weather_type.value) 96 | ``` 97 | 98 | И, конечно, `Enum` структуру можно разбирать с помощью новых возможностей Python — [Pattern Matching](https://www.youtube.com/watch?v=0kyy_zKO86U&t=255s): 99 | 100 | ```python 101 | def what_should_i_do(weather_type: WeatherType) -> None: 102 | match weather_type: 103 | case WeatherType.THUNDERSTORM | WeatherType.RAIN: 104 | print("Уф, лучше сиди дома") 105 | case WeatherType.CLEAR: 106 | print("О, отличная погодка") 107 | case _: 108 | print("Ну так, выходить можно") 109 | 110 | what_should_i_do(WeatherType.CLOUDS) # Ну так, выходить можно 111 | ``` 112 | 113 | Но нам здесь это пока не нужно. 114 | 115 | Также часто полезным бывает отнаследовать класс перечисления от `Enum` и от `str`. Тогда значение можно использовать как строку без обращения к `.value` атрибуту: 116 | 117 | ```python 118 | # Наследование от str и Enum 119 | class WeatherTypeStrEnum(str, Enum): 120 | FOG = "Туман" 121 | CLOUDS = "Облачно" 122 | 123 | # Вариант без наследования от str 124 | class WeatherTypeEnum(Enum): 125 | FOG = "Туман" 126 | CLOUDS = "Облачно" 127 | 128 | print(WeatherTypeStrEnum.CLOUDS.upper()) # ОБЛАЧНО 129 | print(WeatherTypeEnum.CLOUDS.upper()) # AttributeError 130 | print(WeatherTypeEnum.CLOUDS.value.upper()) # ОБЛАЧНО 131 | 132 | print(WeatherTypeStrEnum.CLOUDS == "Облачно") # True 133 | print(WeatherTypeEnum.CLOUDS == "Облачно") # False 134 | print(WeatherTypeEnum.CLOUDS.value == "Облачно") # True 135 | 136 | print(f"Погода: {WeatherTypeStrEnum.CLOUDS}") # Погода: Облачно 137 | print(f"Погода: {WeatherTypeEnum.CLOUDS}") # Погода: WeatherTypeEnum.CLOUDS 138 | print(f"Погода: {WeatherTypeEnum.CLOUDS.value}") # Погода: Облачно 139 | ``` 140 | 141 | При этом тип `WeatherTypeStrEnum` и `str` — это всё же разные типы. Если аргумент функции ожидает `WeatherTypeStrEnum`, то передать туда `str` не получится. Типизация работает как надо: 142 | 143 | ```python 144 | def make_something_great_with_weather(weather: WeatherTypeStrEnum): pass 145 | 146 | smth("Туман") # Не пройдёт проверку типов 147 | smth(WeatherTypeStrEnum.FOG) # Ок, всё в порядке 148 | ``` 149 | 150 | Какие еще варианты для использования Enum можно придумать? Например, перечисление полов, мужской/женский. Перечисление статусов запросов, ответов, каких-то операций. Перечисление статусов заказов, например, если эти статусы зашиты в приложении, а не берутся из справочника БД. Перечисление дней недели (понедельник, вторник и т. д.). 151 | 152 | Итак, полный код `weather_api_service.py` на текущий момент: 153 | 154 | ```python 155 | from datetime import datetime 156 | from dataclasses import dataclass 157 | from enum import Enum 158 | from typing import TypeAlias 159 | 160 | from coordinates import Coordinates 161 | 162 | Celsius: TypeAlias = int 163 | 164 | class WeatherType(str, Enum): 165 | THUNDERSTORM = "Гроза" 166 | DRIZZLE = "Изморось" 167 | RAIN = "Дождь" 168 | SNOW = "Снег" 169 | CLEAR = "Ясно" 170 | FOG = "Туман" 171 | CLOUDS = "Облачно" 172 | 173 | @dataclass(slots=True, frozen=True) 174 | class Weather: 175 | temperature: Celsius 176 | weather_type: WeatherType 177 | sunrise: datetime 178 | sunset: datetime 179 | city: str 180 | 181 | def get_weather(coordinates: Coordinates) -> Weather: 182 | """Requests weather in OpenWeather API and returns it""" 183 | return Weather( 184 | temperature=20, 185 | weather_type=WeatherType.CLEAR, 186 | sunrise=datetime.fromisoformat("2022-05-04 04:00:00"), 187 | sunset=datetime.fromisoformat("2022-05-04 20:25:00"), 188 | city="Moscow" 189 | ) 190 | ``` 191 | 192 | Обратите внимание, как всё чётенько! Мы читаем описание функции `get_weather` и у нас не может быть непониманий, что эта функция принимает на вход и в каком формате, а также что она возвращает на выход и опять же в каком формате. Если в будущем мы будем работать не с OpenWeather API, а с каким-то другим сервисом погоды, то мы просто заменим слой общения с этим внешним сервисом, но пока наша функция `get_weather` будет возвращать структуру `Weather`, весь остальной, внешний по отношению к этой функции, код будет работать без изменений. Мы прописали интерфейс коммуникации функции `get_weather` с внешним миром и пока этот интерфейс поддерживается — неважно как и откуда получаются данные внутри этой функции, главное, чтобы они просто на выходе преобразовались в нужный нам формат. 193 | 194 | Отлично, осталось реализовать заглушку для принтера, который будет печатать нашу погоду, `weather_formatter.py`: 195 | 196 | ```python 197 | from weather_api_service import Weather 198 | 199 | def format_weather(weather: Weather) -> str: 200 | """Formats weather data in string""" 201 | return "Тут будет печать данных погоды из структуры weather" 202 | ``` 203 | 204 | Отлично, каркас приложения готов. Прописаны основные типы данных, что функции принимают на вход и возвращают. По этим функциям и типам уже сейчас понятно, как будет работать приложение, хотя мы ещё по сути ничего не реализовали. 205 | 206 | Заполним полученный скелет приложения теперь реализацией. 207 | -------------------------------------------------------------------------------- /src/weather/realize-coordinates.md: -------------------------------------------------------------------------------- 1 | # Реализация приложения — получение GPS координат 2 | 3 | Реализуем в первую очередь получение GPS-координат, `coordinates.py`: 4 | 5 | ```python 6 | from dataclasses import dataclass 7 | from subprocess import Popen, PIPE 8 | 9 | from exceptions import CantGetCoordinates 10 | 11 | @dataclass(slots=True, frozen=True) 12 | class Coordinates: 13 | longitude: float 14 | latitude: float 15 | 16 | def get_gps_coordinates() -> Coordinates: 17 | """Returns current coordinates using MacBook GPS""" 18 | process = Popen(["whereami"], stdout=PIPE) 19 | (output, err) = process.communicate() 20 | exit_code = process.wait() 21 | if err is not None or exit_code != 0: 22 | raise CantGetCoordinates 23 | output_lines = output.decode().strip().lower().split("\n") 24 | latitude = longitude = None 25 | for line in output_lines: 26 | if line.startswith("latitude:"): 27 | latitude = float(line.split()[1]) 28 | if line.startswith("longitude:"): 29 | longitude = float(line.split()[1]) 30 | return Coordinates(longitude=longitude, latitude=latitude) 31 | 32 | if __name__ == "__main__": 33 | print(get_gps_coordinates()) 34 | ``` 35 | 36 | Хочу обратить внимание тут вот на что. Если что-то пошло не так с процессом получения координат — мы не возвращаем какую-то ерунду вроде `None`. Мы возбуждаем (*райзим*, от англ. *raise*) исключение. Причём исключение не какое-то системное вроде `ValueError`, а наш собственный тип исключения, который мы назвали `CantGetCoordinates` и положили в специальный модуль, куда мы будем класть исключения `exceptions.py`: 37 | 38 | ```python 39 | class CantGetCoordinates(Exception): 40 | """Program can't get current GPS coordinates""" 41 | ``` 42 | 43 | Почему не `ValueError`, а свой тип исключений? Чтобы разделять обычные питоновские `ValueError` от конкретно нашей ситуации с невозможностью получить координаты. Явное лучше неявного. 44 | 45 | Почему исключение, а не возврат `None`? Потому что если у функции есть нормальный сценарий работы и ненормальный, то есть исключительный, то исключительный сценарий должен использовать исключения, а не возвращать какую-то ерунду вроде `False`, `0`, `None`, `tuple()`. Исключительная ситуация должна возбуждать исключение, и уже на уровне выше нашей функции мы должны решить, что с этой исключительной ситуацией делать. Код, который будет вызывать нашу функцию `get_gps_coordinates`, решит, что делать с исключительной ситуацией, на каком уровне и как эта ситуация должна быть обработана. 46 | 47 | Отлично. Функция отдаёт сейчас точные координаты, которые я не хочу раскрывать, давайте введём в приложение конфиг `config.py` и в нём зададим, использовать точные координаты или примерные. Я буду использовать примерные координаты. Погода от этого не изменится, просто в другой район города попаду. 48 | 49 | `config.py`: 50 | 51 | ```python 52 | USE_ROUNDED_COORDS = True 53 | ``` 54 | 55 | `coordinates.py`: 56 | 57 | ```python 58 | from dataclasses import dataclass 59 | from subprocess import Popen, PIPE 60 | 61 | import config 62 | from exceptions import CantGetCoordinates 63 | 64 | @dataclass(slots=True, frozen=True) 65 | class Coordinates: 66 | longitude: float 67 | latitude: float 68 | 69 | def get_gps_coordinates() -> Coordinates: 70 | """Returns current coordinates using MacBook GPS""" 71 | process = Popen(["whereami"], stdout=PIPE) 72 | output, err = process.communicate() 73 | exit_code = process.wait() 74 | if err is not None or exit_code != 0: 75 | raise CantGetCoordinates 76 | output_lines = output.decode().strip().lower().split("\n") 77 | latitude = longitude = None 78 | for line in output_lines: 79 | if line.startswith("latitude:"): 80 | latitude = float(line.split()[1]) 81 | if line.startswith("longitude:"): 82 | longitude = float(line.split()[1]) 83 | if config.USE_ROUNDED_COORDS: # Добавили округление координат 84 | latitude, longitude = map(lambda c: round(c, 1), [latitude, longitude]) 85 | return Coordinates(longitude=longitude, latitude=latitude) 86 | 87 | if __name__ == "__main__": 88 | print(get_gps_coordinates()) 89 | ``` 90 | 91 | Отлично. Обратите внимание — мы не полагаемся здесь на то, на какой строке будет значение широты и долготы в выдаче команды `whereami`. Мы ищем нужную строку во всех возвращаемых строках, не полагаясь на то, будут это первые строки или нет. Получается более надёжное решение на случай смены порядка строк в `whereami`. 92 | 93 | Теперь проведём рефакторинг, поделив большую, делающую слишком много всего функцию `get_gps_coordinates` на несколько небольших простых функций: 94 | 95 | ```python 96 | from dataclasses import dataclass 97 | from subprocess import Popen, PIPE 98 | from typing import Literal 99 | 100 | import config 101 | from exceptions import CantGetCoordinates 102 | 103 | @dataclass(slots=True, frozen=True) 104 | class Coordinates: 105 | latitude: float 106 | longitude: float 107 | 108 | def get_gps_coordinates() -> Coordinates: 109 | """Returns current coordinates using MacBook GPS""" 110 | coordinates = _get_whereami_coordinates() 111 | return _round_coordinates(coordinates) 112 | 113 | def _get_whereami_coordinates() -> Coordinates: 114 | whereami_output = _get_whereami_output() 115 | coordinates = _parse_coordinates(whereami_output) 116 | return coordinates 117 | 118 | def _get_whereami_output() -> bytes: 119 | process = Popen(["whereami"], stdout=PIPE) 120 | output, err = process.communicate() 121 | exit_code = process.wait() 122 | if err is not None or exit_code != 0: 123 | raise CantGetCoordinates 124 | return output 125 | 126 | def _parse_coordinates(whereami_output: bytes) -> Coordinates: 127 | try: 128 | output = whereami_output.decode().strip().lower().split("\n") 129 | except UnicodeDecodeError: 130 | raise CantGetCoordinates 131 | return Coordinates( 132 | latitude=_parse_coord(output, "latitude"), 133 | longitude=_parse_coord(output, "longitude") 134 | ) 135 | 136 | def _parse_coord( 137 | output: list[str], 138 | coord_type: Literal["latitude"] | Literal["longitude"]) -> float: 139 | for line in output: 140 | if line.startswith(f"{coord_type}:"): 141 | return _parse_float_coordinate(line.split()[1]) 142 | else: 143 | raise CantGetCoordinates 144 | 145 | def _parse_float_coordinate(value: str) -> float: 146 | try: 147 | return float(value) 148 | except ValueError: 149 | raise CantGetCoordinates 150 | 151 | def _round_coordinates(coordinates: Coordinates) -> Coordinates: 152 | if not config.USE_ROUNDED_COORDS: 153 | return coordinates 154 | return Coordinates(*map( 155 | lambda c: round(c, 1), 156 | [coordinates.latitude, coordinates.longitude] 157 | )) 158 | 159 | 160 | if __name__ == "__main__": 161 | print(get_gps_coordinates()) 162 | ``` 163 | 164 | Кода стало больше, функций стало больше, но код стал проще читаться и будет проще сопровождаться. Если бы мы сейчас писали тесты, то убедились бы ещё и в том, что этот код легче обложить тестами, чем предыдущий вариант с одной большой функцией, делающей всё подряд. 165 | 166 | Функции, имена которых начинаются с подчёркивания — не предназначены для вызова извне модуля, то есть они вызываются только соседними функциями модуля `coordinates.py`. 167 | 168 | Почему много коротких функций это лучше, чем одна большая функция? Потому что для того, чтобы понять, что происходит внутри функции на 50 строк, надо прочитать 50 строк. А если эти 50 строк разбить на пару меньших функций и понятным образом эти пару функций назвать, то нам понадобится прочесть всего пару строк с вызовами этой пары функций и всё. Прочесть пару строк легче, чем 50. А если нам нужны детали реализации какой-то из этих меньших функций, мы всегда можем в неё провалиться и посмотреть, что внутри. 169 | 170 | Функция `get_gps_coordinates` тут максимально проста — она получает координаты и затем округляет их и возвращает, всё. Два вызова понятно названных функций вместо длинного сложного кода, как было раньше. 171 | 172 | Также обратите внимание — абсолютно все функции типизированы, все принимаемые аргументы функций типизированы и все возвращаемые значения тоже типизированы. Причём типизированы максимально конкретными типами. 173 | 174 | Эта логика реализована без классов, на обычных функциях. Это нормально. Не нужно использовать ООП просто для того, чтобы у вас были классы. От того, что мы обернём несколько описанных здесь функций в класс — никакого нового полезного качества в нашем коде не появится, просто вместо функций будет класс. В таком случае вовсе не нужно использовать классы. 175 | 176 | Обратите внимание также, как в функции `_parse_float_coordinate` обработана ошибка `ValueError`, которая может возникать, если вдруг координаты не получается привести из строки к типу `float` — мы возбуждаем (райзим) исключение своего типа `CantGetCoordinates`. В любой ситуации, когда нам не удалось получить координаты из результатов команды `whereami` мы получаем такое исключение и можем обработать (или не обрабатывать) его в коде, который будет вызывать нашу верхнеуровневую функцию `get_gps_coordinates`. Про работу с исключениями более подробно мы поговорим в отдельном материале. 177 | 178 | -------------------------------------------------------------------------------- /src/weather/interfaces.md: -------------------------------------------------------------------------------- 1 | # Использование интерфейсов и протоколов 2 | 3 | В теории объектно-ориентированного программирования есть понятия интерфейсов и абстрактных классов. Эти классы созданы для того, чтобы быть отнаследованными в других классах. Интерфейс и абстрактный класс созданы для того, чтобы показать, какими свойствами и методами должны обладать все их дочерние классы. Разница интерфейса и абстрактного класса в том, что интерфейс не содержит реализации, а абстрактный класс может помимо абстрактных методов содержать и часть реализованных методов. 4 | 5 | Использование интерфейсов и абстрактных классов — хорошая затея, если мы хотим заложить на будущее возможность замены компонентов системы на другие. Расширяемость системы это хорошо. 6 | 7 | Например, допустим, мы хотим реализовать сохранение истории всех запросов погоды. Чтобы при каждом запуске нашей программы куда-то сохранялись её результаты, и в будущем можно было проанализировать эту информацию. 8 | 9 | Куда мы можем сохранить эту информацию? В плоский txt-файл. В файл JSON. В базу данных SQL. В NoSQL базу данных. Отправить куда-то по сети в какой-то веб-сервис. Вариантов много и потенциально в будущем возможно нам захочется заменить текущий выбранный вариант на какой-то другой. Давайте реализуем модуль `history.py`, который будет отвечать за сохранение истории: 10 | 11 | ```python 12 | from weather_api_service import Weather 13 | 14 | class WeatherStorage: 15 | """Interface for any storage saving weather""" 16 | def save(self, weather: Weather) -> None: 17 | raise NotImplementedError 18 | 19 | def save_weather(weather: Weather, storage: WeatherStorage) -> None: 20 | """Saves weather in the storage""" 21 | storage.save(weather) 22 | ``` 23 | 24 | Здесь `WeatherStorage` — это интерфейс в терминах объектно-ориентированного программирования. Этот интерфейс описывает те методы, которые обязательно должны присутствовать у любого хранилища погоды. Собственно говоря, у любого хранилища погоды должен быть как минимум метод `save`, который принимает на вход погоду, которую он должен сохранить. 25 | 26 | В интерфейсе `WeatherStorage` нет реализации (на то он и интерфейс), он только объявляет метод `save`, который должен быть определён в любом классе, реализующем этот интерфейс. 27 | 28 | Функция `save_weather` будет вызываться более высокоуровневым управляющим кодом для сохранения погоды в хранилище. Эта функция принимает на вход погоду `weather`, которую надо сохранить, и реальный экземпляр хранилища `storage`, которое реализует интерфейс `WeatherStorage`. 29 | 30 | Чтобы показать, что метод `save` интерфейса не реализован, мы возбуждаем в нём исключение `NotImplementedError`, эта ошибка говорит о том, что вызываемый метод не реализован. Таким образом, если мы создадим хранилище, отнаследованное от этого интерфейса, не реализуем в нём метод `save` и вызовем его, то у нас упадёт в рантайме исключение `NotImplementedError`: 31 | 32 | ```python 33 | class PlainFileWeatherStorage(WeatherStorage): 34 | pass 35 | 36 | storage = PlainFileWeatherStorage() 37 | storage.save() # Тут в runtime упадёт ошибка NotImplementedError 38 | ``` 39 | 40 | Проблема такого подхода в том, что ошибка, относящаяся к проверке типов (все ли методы интерфейса реализованы в наследующем его классе) падает только в рантайме. Хотелось бы, чтобы такая проверка выполнялась в IDE и статическим анализатором кода, а не падала в рантайме. Наша задача, напомню, сделать так, чтобы до рантайма ошибки не доходили. 41 | 42 | Какой есть ещё вариант определения интерфейсов в Python? Есть вариант с использованием встроенного модуля ABC ([документация](https://docs.python.org/3/library/abc.html)), созданного как раз для работы с такими абстрактными классами и интерфейсами: 43 | 44 | ```python 45 | from abc import ABC, abstractmethod 46 | 47 | class WeatherStorage(ABC): 48 | """Interface for any storage saving weather""" 49 | @abstractmethod 50 | def save(self, weather: Weather) -> None: 51 | pass 52 | ``` 53 | 54 | Экземпляр класса, наследующего таким образом объявленный интерфейс, не получится создать без явной реализации всех методов, объявленных с декоратором `@abstractmethod`. То есть вот такой код в runtime упадёт сразу в момент создания экземпляра такого класса: 55 | 56 | ```python 57 | class PlainFileWeatherStorage(WeatherStorage): 58 | pass 59 | 60 | # Тут упадет ошибка в рантайме, так как в PlainFileWeatherStorage 61 | # не определен метод save 62 | storage = PlainFileWeatherStorage() 63 | ``` 64 | 65 | Опять же — код падает в runtime, пользователи видят ошибку, плохо. Как перенести проверку на корректность использования интерфейсов и абстрактных классов на IDE и статический анализатор кода? 66 | 67 | Способ появился в Python 3.8 благодаря [PEP 544](https://peps.python.org/pep-0544/), и он называется протоколами, `Protocol`: 68 | 69 | ```python 70 | from typing import protocol 71 | 72 | class WeatherStorage(Protocol): 73 | """Interface for any storage saving weather""" 74 | def save(self, weather: Weather) -> None: 75 | pass 76 | 77 | class PlainFileWeatherStorage: 78 | def save(self, weather: Weather) -> None: 79 | print("реализация сохранения погоды...") 80 | 81 | def save_weather(weather: Weather, storage: WeatherStorage) -> None: 82 | """Saves weather in the storage""" 83 | storage.save(weather) 84 | ``` 85 | 86 | Воу! Класс `PlainFileWeatherStorage` никак не связан с `WeatherStorage`, не отнаследован от него, хотя и реализует его интерфейс в неявном виде, то есть просто определяет все функции, которые должны быть реализованы в этом интерфейсе. Сам интерфейс `WeatherStorage` отнаследован от класса `typing.Protocol`, что делает его так называемым протоколом. В функции `save_weather` тип аргумента `storage` по-прежнему установлен в этот интерфейс `WeatherStorage`. 87 | 88 | Получается, что класс `PlainFileWeatherStorage` неявно реализует протокол/интерфейс `WeatherStorage`. Если вы работали с языком программирования Go — в нём интерфейсы реализованы схожим образом, это так называемая [структурная типизация](https://en.wikipedia.org/wiki/Structural_type_system). 89 | 90 | Почему использование такого подхода в приоритете? Потому что проверкой корректности использования интерфейсов занимается IDE и статический анализатор кода вроде `mypy`. Речь идёт уже не о проверке в runtime, речь идет о проверке корректности реализации до этапа, в котором участвуют пользователи программы. Это то, что нам нужно! 91 | 92 | Таким образом, наш модуль `history.py` принимает следующий вид: 93 | 94 | ```python 95 | from datetime import datetime 96 | from pathlib import Path 97 | from typine import Protocol 98 | 99 | from weather_api_service import Weather 100 | from weather_formatter import format_weather 101 | 102 | class WeatherStorage(Protocol): 103 | """Interface for any storage saving weather""" 104 | def save(self, weather: Weather) -> None: 105 | raise NotImplementedError 106 | 107 | class PlainFileWeatherStorage: 108 | """Store weather in plain text file""" 109 | def __init__(self, file: Path): 110 | self._file = file 111 | 112 | def save(self, weather: Weather) -> None: 113 | now = datetime.now() 114 | formatted_weather = format_weather(weather) 115 | with open(self._file, "a") as f: 116 | f.write(f"{now}\n{formatted_weather}\n") 117 | 118 | def save_weather(weather: Weather, storage: WeatherStorage) -> None: 119 | """Saves weather in the storage""" 120 | storage.save(weather) 121 | ``` 122 | 123 | 124 | `PlainFileWeatherStorage` это реализованное хранилище, отнаследованное от нашего интерфейса, то есть реализующее его методы. Помимо метода `save` этот класс реализует ещё конструктор, который сохраняет в поле `self._file` путь до файла, в который будет записываться информация о погоде. 125 | 126 | Для перевода объекта погоды типа `Weather` в строку используется функция `format_weather`, которую мы реализовали ранее в модуле `weather_formatter`. 127 | 128 | Этот код — абсолютно валиден с точки зрения проверки системы типов. 129 | 130 | Вызовем теперь логику сохранения погоды в главном файле `weather`: 131 | 132 | ```python 133 | #!/usr/bin/env python3.10 134 | from pathlib import Path 135 | 136 | from exceptions import ApiServiceError, CantGetCoordinates 137 | from coordinates import get_gps_coordinates 138 | from history import PlainFileWeatherStorage, save_weather 139 | from weather_api_service import get_weather 140 | from weather_formatter import format_weather 141 | 142 | 143 | def main(): 144 | try: 145 | coordinates = get_gps_coordinates() 146 | except CantGetCoordinates: 147 | print("Не смог получить GPS-координаты") 148 | exit(1) 149 | try: 150 | weather = get_weather(coordinates) 151 | except ApiServiceError: 152 | print("Не смог получить погоду в API-сервиса погоды") 153 | exit(1) 154 | save_weather( 155 | weather, 156 | PlainFileWeatherStorage(Path.cwd() / "history.txt") 157 | ) 158 | print(format_weather(weather)) 159 | 160 | 161 | if __name__ == "__main__": 162 | main() 163 | ``` 164 | 165 | Здесь мы создаём экземпляр объекта `PlainFileWeatherStorage` и передаём его на вход функции `save_weather`. Всё работает! 166 | 167 | ![](../images/bat_history.png) 168 | 169 | Для вывода содержимого текстового файла на скриншоте вместо `cat` использовался `bat` — [продвинутый вариант](https://github.com/sharkdp/bat):) 170 | 171 | Теперь, если мы захотим изменить хранилище, мы можем создать новое хранилище, например, JSON-хранилище, реализовав в нём все методы интерфейса `WeatherStorage`, и передать это новое хранилище в `save_weather`. Всё продолжит работать и будет корректно с точки зрения типов. Причём нам не придётся ничего менять в функции `save_weather`, так как она опирается только на интерфейс, определённый в классе `WeatherStorage`. 172 | 173 | `history.py`, добавленный код: 174 | 175 | ```python 176 | import json 177 | from typing import Protocol, TypedDict 178 | 179 | 180 | class HistoryRecord(TypedDict): 181 | date: str 182 | weather: str 183 | 184 | class JSONFileWeatherStorage: 185 | """Store weather in JSON file""" 186 | def __init__(self, jsonfile: Path): 187 | self._jsonfile = jsonfile 188 | self._init_storage() 189 | 190 | def save(self, weather: Weather) -> None: 191 | history = self._read_history() 192 | history.append({ 193 | "date": str(datetime.now()), 194 | "weather": format_weather(weather) 195 | }) 196 | self._write(history) 197 | 198 | def _init_storage(self) -> None: 199 | if not self._jsonfile.exists(): 200 | self._jsonfile.write_text("[]") 201 | 202 | def _read_history(self) -> list[HistoryRecord]: 203 | with open(self._jsonfile, "r") as f: 204 | return json.load(f) 205 | 206 | def _write(self, history: list[HistoryRecord]) -> None: 207 | with open(self._jsonfile, "w") as f: 208 | json.dump(history, f, ensure_ascii=False, indent=4) 209 | ``` 210 | 211 | Здесь мы воспользовались структурой `TypedDict`, типизированным словарём. Это удобно для нашего сценария, так как каждая запись погоды в JSON-файл будет представлять собой как раз структуру словаря, состоящую из двух полей — date для `даты` и времени получения погоды и `weather` для описания погоды. Метод `_read_history` предназначен для чтения данных погоды из JSON-файла и он возвращает не `list[dict]`, а `list[HistoryRecord]`, максимально конкретный тип данных. Аналогично метод `_write` принимает в качестве аргумента не `list[dict]`, а тоже `list[HistoryRecord]`. Везде используем максимально точную конкретную структуру данных. 212 | 213 | `weather`, изменённый код: 214 | 215 | ```python 216 | from history import JSONFileWeatherStorage, save_weather 217 | 218 | 219 | def main(): 220 | # пропущено.... 221 | save_weather( 222 | weather, 223 | JSONFileWeatherStorage(Path.cwd() / "history.json") 224 | ) 225 | print(format_weather(weather)) 226 | 227 | 228 | if __name__ == "__main__": 229 | main() 230 | ``` 231 | 232 | Всё работает: 233 | 234 | ![](../images/bat_history2.png) 235 | 236 | В процессе сохранения файла тоже может возникнуть ошибка. Например, директория может быть закрыта для записей и тд. Такие ошибки тоже нужно обработать. Напишите эту обработку самостоятельно в качестве тренировки! 237 | 238 | -------------------------------------------------------------------------------- /theme/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | {{#if is_print }} 8 | 9 | {{/if}} 10 | {{#if base_url}} 11 | 12 | {{/if}} 13 | 14 | 15 | 16 | {{> head}} 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{#if favicon_svg}} 24 | 25 | {{/if}} 26 | {{#if favicon_png}} 27 | 28 | {{/if}} 29 | 30 | 31 | 32 | {{#if print_enable}} 33 | 34 | {{/if}} 35 | 36 | 37 | 38 | {{#if copy_fonts}} 39 | 40 | {{/if}} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {{#each additional_css}} 49 | 50 | {{/each}} 51 | 52 | {{#if mathjax_support}} 53 | 54 | 55 | {{/if}} 56 | 57 | 58 | 59 | 63 | 64 | 65 | 79 | 80 | 81 | 91 | 92 | 93 | 103 | 104 | 110 | 111 |
112 | 113 |
114 | {{> header}} 115 | 116 | 163 | 164 | {{#if search_enabled}} 165 | 175 | {{/if}} 176 | 177 | 178 | 185 | 186 |
187 |
188 | {{{ content }}} 189 |
190 | 191 | 207 |
208 |
209 | 210 | 223 | 224 |
225 | 226 | {{#if live_reload_endpoint}} 227 | 228 | 243 | {{/if}} 244 | 245 | {{#if google_analytics}} 246 | 247 | 262 | {{/if}} 263 | 264 | {{#if playground_line_numbers}} 265 | 268 | {{/if}} 269 | 270 | {{#if playground_copyable}} 271 | 274 | {{/if}} 275 | 276 | {{#if playground_js}} 277 | 278 | 279 | 280 | 281 | 282 | {{/if}} 283 | 284 | {{#if search_js}} 285 | 286 | 287 | 288 | {{/if}} 289 | 290 | 291 | 292 | 293 | 294 | 295 | {{#each additional_js}} 296 | 297 | {{/each}} 298 | 299 | {{#if is_print}} 300 | {{#if mathjax_support}} 301 | 308 | {{else}} 309 | 314 | {{/if}} 315 | {{/if}} 316 | 317 | 318 | 319 | --------------------------------------------------------------------------------