Урок 37. Typing + Pydantic¶
З цього уроку FastAPI-гілка курсу будує новинний агрегатор: парсер стрічки новин, який крок за кроком обростає API, базою, кешем, підсумками від Gemini й Telegram-ботом. Починаємо не з нуля: вже є робочий парсер rbc.ua — функція parse_rbc_news з уроку про web scraping — і 168 новин, які вона зібрала.
Парсер повертає list[dict]. Словник нічого не гарантує: ключ може бути з одруківкою, значення — порожнім рядком, «дата» — лише "14:19", URL — з чужого домену. Сьогодні два рефакторинги цього коду: анотації типів (їх перевіряє mypy до запуску) і Pydantic-модель NewsItem (вона перевіряє дані під час роботи). Pydantic — фундамент FastAPI: у наступному уроці ця сама модель стане відповіддю API.
| Урок | Крок агрегатора |
|---|---|
| 36 | парсер з типами; NewsItem на Pydantic |
| 37 | FastAPI: GET /api/news, POST /api/scrape, /docs, Postman |
| 38 | SQLAlchemy: новини в базі |
| 39 | middleware, кеш і rate limit на Redis |
| 41 | тести API |
| 43 | Gemini: підсумок, категорія, тональність |
| 47 | Telegram-бот |
| 48–50 | Docker, Compose, CI/CD |
Проєкт: news_hub.
Що потрібно з попередніх уроків: функції й словники (М1), класи й @classmethod (уроки 20–24), винятки (13), pytest (25), HTTP і парсинг відповіді (31), серіалізатори DRF (35) — Pydantic робить для FastAPI те саме.
Після уроку ти зможеш:
- анотувати функції:
list[str],dict[str, int],X | None,Literal,TypedDict; - перевірити код
mypyі прочитати його помилки; - описати Pydantic-модель з обмеженнями полів (
Field), валідаторами (field_validator,model_validator) і незмінністю (frozen); - відділити «сирі» дані з інтернету від перевірених і не губити відхилені мовчки;
- отримати з моделі JSON і JSON Schema.
Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки — типи й моделі на справжніх новинах з перевірками.
Звідки дані
Сайт www.rbc.ua зараз недоступний із середовища, де збирався курс, тому агрегатор працює на знімку — 168 новинах, які parse_rbc_news зібрала раніше (data/rbc_news_snapshot.json). Код парсера той самий; як запустити його на свіжій сторінці — у README.md проєкту.
Пригадай¶
- Що поверне
{"title": "x"}["titel"]і коли ти про це дізнаєшся? - Що робить
@classmethodі чим він відрізняється від звичайного методу (урок 24)? - Що перевіряв
NoteInputSerializerв уроці 36 і що повертав для неправильних даних?
Відповіді
KeyError: 'titel'— лише коли рядок виконається, тобто в найгірший момент: у роботі, на справжніх даних.- Отримує клас (
cls), а не об'єкт — типовий спосіб написати «альтернативний конструктор», якNewsItem.from_raw(...)сьогодні. - Типи й обмеження полів;
is_valid()→False, помилки за полями, API —400. Pydantic робить те саме, FastAPI поверне422.
Старт: з якого коду починаємо¶
Функція parse_rbc_news(html) -> list[dict] шукає на сторінці контейнери новин, а якщо їх немає — посилання /news/ з текстом «14:19 Заголовок». Кожна новина — словник з п'ятьма рядками. Подивимось на те, що вона насправді зібрала (у папці news_hub):
import json
from collections import Counter
from urllib.parse import urlsplit
news = json.loads(open("data/rbc_news_snapshot.json", encoding="utf-8").read())
print("новин:", len(news), "| ключі:", list(news[0]))
print("перша:", news[1])
print("порожніх category:", sum(not n["category"] for n in news),
"| description:", sum(not n["description"] for n in news),
"| datetime:", sum(not n["datetime"] for n in news))
print("формати datetime:", Counter(len(n["datetime"]) for n in news))
print("домени:", Counter(urlsplit(n["url"]).hostname for n in news))
print("шляхи:", Counter("/".join(urlsplit(n["url"]).path.split("/")[1:3]) for n in news))
новин: 168 | ключі: ['title', 'url', 'category', 'description', 'datetime']
перша: {'title': 'У Путіна заявили, що вихід на мирну угоду з Україною "займе багато часу"', 'url': 'https://www.rbc.ua/rus/news/putina-zayavili-shcho-vihid-mirnu-ugodu-ukrayinoyu-1778325455.html', 'category': '', 'description': '', 'datetime': '14:19'}
порожніх category: 168 | description: 168 | datetime: 31
формати datetime: Counter({5: 137, 0: 31})
домени: Counter({'www.rbc.ua': 167, 'auto.rbc.ua': 1})
шляхи: Counter({'rus/news': 138, 'ukr/news': 30})
Висновки з реальних даних:
categoryіdescriptionпорожні завжди — стрічка їх не містить; категорію й мову доведеться виводити з URL (/rus/news/…);datetime— або"", або лише час"HH:MM"без дати;- одна новина — з піддомену
auto.rbc.ua: код, що «ріже» URL рядком, на ній помилиться (див. «Знайди помилку»).
Із list[dict] усе це з'ясовується лише під час запуску. Рефакторимо.
Рефакторинг 1. Анотації типів і TypedDict¶
Анотація типу — підказка в коді, якого типу змінна, параметр чи результат: def headline(item: RawNews) -> str. Python її не перевіряє під час роботи; перевіряє окрема програма — mypy — до запуску, як лінтер.
| Запис | Що означає | Приклад з агрегатора |
|---|---|---|
str, int, bool |
простий тип | title: str |
list[str] |
список рядків | ITEM_SELECTORS: list[tuple[str, str]] |
dict[str, str] |
словник: ключ → значення | CATEGORIES: dict[str, str] |
set[str] |
множина | seen_urls: set[str] |
X \| None |
або X, або None |
published_time: time \| None |
Literal["uk", "ru"] |
лише ці значення | lang |
TypedDict |
словник з відомими ключами й типами | RawNews |
Callable[[Tag], bool] |
функція: аргументи → результат | фільтр тегів у парсері |
Що змінилося¶
| Файл | Зміна | Навіщо |
|---|---|---|
news_hub/parser.py |
parse_rbc_news зі стартового ноутбука: анотації всіх функцій, list[dict] → list[RawNews], дві стратегії — окремі функції |
mypy бачить ключі новини; функцію легше тестувати |
news_hub/models.py |
новий — NewsItem, validate_news (рефакторинг 2) |
перевірка даних |
news_hub/snapshot.py |
новий — завантаження знімка через TypeAdapter |
знімок теж перевірений |
tests/ |
нові — 12 тестів | парсер, моделі, знімок |
+class RawNews(TypedDict):
+ """Одна новина так, як її дає HTML: лише рядки, ще не перевірені."""
+ title: str
+ url: str
+ category: str
+ description: str
+ datetime: str
+
+
-def parse_rbc_news(html: str) -> list[dict]:
+def parse_rbc_news(html: str) -> list[RawNews]:
"""HTML сторінки rbc.ua → список сирих новин."""
soup = BeautifulSoup(html, "html.parser")
- news_list = []
- seen_urls = set()
+ seen_urls: set[str] = set()
+ # Стратегія 2 — якщо контейнерів немає або вони нічого не дали
+ return _parse_containers(soup, seen_urls) or _parse_links(soup, seen_urls)
TypedDict і Pydantic на Python 3.10–3.11
RawNews імпортується з typing_extensions, а не з typing. Pydantic перевіряє TypedDict з typing лише на Python 3.12+; на 3.10 тест упав з PydanticUserError: Please use typing_extensions.TypedDict instead of typing.TypedDict on Python < 3.12. typing_extensions встановлюється разом із Pydantic.
Логіка розбору не змінилася — змінилися підписи. Два приклади в examples/ показують, що це дає. «Було» — функції, що працюють зі звичайним dict:
def parse() -> list[dict]:
return [{"title": "Реформа ЗСУ", "url": "https://www.rbc.ua/rus/news/reforma-zsu", "datetime": ""}]
def headline(item: dict) -> str:
return item["titel"].upper() # одруківка в ключі
def hour(item: dict) -> int:
return int(item["datetime"][:2]) # "" → ValueError, але mypy мовчить
«Стало» — ті самі помилки з типами RawNews і NewsItem:
from news_hub.models import NewsItem
from news_hub.parser import RawNews
def headline(item: RawNews) -> str:
return item["titel"].upper() # та сама одруківка
def hour(item: NewsItem) -> int:
return item.published_time.hour # у 31 новини часу немає (None)
$ mypy examples/before_dict.py
Success: no issues found in 1 source file
$ mypy examples/after_typed.py
examples/after_typed.py:7: error: TypedDict "RawNews" has no key "titel" [typeddict-item]
examples/after_typed.py:7: note: Did you mean "title"?
examples/after_typed.py:11: error: Item "None" of "time | None" has no attribute "hour" [union-attr]
Found 2 errors in 1 file (checked 1 source file)
- для
dictбудь-який ключ «правильний» — mypy мовчить, помилка чекає на запуск; - з
TypedDictmypy знає ключі й навіть підказуєDid you mean "title"?; time | Noneзмушує обробитиNone— саме той випадок, який у реальних даних трапляється 31 раз.
Анотації — лише підказки. Під час роботи Python їх не перевіряє:
from news_hub.parser import RawNews
item: RawNews = {"title": 42, "url": None, "category": "", "description": "", "datetime": ""} # type: ignore
print(type(item), item["title"], item["url"])
Дані з інтернету не прочитали нашого коду — їх треба перевіряти під час роботи. Це робить Pydantic.
Поглиблено
typing — Support for type hints, mypy: Type hints cheat sheet, TypedDict, PEP 604 (X | None).
Рефакторинг 2. Pydantic: модель NewsItem¶
Pydantic — бібліотека, яка перетворює анотації типів на перевірку: клас-модель описує поля й типи, а створення об'єкта або перевіряє й приводить дані, або кидає ValidationError з поясненням за полями.
class NewsItem(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, frozen=True)
title: str = Field(min_length=10, max_length=300)
url: HttpUrl
source: str # домен без www: rbc.ua, auto.rbc.ua
lang: Literal["uk", "ru"] # з першої частини шляху: /ukr/ або /rus/
category: str # з розділу в шляху: /ukr/news/ → «Новини»
published_time: time | None = None
@model_validator(mode="before")
@classmethod
def derive_from_url(cls, data: Any) -> Any:
"""source, lang і category, яких немає в HTML, беремо з URL — розбираючи його, а не рядком."""
if not isinstance(data, dict) or not isinstance(data.get("url"), str):
return data
parts = urlsplit(data["url"])
path = [segment for segment in parts.path.split("/") if segment]
derived: dict[str, Any] = {"source": (parts.hostname or "").removeprefix("www.")}
if path and path[0] in LANGS:
derived["lang"] = LANGS[path[0]]
if len(path) > 1:
derived["category"] = CATEGORIES.get(path[1], path[1].capitalize())
return derived | {key: value for key, value in data.items() if value not in (None, "")}
@field_validator("url")
@classmethod
def only_rbc(cls, url: HttpUrl) -> HttpUrl:
if not (url.host or "").endswith("rbc.ua"):
raise ValueError("очікуємо новину з rbc.ua")
return url
@classmethod
def from_raw(cls, raw: RawNews) -> "NewsItem":
"""Сирий рядок парсера → модель. Поле datetime зі стрічки — це лише «HH:MM»."""
return cls.model_validate({"title": raw["title"], "url": raw["url"],
"category": raw["category"], "published_time": raw["datetime"]})
| Інструмент | Що робить у NewsItem |
|---|---|
| анотація поля | тип і приведення: "14:19" → time(14, 19), рядок → HttpUrl |
Field(min_length=10, max_length=300) |
обмеження значення |
Literal["uk", "ru"] |
лише дозволені значення |
field_validator("url") |
власне правило для одного поля — після перевірки типу |
model_validator(mode="before") |
обробка сирих даних до перевірки полів: виводимо source, lang, category з URL |
str_strip_whitespace=True |
обрізати пробіли в усіх рядках |
frozen=True |
модель не можна змінити після створення |
Як це працює покроково — на справжній новині зі знімка, тій самій з піддомену auto.rbc.ua:
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
subgraph S1 ["1. from_raw: RawNews → словник полів"]
direction LR
R1["url: auto.rbc.ua/rus/news/…<br>category: '' · datetime: ''"] --> R2["title · url<br>category · published_time"]
end
subgraph S2 ["2. model_validator before: derive_from_url"]
direction LR
D1["urlsplit<br>host auto.rbc.ua · path rus/news"] --> D2["source auto.rbc.ua<br>lang ru · category Новини"] --> D3["порожні '' не перекривають<br>виведене"]
end
subgraph S3 ["3. поля: тип і обмеження"]
direction LR
F1["title: 10–300 символів"] --> F2["url: HttpUrl"] --> F3["lang: uk або ru"] --> F4["published_time: None"]
end
subgraph S4 ["4. field_validator after: only_rbc"]
direction LR
V1["url.host закінчується<br>на rbc.ua?"] --> V2["так"]
end
subgraph S5 ["5. результат"]
direction LR
OK["NewsItem, frozen<br>source auto.rbc.ua · Новини"]
end
S1 --> S2 --> S3 --> S4 --> S5
class R1,R2,D1,F1,F2,F3,F4 step
class D2,D3 warning
class V1 decision
class V2,OK success
Будь-яка помилка на кроках 3–4 додається до однієї ValidationError; модель створюється, лише якщо помилок немає.
Перевіримо на справжній новині зі знімка й на зіпсованих даних:
from pydantic import ValidationError
from news_hub.models import NewsItem
from news_hub.snapshot import load_snapshot
raw_news = load_snapshot()
item = NewsItem.from_raw(raw_news[1])
print(repr(item))
print(item.model_dump_json())
broken = {"title": "Коротко", "url": "https://example.com/ukr/news/x.html",
"category": "", "description": "", "datetime": "25:99"}
try:
NewsItem.from_raw(broken)
except ValidationError as error:
print(error.error_count(), "помилки:")
for e in error.errors():
print(" ", e["loc"][0], "→", e["msg"])
try:
item.title = "Інший заголовок новини"
except ValidationError as error:
print("frozen →", error.errors()[0]["msg"])
NewsItem(title='У Путіна заявили, що вихід на мирну угоду з Україною "займе багато часу"', url=HttpUrl('https://www.rbc.ua/rus/news/putina-zayavili-shcho-vihid-mirnu-ugodu-ukrayinoyu-1778325455.html'), source='rbc.ua', lang='ru', category='Новини', published_time=datetime.time(14, 19))
{"title":"У Путіна заявили, що вихід на мирну угоду з Україною \"займе багато часу\"","url":"https://www.rbc.ua/rus/news/putina-zayavili-shcho-vihid-mirnu-ugodu-ukrayinoyu-1778325455.html","source":"rbc.ua","lang":"ru","category":"Новини","published_time":"14:19:00"}
3 помилки:
title → String should have at least 10 characters
url → Value error, очікуємо новину з rbc.ua
published_time → Input should be in a valid time format, hour value is outside expected range of 0-23
frozen → Instance is frozen
published_time— справжнійdatetime.time, у JSON —"14:19:00";source,langіcategoryPydantic вивів з URL;- одна
ValidationErrorзібрала всі три помилки, а не лише першу — для клієнта API це список, що виправити; frozen=True: перевірений об'єкт уже не зіпсуєш випадковим присвоєнням.
Перевірка всього знімка: нічого не губиться мовчки¶
Парсер відсіює лише посилання, що точно не новини (текст коротший за 10 символів — пункти меню на кшталт «Новини»). Усе інше перевіряє NewsItem, а validate_news розділяє перевірені й відхилені з причинами — відхилене можна показати, записати в журнал чи порахувати:
def validate_news(raw_items: list[RawNews]) -> tuple[list[NewsItem], list[Rejected]]:
"""Розділяє сирі новини на перевірені й відхилені — нічого не губиться мовчки."""
valid: list[NewsItem] = []
rejected: list[Rejected] = []
for raw in raw_items:
try:
valid.append(NewsItem.from_raw(raw))
except ValidationError as error:
messages = [f"{'.'.join(map(str, e['loc'])) or 'item'}: {e['msg']}" for e in error.errors()]
rejected.append(Rejected(raw=raw, errors=messages))
return valid, rejected
from collections import Counter
from news_hub.models import validate_news
valid, rejected = validate_news(raw_news)
print("перевірених:", len(valid), "| відхилених:", len(rejected))
print(Counter((n.source, n.lang, n.category) for n in valid))
print("без часу публікації:", sum(n.published_time is None for n in valid))
valid, rejected = validate_news(raw_news[:2] + [broken])
print(len(valid), "перевірених;", rejected[0].errors)
перевірених: 168 | відхилених: 0
Counter({('rbc.ua', 'ru', 'Новини'): 137, ('rbc.ua', 'uk', 'Новини'): 30, ('auto.rbc.ua', 'ru', 'Новини'): 1})
без часу публікації: 31
2 перевірених; ['title: String should have at least 10 characters', 'url: Value error, очікуємо новину з rbc.ua', 'published_time: Input should be in a valid time format, hour value is outside expected range of 0-23']
Увесь знімок пройшов перевірку — стартовий парсер збирав коректні новини. Але тепер це доведено кодом і тестом test_snapshot_is_valid, а не припущено.
JSON Schema: опис моделі для інших програм¶
З моделі Pydantic будує JSON Schema — машинний опис полів і обмежень. FastAPI вставить його у /docs і OpenAPI-схему (урок 38), клієнт на іншій мові згенерує з нього свої класи:
schema = NewsItem.model_json_schema()
print(schema["required"])
print(schema["properties"]["title"])
print(schema["properties"]["lang"])
print(schema["properties"]["published_time"])
['title', 'url', 'source', 'lang', 'category']
{'maxLength': 300, 'minLength': 10, 'title': 'Title', 'type': 'string'}
{'enum': ['uk', 'ru'], 'title': 'Lang', 'type': 'string'}
{'anyOf': [{'format': 'time', 'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Published Time'}
TypedDict Pydantic теж уміє перевірити — через TypeAdapter. Так snapshot.py перевіряє структуру файлу знімка, перш ніж віддати його далі:
_RAW_LIST = TypeAdapter(list[RawNews]) # TypedDict теж можна перевірити Pydantic-ом
def load_snapshot(path: Path = SNAPSHOT) -> list[RawNews]:
"""JSON-файл → список RawNews; неправильна структура файлу — ValidationError."""
return _RAW_LIST.validate_python(json.loads(path.read_text(encoding="utf-8")))
Поглиблено
Pydantic: Models, Fields, Validators, Type Adapter, JSON Schema. Pydantic-схеми в Django — глава книги Django Ninja.
Архітектура: межа довіри¶
flowchart TD
classDef step fill:#eceff1,stroke:#546e7a,stroke-width:1px,color:#1a1a1a;
classDef decision fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#1a1a1a;
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1a1a1a;
classDef error fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#1a1a1a;
classDef warning fill:#fff8e1,stroke:#e65100,stroke-width:2px,color:#1a1a1a;
subgraph BEFORE ["до рефакторингу: словники скрізь"]
direction LR
H0["HTML rbc.ua"] --> P0["parse_rbc_news<br>list of dict"] --> U0["pandas, CSV, JSON<br>KeyError під час запуску"]
end
subgraph AFTER ["урок 37: перевірка на межі"]
direction LR
H1["HTML або знімок"] --> P1["parse_rbc_news<br>list of RawNews"] --> V["NewsItem<br>validate_news"]
V -- "відхилені + причини" --> R["Rejected"]
V -- "перевірені" --> OK["далі лише NewsItem"]
end
subgraph NEXT ["наступні уроки"]
direction LR
A["API, урок 38"] ~~~ D["база, урок 39"] ~~~ G["Gemini, урок 44"] ~~~ B["бот, урок 48"]
end
BEFORE --> AFTER --> NEXT
class H0,H1,P1 step
class P0,U0 error
class V warning
class R error
class OK,A,D,G,B success
- Межа довіри. Усе, що прийшло ззовні (HTML, файл, тіло запиту), — «сире» (
RawNews, лише рядки). Перевірка відбувається один раз — уNewsItem. Далі код працює з гарантіями:url— справжній URL з rbc.ua,lang—"uk"або"ru",published_time—timeабоNone. - Два інструменти — два моменти.
mypyловить помилки в коді до запуску; Pydantic — помилки в даних під час роботи. Одне не замінює інше. - Модель — контракт. API (37), база (38), Gemini (43) і бот (47) отримуватимуть
NewsItem, а не словник: змінимо модель — mypy і тести покажуть усі місця, які це зачепило.
Тести і mypy¶
Приклад виводу (час залежить від машини):
$ pytest -q -p no:cacheprovider
............ [100%]
12 passed in 0.10s
$ mypy --strict news_hub
Success: no issues found in 4 source files
--strict вмикає найсуворіші перевірки: кожна функція має анотації, жодного неявного Any.
Практика¶
Розібраний приклад: model_validator(mode="before")¶
HTML не містить ні мови, ні категорії — але вони є в URL: https://www.rbc.ua/ukr/news/…. derive_from_url отримує сирий словник до перевірки полів і доповнює його:
urlsplit(url)ділить URL на частини:hostname→www.rbc.ua,path→/ukr/news/…;source— домен безwww.;- перша частина шляху →
langчерезLANGS = {"ukr": "uk", "rus": "ru"}; - друга →
categoryчерез словникCATEGORIESз прототипуnews_dashboard; derived | {…}— значення, які прийшли непорожніми, мають пріоритет над виведеними.
for url in ["https://www.rbc.ua/ukr/news/budget-1.html",
"https://auto.rbc.ua/rus/news/speka-1778085857.html",
"https://www.rbc.ua/ukr/economic/hryvnia-2.html"]:
news = NewsItem.model_validate({"title": "Заголовок для перевірки", "url": url})
print(news.source, news.lang, news.category)
mode="before" — бо на цьому етапі полів source, lang, category ще немає: після перевірки полів (mode="after") модель без них просто не створилася б.
Зміни приклад¶
- На rbc.ua є й англомовні сторінки (
/eng/news/…). Додай"eng": "en"уLANGSі"en"уLiteral. Перевір, щоmypy --strictне скаржиться, а новина з/eng/отримуєlang="en". - Заголовок з одних великих літер — клікбейт. Додай
field_validator("title"), який відхиляє заголовок, деtitle.isupper(). Допиши тест уtests/test_models.py.
Спробуй самостійно: модель звіту парсингу¶
Опиши модель ScrapeReport — відповідь майбутнього POST /api/scrape (урок 38):
source: HttpUrl— яку сторінку парсили;scraped_at: datetime— коли; за замовчуванням — зараз (Field(default_factory=…));items: list[NewsItem],rejected: list[Rejected];@computed_fieldtotal— скільки всього новин (перевірених + відхилених).
Критерії перевірки: ScrapeReport(source=…, items=valid, rejected=rejected).model_dump() містить total; неправильний source — ValidationError; mypy --strict чистий.
Знайди помилку¶
Так визначають категорію новини, розбираючи URL рядком:
url = "https://auto.rbc.ua/rus/news/speka-pislya-holodiv-ryatuemo-vid-rizikiv-1778085857.html"
url_parts = [p for p in url.replace("https://www.rbc.ua", "").split("/") if p]
category_map = {"news": "Новини", "economic": "Економіка", "politics": "Політика"}
raw_cat = url_parts[1] if len(url_parts) > 1 else (url_parts[0] if url_parts else "")
print(url_parts[:3], "→", category_map.get(raw_cat, raw_cat.capitalize() or "Загальні"))
URL — справжній, зі знімка. Чому категорія «Auto.rbc.ua»?
Відповідь
url.replace("https://www.rbc.ua", "") — розбір URL рядком: він працює лише для одного точного домену. Для піддомену auto.rbc.ua заміна нічого не прибирає, split("/") дає ['https:', 'auto.rbc.ua', 'rus', …], і «категорією» стає домен. Помилка тиха — жодного винятку, лише неправильні дані в дашборді.
NewsItem розбирає URL як URL: urlsplit окремо дає hostname і path, тож піддомен ні на що не впливає — див. розібраний приклад: auto.rbc.ua ru Новини. Тест test_subdomain_is_parsed_not_replaced закріплює поведінку.
Правило: структуровані дані (URL, дати, JSON) розбирай бібліотекою, а не replace/split.
Підсумок¶
| Поняття | Що запам'ятати |
|---|---|
| Анотації типів | підказки: def f(x: int) -> str; Python їх не перевіряє під час роботи |
mypy |
перевіряє типи до запуску; --strict — найсуворіше |
X \| None |
змушує обробити None |
Literal[...] |
лише дозволені значення |
TypedDict |
словник з відомими ключами: mypy ловить одруківки |
Pydantic BaseModel |
перевірка й приведення даних під час роботи; помилка — ValidationError з усіма полями |
Field(...) |
обмеження: min_length, max_length, ge, le, default_factory |
field_validator / model_validator |
правило для поля / для всього об'єкта; mode="before" — до перевірки полів |
model_config |
str_strip_whitespace, frozen |
model_dump() / model_dump_json() |
модель → словник / JSON |
model_json_schema() |
опис моделі для інших програм — основа /docs у FastAPI |
TypeAdapter |
перевірка типів без моделі: list[RawNews] |
| Межа довіри | дані ззовні — «сирі»; перевіряй один раз на вході, далі — лише моделі |
Самоперевірка¶
- Чим відрізняється перевірка
mypyвід перевірки Pydantic? Чи можна обійтися чимось одним? - Що дає
TypedDictпорівняно зdict? - Чому
published_time: time | None, а неtime? - Навіщо
model_validator(mode="before")уNewsItem, а неmode="after"? - Чому
validate_newsповертає відхилені новини, а не просто пропускає їх? - Чим небезпечний розбір URL через
replace/split?
Відповіді
mypyперевіряє код до запуску; Pydantic — дані під час роботи. Код може бути бездоганно типізований, а дані з інтернету — будь-якими, і навпаки — тож потрібні обидва.- mypy знає ключі й типи значень: одруківка
"titel"— помилка до запуску, а неKeyErrorу роботі. - У 31 новини зі знімка часу немає.
time | Noneчесно це описує, і mypy змусить обробитиNone. source,lang,categoryне приходять з HTML — їх треба вивести з URL до перевірки полів, інакше модель не створиться без обов'язкових полів.- Щоб нічого не губилося мовчки: відхилене можна порахувати, показати й виправити парсер.
- Він працює лише для точного збігу рядка: піддомен,
http://замістьhttps://, параметри запиту — і результат тихо стає неправильним.urlsplitрозбирає URL за стандартом.
Що далі¶
- Ноутбук заняття: Відкрити вправи в Colab Переглянути розв’язки.
- Урок 38 — FastAPI над
NewsItem:GET /api/news,POST /api/scrape,/docsз JSON Schema, Postman. Основа — прототипnews_dashboard.
Документація і джерела¶
- Код:
news_hub;parse_rbc_newsі знімок — зі стартового ноутбука про web scraping, словник категорій — з прототипуnews_dashboard. - Python: typing, Type hints cheat sheet (mypy), urllib.parse.urlsplit
- Pydantic: Models, Fields, Validators, Computed fields, Type Adapter, JSON Schema
- FastAPI: Python Types Intro — навіщо FastAPI анотації