Съдържание на курса
Урок 5 — Моделът на данните
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 90 минути
Какво изграждаш: модела на revisor (Servicio, Estado, Resultado) и неговия пълен отчет, с таблица и обобщение
Какво научаваш: шаблони за данни с class и @dataclass(frozen=True); Enum; match и case, с джокер и с гардове (guards); отчета с реда на обобщението
Откъде идваш. Носиш проекта от Урок 4: два файла, formato.py и revisor.py, вече без паралелни списъци. В revisor.py SERVICIOS е списъкът с имена, които искаш да проверяваш, а SIMULADO е речник, който за всяко име пази кортеж (código, tiempo_ms, error). Работи и вече не се разминава. Но Урок 4 завърши с три отбелязани неудобства. Кортежът не казва какво е всяка позиция: вторият елемент е времето, защото ти го помниш, а не защото кодът го казва. clasificar връща текст ("ARRIBA", "LENTO", "CAIDO") и нищо не пречи да се напише "ARIBA", без Python да протестира. И таблицата още няма реда на обобщението.
Какво не прави този урок. Не чете конфигурацията от файл (четирите услуги продължават да са написани на ръка; Урок 6 ги изважда оттам), не прихваща грешки с try и не дефинира собствени методи в класовете. Нито пита някой интернет адрес: кодовете и времената продължават да идват от SIMULADO.
Работиш в папката си ~/curso-python/revisor/, с активна виртуална среда. Малките програми са, както в Урок 4, в подпапката practica/.
След урока ще можеш да
- Декларираш
ServicioиResultadoкато@dataclass(frozen=True)иEstadoкатоEnumи да обясниш какво се печели в сравнение с кортеж, речник или отделен текст. - Построиш инстанция с полетата по позиция или по име, да ги четеш с точка и да направиш друга с
replace, вместо да променяш тази, която имаш. - Класифицираш услуга с
matchиcase, с джокер и с гардове (case c if c >= 400), без да паднеш в клопката с името, което улавя (captures). - Прочетеш
FrozenInstanceError,ValueErrorнаdataclassсъс списък като стойност по подразбиране и този наEnum, от който се иска стойност, която няма. - Построиш
revisorот четири файла и да възпроизведеш неговата таблица с обобщението4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms.
Защо, преди как
Виж как днес revisor говори за нещата от своята задача. Една услуга е отделно име вътре в списък. Това, което е отговорила, е анонимен кортеж, (200, 2300, None). А нейното състояние е текст, "LENTO". И трите форми работят и трите имат един и същ недостатък: Python не знае какво значат. Не знае, че вторият елемент на кортежа е време, нито че "LENTO" е една от само три възможни стойности. За Python са кортеж и текст като всички други и затова печатна грешка (да напишеш "ARIBA" или да поискаш respuesta[2], като искаш времето) не се проваля там, където се случва: промъква се през програмата и произвежда грешна таблица без нито едно съобщение.
Решението е да дадеш на всяко нещо от задачата собствено име, което Python разпознава. Една услуга вече няма да е „текст в списък“, а Servicio, с полета, наречени nombre, url и timeout_ms; едно състояние вече няма да е текст, който може да се напише погрешно, а Estado.LENTO, което Python разпознава или отхвърля; а това, което е излязло от проверката на една услуга, ще е Resultado. Тази форма, която даваме в програмата на онова, с което се занимава задачата, я наричаме тук моделът на данните. (Внимание, не е същото като раздела „Data model“ в документацията на Python, който говори за това как работи езикът отвътре; тук „модел“ е нашият.)
Пътят има три инструмента и едно сглобяване. dataclass (5.1) произвежда шаблони за данни; Enum (5.2) затваря списъка с позволени стойности; match (5.3) решава между много случаи ясно; а в 5.4 се събират в проекта, който печели и своя ред на обобщението. В края на урока revisor ще прави това:
$ cd fig05_07
$ python revisor.py
SERVICIO ESTADO TIEMPO DETALLE
catalogo ARRIBA 142ms 200
inventario LENTO 2.3s 200
pagos ARRIBA 87ms 200
reportes CAIDO — sin conexión
4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms
Това е таблицата, каквато винаги, сега с празен ред и обобщение и произведена от обекти с име вместо от кортежи и отделни текстове. Обърни внимание, че заглавието и четирите реда излизат идентични с тези от уроци 3 и 4, а обобщението е новото: за да се преброи колко работят, колко са бавни и колко паднали и да се извади средното, трябват изразите за построяване от Урок 4, сега приложени върху резултати, които вече знаят какво са.
Понятията
5.1 Шаблон за всяка услуга: class и @dataclass
5.1.1 Защо кортежът и речникът не стигат
С това, което вече знаеш, една услуга би могла да е кортеж, ("catalogo", "https://catalogo.example", 5000), или речник, {"nombre": "catalogo", "url": "https://catalogo.example", "timeout_ms": 5000}. Кортежът, както видя в Урок 4, не казва какво е всяка позиция. Речникът го казва, но има друг проблем: ключовете са текстове, които пишеш по памет, и печатната грешка не си личи там, където се случва.
servicio = {"nombre": "catalogo", "url": "https://catalogo.example", "timeout_ms": 5000}
print(servicio.get("nmbre"))
None
Да напишеш "nmbre" вместо "nombre" не счупи нищо: .get върна None и този None ще се промъква през програмата, докато нещо се провали три функции по-нататък, далече от причината. Това, което искаме, е тази печатна грешка да се провали на самото място, със съобщение, което я казва. Това го дава собствен тип.
5.1.2 Класът като шаблон
Класът е шаблон за правене на стойности, които имат всички една и съща форма. Всяка стойност, направена с шаблона, се нарича инстанция. Помисли за формичката за сладки: формата е една, а сладките са много. В revisor шаблонът е Servicio, а catalogo, inventario и pagos са три инстанции. Тук ще използваш класовете само като шаблони за данни: без методи, без наследяване (с едно изключение в синтаксиса, което ще видиш в 5.2). Думата class, следвана от име и двоеточие, отваря шаблона, а под нея, с отстъп, се декларира по едно поле на ред, във формата nombre: tipo.
Python има инструмент, модулът dataclasses, който произвежда почти всичко, от което се нуждае един шаблон за данни: начина за построяване на всяка инстанция, този за показването ѝ при отпечатване и този за сравняването ѝ с ==. Включва се с един ред точно над класа, който започва с @ и се нарича декоратор: това е знак, който иска от Python да обработи класа с този инструмент, преди да го предаде. Документацията на модула го описва така: декораторът добавя генерираните методи към класа, който декорира. Тук се използва като @dataclass(frozen=True), където frozen=True (замразен) иска освен това инстанциите да не могат да се променят, като кортеж. Запиши това като fig05_01.py:
# fig05_01.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Servicio:
nombre: str
url: str
timeout_ms: int = 5000
catalogo = Servicio("catalogo", "https://catalogo.example")
notificaciones = Servicio(
nombre="notificaciones",
url="https://notificaciones.example",
timeout_ms=1000,
)
print(catalogo)
print(notificaciones.timeout_ms)
print(catalogo.nombre, catalogo.timeout_ms)
igual = Servicio("catalogo", "https://catalogo.example")
print(catalogo == igual, catalogo is igual)
raro = Servicio(nombre=5, url=None)
print(raro)
$ python fig05_01.py
Servicio(nombre='catalogo', url='https://catalogo.example', timeout_ms=5000)
1000
catalogo 5000
True False
Servicio(nombre=5, url=None, timeout_ms=5000)
from dataclasses import dataclassвнася от модулаdataclassesсамо инструмента, който ти трябва, с форматаfrom ... importот Урок 3.- Двата празни реда преди и след класа са конвенция за стил (PEP 8), не синтаксис; форматиращият инструмент, който ще видиш в Урок 8, ги изисква.
nombre: strиurl: strдекларират две текстови полета.timeout_ms: int = 5000декларира целочислено поле със стойност по подразбиране: ако при построяването не я дадеш, е 5000, времевият лимит наhttpx, койтоrevisorще използва по-нататък. Полетата със стойност по подразбиране са накрая, както параметрите със стойност по подразбиране на една функция.Servicio("catalogo", "https://catalogo.example")построява инстанция: шаблонът се вика като функция, с полетата в реда, в който са декларирани. Може да се даде и всяко поле по име, както вnotificaciones, което освен това сменя времевия лимит на 1000. По име се чете по-добре, когато са повече от две-три полета.print(catalogo)показваServicio(nombre='catalogo', url='https://catalogo.example', timeout_ms=5000): начинът за отпечатване също е генериран отdataclassи затова инстанция, която отпечатваш при дебъгване, се разбира сама. С кортеж без имена би видял('catalogo', 'https://catalogo.example', 5000).catalogo.timeout_msчете поле с точка. Ако напишеш името погрешно (catalogo.timeout), Python се проваля на самото място сAttributeError, което искахме.catalogo == igualеTrue, защото==сравнява поле по поле: това са две отделни инстанции със същите данни.catalogo is igualеFalse, защото са два различни обекта. Това е==срещуisот Урок 3, с истински случай:==пита „имат ли една и съща стойност?“,isпита „едно и също нещо ли са?“.- Последните два реда са, за да не се доверяваш прекалено.
Servicio(nombre=5, url=None)се построява, без да протестира, макарnombreда трябва да е текст иurlсъщо. Това е, което научи в Урок 0: анотациите не се налагат при изпълнение.dataclassизползва анотациите само за да знае какви полета има и в какъв ред: документацията уточнява, че, освен две изключения, които няма да използваш, нищо в декоратора не изследва анотирания тип. Който ги проверява, е отделен инструмент,mypy, в Урок 8.
5.1.3 Замразен: за да промениш, се създава друг
Какво прави frozen=True? Забранява присвояването към поле. Виж какво става, ако опиташ:
from dataclasses import dataclass
@dataclass(frozen=True)
class Servicio:
nombre: str
url: str
timeout_ms: int = 5000
catalogo = Servicio("catalogo", "https://catalogo.example")
catalogo.timeout_ms = 1000
dataclasses.FrozenInstanceError: cannot assign to field 'timeout_ms'
От този traceback се показва само последният ред, който е важният: по средата минава през ред, който dataclasses генерира отвътре и който не е еднакъв в 3.12 и в 3.14. Казва „не може да се присвоява на полето timeout_ms“. Както с кортежа, това е гаранция, а не повреда. Документацията го нарича емулация на инстанции само за четене.
Защо би искал това? Защото резултатът от проверката на една услуга е факт: „в 10:42 inventario се забави 2300 ms“. Никой не бива да може да го поправя после, без да остави следа. И защото, когато в уроци 9 и 10 няколко задачи четат едни и същи услуги едновременно, това, че никоя не може да ги променя, избягва цял клас грешки. А ако наистина ти трябва друга версия на една услуга, не я редактираш: правиш друга с dataclasses.replace, която копира инстанция, като променя само полетата, които ѝ посочиш:
# fig05_02.py
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Servicio:
nombre: str
url: str
timeout_ms: int = 5000
catalogo = Servicio("catalogo", "https://catalogo.example")
catalogo_corto = replace(catalogo, timeout_ms=1000)
print(catalogo.timeout_ms, catalogo_corto.timeout_ms)
repetido = Servicio("catalogo", "https://catalogo.example")
print(len({catalogo, repetido}))
$ python fig05_02.py
5000 1000
1
replace не пипна catalogo: върна catalogo_corto, нова инстанция. А последният ред показва друго предимство на неизменяемото: замразена инстанция може да се сложи в множество (или да е ключ на речник), защото вече не се променя: е hashable в смисъла на Урок 4 (4.1.4), стига всички нейни полета да са. Python изчислява нейния отпечатък от полетата, а тези на Servicio са текстове и цели числа. Две равни инстанции се броят за една: {catalogo, repetido} има един елемент.
Предупреждение, което се свързва с копие срещу препратка от Урок 4: frozen забранява повторното присвояване на полето, но не прави неизменяемо това, което полето съдържа. Поле, което пази списък, продължава да може да се променя отвътре (servicio.etiquetas.append(...)), защото списъкът продължава да е изменяем. И има втори ефект: такава инстанция вече не може да се сложи в множество, защото отпечатъкът се изчислява от всички полета, а списъкът няма отпечатък; опитът се проваля с TypeError: unhashable type: 'list' (в 3.14 текстът добавя cannot use ... as a set element). Затова в revisor полетата са от неизменяеми типове: текстове, цели числа, None.
5.2 Enum: затворен списък от стойности
Виж друг подобен проблем. clasificar от Урок 3 връща текста "ARRIBA", "LENTO" или "CAIDO", а който го използва, сравнява с текстове. Ако се напише един погрешно:
estado = "ARIBA"
if estado == "ARRIBA":
print("el servicio está arriba")
else:
print("el servicio NO está arriba")
el servicio NO está arriba
Програмата не се провали: каза, че услугата не работи, когато в действителност някой е написал едно R по-малко. Това е същата печатна грешка от 5.1.1, сега в стойност. Нужен е затворен списък от позволени стойности, с имена, които Python разпознава, и това е Enum (от „enumeration“, изброяване). Модулът enum го определя като набор от символични имена, свързани с уникални стойности.
# fig05_03.py
from enum import Enum
class Estado(Enum):
ARRIBA = "ARRIBA"
LENTO = "LENTO"
CAIDO = "CAIDO"
estado = Estado.LENTO
print(estado)
print(estado.name, estado.value)
print(Estado("CAIDO"))
print(estado is Estado.LENTO, estado == "LENTO")
for posible in Estado:
print(posible.value)
$ python fig05_03.py
Estado.LENTO
LENTO LENTO
Estado.CAIDO
True False
ARRIBA
LENTO
CAIDO
class Estado(Enum):отваря шаблон, кактоServicio, но с(Enum)между скобите: така се казва на Python „този шаблон се основава наEnum“, тоест е изброим тип. Скобите след името значат „този клас тръгва от онзи друг“. В кода, който пишеш в този курс, ще ги видиш само сEnumи, в Урок 6, сException(лабораторията в Урок 9 също ги използва, но тази програма я пускаш, без да я пишеш); за това, което ти трябва, стига да го четеш като „клас от семейството наEnum“.- Вътре има по един ред на член:
ARRIBA = "ARRIBA"дава името на члена (вляво, винаги с главни букви по конвенция) и неговата стойност (вдясно). Тук стойността е същият текст, който се отпечатва в таблицата, без ударение и с главни букви, което е и начинът, по който ще го пази JSON-ът в Урок 6. Estado.LENTOе член.printго показва с името на класа отпред:Estado.LENTO..nameе неговото име (LENTO) и.valueнеговата стойност (LENTO). Когато отчетът иска думата за таблицата, използва.value.Estado("CAIDO")върви в обратна посока: търси члена, чиято стойност е този текст. Ако няма такъв, се проваля веднага (ще го видиш след малко).estado is Estado.LENTOеTrue: членовете се сравняват по идентичност и всеки член съществува само веднъж. Аestado == "LENTO"еFalse: членътEstado.LENTOи текстът"LENTO"са различни неща, макар да се отпечатват почти еднакво. Това изненадва почти всички първия път. Сравнява се винаги с членове (estado == Estado.LENTO), никога с текста.for posible in Estado:обхожда членовете в реда, в който са декларирани.
Сега печатната грешка, в двата ѝ варианта. Да напишеш Estado.ARIBA се проваля мигновено, на точното място на грешката:
from enum import Enum
class Estado(Enum):
ARRIBA = "ARRIBA"
LENTO = "LENTO"
CAIDO = "CAIDO"
print(Estado.ARIBA)
Последният ред на traceback е:
AttributeError: type object 'Estado' has no attribute 'ARIBA'. Did you mean: 'ARRIBA'?
Python дори отгатва какво си искал да напишеш. Важното е, че грешката се случва на реда на грешката, а не три функции по-късно. И търсене по стойност на текст, който не съществува:
from enum import Enum
class Estado(Enum):
ARRIBA = "ARRIBA"
LENTO = "LENTO"
CAIDO = "CAIDO"
Estado("ROTO")
ValueError: 'ROTO' is not a valid Estado
ValueError: 'ROTO' is not a valid Estado. Двете грешки казват едно и също с различни думи: „този член не съществува“. Забележи, че са точно онзи вид повреда, който искахме: рано, ясно и с името на виновника.
5.3 match и case: решаване между много случаи
Урок 2 те научи на if/elif/else. Когато клоновете са много и всички питат за същата стойност, дълга верига от elif става трудна за четене и за това Python носи, от версия 3.10, инструкцията match. Много е мощна (официалната документация ѝ посвещава цял раздел, а PEP 636 е учебно ръководство), но тук ще използваш само най-простата ѝ част. Виж първо функция, която описва код на състояние и която още не е тази на revisor:
# fig05_04.py
def describir(codigo: int | None) -> str:
match codigo:
case None:
return "sin respuesta"
case 200:
return "todo bien"
case 301 | 302:
return "redirección"
case 404:
return "no existe"
case c if c >= 500:
return f"falla del servidor ({c})"
case c if c >= 400:
return f"error del cliente ({c})"
case _:
return "otro"
for codigo in [None, 200, 302, 404, 418, 503, 100]:
print(codigo, "->", describir(codigo))
$ python fig05_04.py
None -> sin respuesta
200 -> todo bien
302 -> redirección
404 -> no existe
418 -> error del cliente (418)
503 -> falla del servidor (503)
100 -> otro
Как се чете:
match codigo:казва „ще сравнявам тази стойност“ (субектът). Всекиcaseотдолу е шаблон, с който Python я изпробва, отгоре надолу, и се задържа на първия, който пасне: изпълнява неговия блок и не поглежда другите. Затова редът има значение.case None,case 200,case 404са литерални шаблони: пасват, ако субектът е това. Първият пита заNoneпо идентичност, както те научиха в Урок 2, а останалите по равенство.case 301 | 302е единият или другият: вертикалната черта значи „или“. Пасва с който и да е от двата кода.case c if c >= 500е този, който заслужава внимание. Първоcе шаблон за улавяне (capture): отделно име пасва с всичко и, между другото, запазва субекта в тази променлива, така че вътре в блокаcе кодът. Послеif c >= 500е гард (guard): допълнително условие, което трябва да е изпълнено, за да важи случаят. Ако не е изпълнено, Python продължава със следващияcase. Документацията го описва така: ако шаблонът не пасне, гардът дори не се оценява; ако пасне, гардът се оценява и случаят се избира само ако той е верен. Затова 418 минава покрай случая за 500 (по-малко е) и пада в следващия,c >= 400.case _:е джокерът: долната черта пасва с всичко и не запазва нищо. Стои накрая и събира това, което никой предишен случай не е уловил (тук, 100).
Пълната класификация на услугите на revisor са четири правила, които вече познаваш от Урок 2: без код е паднала; код 400 или повече е паднала; ако е отговорила и се е забавила 1000 ms или повече е бавна; иначе работи. Пише се със същия шаблон и връща членове на Estado вместо текстове. Това ще остане в modelo.py:
def clasificar(
codigo: int | None,
tiempo_ms: int | None,
umbral_ms: int = UMBRAL_LENTO_MS,
) -> Estado:
match codigo:
case None:
return Estado.CAIDO
case c if c >= 400:
return Estado.CAIDO
case _:
if tiempo_ms is not None and tiempo_ms >= umbral_ms:
return Estado.LENTO
return Estado.ARRIBA
Чети я отгоре надолу, както го прави Python. Първият case улавя „без код“, а вторият, със своя гард, улавя „код 400 или повече“: първите две правила, и двете дават CAIDO. Това, което стига до case _, вече знаем, че е отговорило добре (има код и е под 400), и там остава само да се реши между другите две правила с if от Урок 2: ако времето съществува и е поне прагът, LENTO; иначе, ARRIBA. Да провериш tiempo_ms is not None, преди да сравниш, е необходимо, защото код може да съществува без време, а сравнението None >= 1000 се проваля с TypeError. Забележи и че редът на случаите е логиката: ако сложиш случая за 400 преди този за None, None >= 400 би се провалило. В проекта от 5.4 функцията живее в modelo.py, до класовете, които дефинира в 5.1 и 5.2.
Сега най-известната клопка на match. В един шаблон отделно име не сравнява, а улавя. Ако искаш да сравниш с константа, не стига да напишеш нейното име:
# fig05_05.py
LIMITE = 400
def es_error(codigo: int) -> bool:
match codigo:
case LIMITE:
return True
case _:
return False
$ python fig05_05.py
File "fig05_05.py", line 7
case LIMITE:
^^^^^^
SyntaxError: name capture 'LIMITE' makes remaining patterns unreachable
Тази програма дори не стартира: Python я отхвърля при четенето ѝ, със SyntaxError. case LIMITE: не пита „кодът 400 ли е?“; казва „всичко пасва и го запази в променлива, наречена LIMITE“. Тъй като пасва с всичко, case _ отдолу никога не би бил достигнат и затова съобщението казва name capture 'LIMITE' makes remaining patterns unreachable („улавянето на името прави недостижими останалите шаблони“). Python предупреждава само защото след case LIMITE: има друг случай. Ако case LIMITE: беше последният, нямаше да предупреди за нищо: документацията допуска случай, който пасва винаги (нарича го неопровержим), стига да е накрая, и програмата би работила с джокер, преоблечен като константа, връщайки True за всеки код. Можеш да го провериш: с case LIMITE: накрая es_error(5) връща True. Практическото правило: за да сравниш с константа, се използва име с точка, като Estado.CAIDO; точката казва на Python, че е стойност, а не ново име, и ги нарича шаблони за стойност.
# fig05_06.py
from enum import Enum
class Estado(Enum):
ARRIBA = "ARRIBA"
LENTO = "LENTO"
CAIDO = "CAIDO"
def semaforo(estado: Estado) -> str:
match estado:
case Estado.ARRIBA:
return "verde"
case Estado.LENTO:
return "amarillo"
case Estado.CAIDO:
return "rojo"
for estado in Estado:
print(estado.value, "->", semaforo(estado))
$ python fig05_06.py
ARRIBA -> verde
LENTO -> amarillo
CAIDO -> rojo
Тук Estado.ARRIBA носи точка, така че сравнява. Това е най-естественото използване на match заедно с Enum: един случай на член, без джокер. И носи предимство, което mypy използва в Урок 8: ако един ден добавиш нов член към Estado и забравиш неговия case, средството за проверка на типове го посочва.
5.4 Сглобяване на revisor
Събери всичко в проекта. Накрая ще имаш четири файла в ~/curso-python/revisor/: modelo.py (нов), revision.py (нов), formato.py (променя се) и revisor.py (променя се). Всеки файл има причина да съществува: modelo.py казва какви неща има (услугата, състоянието, резултатът) и как се класифицират; revision.py проверява една услуга; formato.py дава текстова форма на един резултат, а revisor.py е входната точка, която ги събира.
Първият е моделът:
# fig05_07/modelo.py
from dataclasses import dataclass
from enum import Enum
UMBRAL_LENTO_MS = 1000
class Estado(Enum):
ARRIBA = "ARRIBA"
LENTO = "LENTO"
CAIDO = "CAIDO"
@dataclass(frozen=True)
class Servicio:
nombre: str
url: str
timeout_ms: int = 5000
@dataclass(frozen=True)
class Resultado:
servicio: Servicio
estado: Estado
tiempo_ms: int | None
detalle: str
def clasificar(
codigo: int | None,
tiempo_ms: int | None,
umbral_ms: int = UMBRAL_LENTO_MS,
) -> Estado:
match codigo:
case None:
return Estado.CAIDO
case c if c >= 400:
return Estado.CAIDO
case _:
if tiempo_ms is not None and tiempo_ms >= umbral_ms:
return Estado.LENTO
return Estado.ARRIBA
def nombres_repetidos(servicios: list[Servicio]) -> set[str]:
vistos: set[str] = set()
repetidos: set[str] = set()
for servicio in servicios:
if servicio.nombre in vistos:
repetidos.add(servicio.nombre)
vistos.add(servicio.nombre)
return repetidos
# fig05_07/revision.py
from modelo import Resultado, Servicio, clasificar
SIMULADO: dict[str, tuple[int | None, int | None, str | None]] = {
"catalogo": (200, 142, None),
"inventario": (200, 2300, None),
"pagos": (200, 87, None),
"reportes": (None, None, "sin conexión"),
}
def revisar(servicio: Servicio) -> Resultado:
sin_respuesta = (None, None, "sin conexión")
codigo, tiempo_ms, error = SIMULADO.get(servicio.nombre, sin_respuesta)
if codigo is not None:
detalle = str(codigo)
elif error is not None:
detalle = error
else:
detalle = "sin conexión"
estado = clasificar(codigo, tiempo_ms)
return Resultado(servicio, estado, tiempo_ms, detalle)
def revisar_todos(servicios: list[Servicio]) -> list[Resultado]:
return [revisar(servicio) for servicio in servicios]
# fig05_07/formato.py
from modelo import Estado, Resultado
def formatear_tiempo(tiempo_ms: int | None) -> str:
if tiempo_ms is None:
return "—"
if tiempo_ms < 1000:
return f"{tiempo_ms}ms"
return f"{tiempo_ms / 1000:.1f}s"
def encabezado() -> str:
return f"{'SERVICIO':<12}{'ESTADO':<10}{'TIEMPO':>6} {'DETALLE'}"
def linea(resultado: Resultado) -> str:
nombre = resultado.servicio.nombre
estado = resultado.estado.value
tiempo = formatear_tiempo(resultado.tiempo_ms)
return f"{nombre:<12}{estado:<10}{tiempo:>6} {resultado.detalle}"
def contar(cantidad: int, palabra: str) -> str:
if cantidad == 1:
return f"{cantidad} {palabra}"
return f"{cantidad} {palabra}s"
def resumen(resultados: list[Resultado]) -> str:
total = len(resultados)
arriba = len([r for r in resultados if r.estado == Estado.ARRIBA])
lentos = len([r for r in resultados if r.estado == Estado.LENTO])
caidos = len([r for r in resultados if r.estado == Estado.CAIDO])
tiempos_ms = [r.tiempo_ms for r in resultados if r.tiempo_ms is not None]
promedio_ms: int | None = None
if len(tiempos_ms) > 0:
promedio_ms = round(sum(tiempos_ms) / len(tiempos_ms))
partes = [
contar(total, "servicio"),
f"{arriba} arriba",
contar(lentos, "lento"),
contar(caidos, "caído"),
f"promedio {formatear_tiempo(promedio_ms)}",
]
return " · ".join(partes)
# fig05_07/revisor.py
from formato import encabezado, linea, resumen
from modelo import Servicio
from revision import revisar_todos
SERVICIOS: list[Servicio] = [
Servicio("catalogo", "https://catalogo.example"),
Servicio("inventario", "https://inventario.example"),
Servicio("pagos", "https://pagos.example"),
Servicio("reportes", "https://reportes.example"),
]
def main() -> None:
resultados = revisar_todos(SERVICIOS)
print(encabezado())
for resultado in resultados:
print(linea(resultado))
print()
print(resumen(resultados))
if __name__ == "__main__":
main()
Четирите части, една по една.
modelo.py. Събира всичко от разделите 5.1 до 5.3. Константите и класовете са отгоре, защото clasificar ги използва. Resultado е втори шаблон, който вече не пази отделни текстове: пази една Servicio (тази, която е проверена), едно Estado, времето (int | None, защото паднала услуга няма) и детайла. Забележи композицията: един шаблон може да има полета, чийто тип е друг шаблон. clasificar е тази, която написа в 5.3, само че списъкът с параметри е разпределен на редове, защото редът с def не се побира в 88-те знака, които изисква стилът (запетаята накрая на списъка с параметри е част от този начин за подреждане). А nombres_repetidos е тази от Урок 4 (4.1.5), само че сега получава списък от Servicio и гледа полето servicio.nombre.
revision.py. Нов модул е, но съдържанието му не е ново: SIMULADO е речникът от Урок 4, който напуска revisor.py и се мести тук, със същия кортеж от три (código, tiempo_ms, error) за всяко име. revisar отново сменя сигнатурата си: сега получава Servicio и връща Resultado, вместо да получава име и да връща вече съставен ред. Вътре е това, което вече познаваш: търси с .get (име, което го няма в SIMULADO, е услуга без данни и излиза паднала „sin conexión“), разопакова кортежа в три имена, решава detalle със същото правило (кодът като текст, ако е имало отговор, а иначе текстът на грешката), класифицира и връща Resultado. Това, че revisar вече не съставя реда, е разделението, което търсехме: да проверяваш е едно, да показваш е друго, и всяко живее в своя модул. revisar_todos е израз за построяване: списък от Resultado, по един на услуга, в същия ред. Когато в Урок 9 програмата пита истински, SIMULADO изчезва, а revisar продължава да връща същото.
formato.py. formatear_tiempo и encabezado остават както в уроци 3 и 4. (Ако си ги написал по друг начин, но изходът е бил същият, остави ги както са.) Това, което се променя, е linea, която сменя сигнатурата си: преди получаваше четири отделни стойности, а сега получава един-единствен Resultado и вади от него каквото ѝ трябва. Това е, което търсехме: вече не трябва да подаваш четири неща в правилния ред. Двете нови функции са contar и resumen. contar добавя „s“, когато количеството не е едно: contar(1, "caído") дава 1 caído, а contar(3, "caído") дава 3 caídos. resumen брои общото с len, използва три израза за построяване, за да преброи колко има от всяко състояние, с четвърти вади времената на отговорилите, изчислява средното само ако има някое (с len(tiempos_ms) > 0, защото деленето на нула би се провалило) и събира частите с " · ".join(partes). Анотацията promedio_ms: int | None = None казва, че започва без стойност и може да се окаже цяло число; ако никоя услуга не е отговорила, обобщението казва promedio —.
revisor.py. SERVICIOS престава да бъде списък от имена и става списък от Servicio: четирите канонични, написани на ръка, всяка със своето име и своя адрес (времевият лимит остава в стойността по подразбиране). За да добавиш услуга, пишеш Servicio(...) и, докато данните са симулирани, нейния ред в SIMULADO, както в Урок 4. main проверява всички, отпечатва заглавието, ред за всеки резултат, празен ред (този, който print() без аргументи пише) и обобщението. from ... import работят, защото четирите файла са в същата папка, както в Урок 3.
Пусни проекта от неговата папка:
$ cd fig05_07
$ python revisor.py
SERVICIO ESTADO TIEMPO DETALLE
catalogo ARRIBA 142ms 200
inventario LENTO 2.3s 200
pagos ARRIBA 87ms 200
reportes CAIDO — sin conexión
4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms
Това е таблицата от уроци 3 и 4, с празен ред и обобщение. Ако колоните не ти излизат подравнени, виновникът почти винаги е излишен или липсващ интервал в една от f-strings; провери дали са идентични с тези от текста. А ако обобщението брои погрешно, сравни всеки израз за построяване с този от урока: трите, които броят състояния, сменят само члена на Estado.
С това revisor вече има свой модел: Servicio, която се проверява, Estado, в което остава, и Resultado, който свързва двете неща с измереното. Но има нещо, което продължава да е неудобно, и ще го усетиш в упражнение 1: списъкът с услуги е вътре в кода. За да провериш друга услуга, трябва да отвориш revisor.py и да редактираш. Урок 6 го изважда в конфигурационен файл и с него идват грешките, които заслужават собствен урок.
Грешката, която ще видиш
В предишните раздели вече предизвика няколко грешки на мястото, където се раждат (FrozenInstanceError, AttributeError, ValueError, SyntaxError). Ето онази, която най-често ще срещаш, като пишеш шаблони с dataclass, а накрая една таблица, за да ги разпознаеш всичките с един поглед.
ValueError: mutable default <class 'list'> for field ... is not allowed
Това е същият проблем с изменяемия аргумент по подразбиране от Урок 3, сега в поле на dataclass. Ако му сложиш списък като стойност по подразбиране:
from dataclasses import dataclass
@dataclass(frozen=True)
class Servicio:
nombre: str
etiquetas: list[str] = []
ValueError: mutable default <class 'list'> for field etiquetas is not allowed: use default_factory
Последният ред казва: „изменяема стойност по подразбиране (list) за полето etiquetas не е позволена: използвай default_factory“. (Тук също се показва само последният ред: между него и твоя файл traceback минава през вътрешни редове на dataclasses.py, които се променят между версиите на Python и не ти помагат да прочетеш грешката. Важното е типът, ValueError, и съобщението, което освен това ти казва как да го поправиш.) Python те пази от нещо, което в обикновена функция ставаше мълчаливо: един-единствен списък, споделен от всички инстанции. Решението е фабрика: вместо „този списък“ казваш „викай list всеки път, когато трябва нов“.
# fig05_08.py
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Servicio:
nombre: str
etiquetas: list[str] = field(default_factory=list)
catalogo = Servicio("catalogo")
pagos = Servicio("pagos")
catalogo.etiquetas.append("critico")
print(catalogo.etiquetas, pagos.etiquetas)
$ python fig05_08.py
['critico'] []
Всяка инстанция има собствен списък и затова pagos не разбра за етикета на catalogo. Между другото се вижда това, за което беше предупредено в 5.1.3: инстанцията е замразена и въпреки това catalogo.etiquetas.append(...) проработи, защото frozen пречи на повторното присвояване на полето, не на промяната на списъка, който съдържа. Затова в revisor не се използват списъци като полета.
Грешките на този урок с един поглед
Когато ти се появи една, потърси тук последния ред. Всички излязоха от изпълненията на този урок.
| Последният ред казва... | Какво е станало | Къде го видя |
|---|---|---|
dataclasses.FrozenInstanceError: cannot assign to field '...' |
опитал си да присвоиш на поле на замразена инстанция | 5.1.3 |
ValueError: mutable default ... use default_factory |
сложил си списък като стойност по подразбиране на поле | горе |
AttributeError: type object 'Estado' has no attribute 'ARIBA'. Did you mean: 'ARRIBA'? |
поискал си член на Enum, който не съществува |
5.2 |
ValueError: 'ROTO' is not a valid Estado |
потърсил си по стойност член, който не съществува | 5.2 |
SyntaxError: name capture 'LIMITE' makes remaining patterns unreachable |
написал си отделно име, където си искал да сравниш | 5.3 |
Какво се прави погрешно
Всяка от тези небрежности има конкретна цена и повечето не дават грешка от първия път.
Да пазиш запис в речник или в кортеж, когато вече имаш шаблон. Кортеж от шест елемента те принуждава да помниш кой е кой; речник с текстови ключове превръща печатните грешки в далечни None. Ако данните имат фиксирана форма, тази форма заслужава име: @dataclass.
Да вярваш, че dataclass валидира полетата си. Servicio(nombre=5, url=None) се построява, без да протестира (5.1.2): анотациите казват какво очакваш, но не го проверяват при изпълнение. Цената е лоша данна, която пътува през цялата програма, докато се провали далече. Това, което идва отвън, се валидира при влизането и това е Урок 6.
Да оставиш отделни текстове там, където вече има Enum. Това е най-коварното. Ако промениш clasificar, за да връща Estado.LENTO, но на друго място в програмата остане if estado == "LENTO", това сравнение никога няма да е вярно (5.2: един член не е равен на своя текст) и програмата ще продължи да работи, без да предупреди. Когато мигрираш от текстове към Enum, намери всички места, които сравняват.
Да напишеш case NOMBRE:, искайки да сравниш. Улавя, не сравнява. Ако искаш да сравниш с константа, използвай име с точка (Estado.CAIDO) или гард (case c if c == LIMITE).
Да слагаш изменяема колекция като поле на замразен шаблон. frozen пази полето, не това, което полето съдържа: списък, запазен в замразена инстанция, може да продължи да се променя отвътре (fig05_08.py). Цената е гаранция, която изглежда, че съществува, и не съществува. В revisor полетата са текстове, цели числа, None и други замразени шаблони.
Да построяваш инстанции по позиция, когато полетата са много. Servicio("notificaciones", "https://notificaciones.example", 1000) се чете; с шест полета никой не знае кое е четвъртото. С повече от две-три, по име: Servicio(nombre=..., url=..., timeout_ms=...).
Упражнения
Опитай всяко, преди да погледнеш решенията.
Упражнение 1 — Още две услуги и средно само на тези, които работят
(а) Добави двете допълнителни услуги на revisor към версията от 5.4: facturacion (отговаря с код 503 за 95 ms) и notificaciones (с времеви лимит 1000 ms, не получава отговор: „tiempo agotado“). Трябва да пипнеш две места. Преди да пуснеш, предвиди последните два реда на изхода: този на реда на notificaciones и обобщението. (б) Добави накрая на main ред, който отпечатва средното на времената само на услугите ARRIBA, без закръгляне. Удобно е да го направиш с израз за построяване.
Упражнение 2 — Предизвикай четири грешки и ги прочети
Отвори REPL с python от папката на revisor, внеси от modelo каквото ти трябва и предизвикай тези четири грешки, една по една: (а) присвояване на timeout_ms на инстанция на Servicio; (б) Estado("ROTO"); (в) Estado.ARIBA; (г) във файл в папката ти practica/, dataclass с поле etiquetas: list[str] = []. За всяка запиши в дневника си последния ред и, със свои думи, какво значи и как се поправя.
Упражнение 3 — Код без време
Какво връща clasificar(200, None), тоест услуга, която е отговорила с 200, но за която няма измерено време? Предвиди го два пъти: с clasificar от Урок 3 (тази, която връща текстове) и с днешната (тази с match). После го провери от папката на revisor, с from modelo import clasificar. Защо правилото дава този резултат, а не LENTO? И накрая: какво би станало, ако махнеш от условието частта tiempo_ms is not None?
Решения
Решение 1
(а) В revision.py SIMULADO печели два реда:
"reportes": (None, None, "sin conexión"),
"facturacion": (503, 95, None),
"notificaciones": (None, None, "tiempo agotado"),
}
А в revisor.py SERVICIOS печели две услуги:
Servicio("reportes", "https://reportes.example"),
Servicio("facturacion", "https://facturacion.example"),
Servicio("notificaciones", "https://notificaciones.example", timeout_ms=1000),
]
Пълният изход:
SERVICIO ESTADO TIEMPO DETALLE
catalogo ARRIBA 142ms 200
inventario LENTO 2.3s 200
pagos ARRIBA 87ms 200
reportes CAIDO — sin conexión
facturacion CAIDO 95ms 503
notificacionesCAIDO — tiempo agotado
6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms
Обобщението е 6 servicios · 2 arriba · 1 lento · 3 caídos, а средното излиза 656 ms, защото го изчисляват четирите, които са имали време (142, 2300, 87 и 95, които правят 2624, разделени на 4). Забележи, че facturacion се брои в средното, макар да е паднала: отговорила е, с 503 и 95 ms. notificaciones няма време и не се брои. Забележи и реда на notificaciones: това е същото преливане, което видя в Урок 4, с името, залепено за състоянието, защото има 14 букви, а колоната е 12; Урок 11 го решава, като изчислява ширината от най-дългото име. И третото наблюдение е това, което дава повод за следващия урок: за да провериш още две услуги, трябваше да редактираш кода, в два файла.
(б) Внася се Estado и се добавя изразът за построяване накрая на main:
from modelo import Estado, Servicio
print(resumen(resultados))
tiempos_arriba = [
r.tiempo_ms
for r in resultados
if r.estado == Estado.ARRIBA and r.tiempo_ms is not None
]
print(sum(tiempos_arriba) / len(tiempos_arriba))
Последният ред на изхода е:
114.5
(142 и 87 правят 229, разделени на 2 са 114.5.) Условието r.tiempo_ms is not None не е украшение. Услуга ARRIBA може да няма време: clasificar(200, None) връща Estado.ARRIBA, защото без измерено време няма доказателство за бавност. Ако този None стигне до sum, програмата ще се провали с TypeError. А освен това tiempo_ms е анотирано като int | None и който чете кода (или mypy, в Урок 8), трябва да види, че None вече е отхвърлен, преди да се събира.
Решение 2
Последните четири реда, всеки измерен:
dataclasses.FrozenInstanceError: cannot assign to field 'timeout_ms'
FrozenInstanceError: инстанцията е замразена. Поправка: да не се присвоява; да се направи друга с replace(servicio, timeout_ms=...).
ValueError: 'ROTO' is not a valid Estado
ValueError: не съществува член на Estado със стойност ROTO. Поправка: да се използва една от трите позволени стойности или направо Estado.CAIDO, което дори не минава през текста.
AttributeError: type object 'Estado' has no attribute 'ARIBA'. Did you mean: 'ARRIBA'?
AttributeError: Estado няма член, наречен ARIBA, и Python подсказва този, който прилича. Поправка: този, който подсказва. Това е печатната грешка, която Enum дойде да улови, и я улавя на реда, където се случва.
ValueError: mutable default <class 'list'> for field etiquetas is not allowed: use default_factory
ValueError при дефинирането на класа, преди да е създадена никаква инстанция. Поправка: etiquetas: list[str] = field(default_factory=list), както във fig05_08.py.
Решение 3
И двете версии връщат ARRIBA: тази от Урок 3, текста "ARRIBA"; днешната, Estado.ARRIBA. От папката на revisor:
from modelo import clasificar
print(clasificar(200, None))
Estado.ARRIBA
Причината е в третото правило, което е същото от Урок 2: една услуга е LENTO, ако е отговорила и се е забавила 1000 ms или повече. С None няма измерено време, така че няма доказателство за бавност, а единственото, което се знае, е, че е отговорила с добър код: ARRIBA. Двете версии третират случая еднакво, защото и двете питат tiempo_ms is not None, преди да сравнят. В revisor този случай не се среща (услуга, която е отговорила, винаги носи своето време), но tiempo_ms е анотирано като int | None и функцията трябва да даде отговор за всичко, което нейната сигнатура допуска.
Ако махнеш tiempo_ms is not None, сравнението None >= 1000 би стигнало да се оцени и програмата щеше да спре с:
TypeError: '>=' not supported between instances of 'NoneType' and 'int'
Това е същата грешка от Урок 2, когато въпросите бяха в лош ред.
Как разбирам, че съм успял
- В
~/curso-python/revisor/python revisor.pyотпечатва седем реда (заглавието, четири реда, празен ред и обобщението), а последният е4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms. - С двете допълнителни услуги от упражнение 1 последният ред е
6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms, а редът наnotificacionesизлиза с името, залепено за състоянието (и знаеш защо). - В REPL присвояването на
timeout_msнаServicioсе проваля сdataclasses.FrozenInstanceError: cannot assign to field 'timeout_ms', аEstado("ROTO")се проваля сValueError: 'ROTO' is not a valid Estado. - Можеш да напишеш по памет
clasificarсmatch: без код еCAIDO, код 400 или повече еCAIDO, време 1000 или повече еLENTO, а останалото еARRIBA. - Можеш да обясниш със свои думи разликата между
==иisза две равниServicio, защоEstado.LENTO == "LENTO"еFalseи какво правиreplace. - Записа в дневника какво ти костваше най-много: това, което запишеш там, е онова, което трябва да повториш.
Обобщение
Отговори, без да гледаш урока:
- Какво е клас и какво е инстанция? Какво добавя
@dataclassкъм един клас и какво добавяfrozen=True? - Какво се печели с
@dataclass(frozen=True)в сравнение с речник или кортеж? Какво правиreplace? - Какво сравнява
==между двеServicioи какво сравняваis? - Защо
Estado.LENTO == "LENTO"даваFalseи как се получава текстът на един член? - В какъв ред изпробва
matchсвоите случаи и какво прави джокерътcase _? - В един
match, какво прави отделно име катоcase LIMITE:? Как се сравнява с константа? - Защо
lineaсмени сигнатурата си в този урок и какво спечели от промяната?
За допълнително четене
- Python, „dataclasses“ — пълният справочник на модула:
frozen,field,replaceи всичко, което този урок не използва. На английски, като почти цялата официална документация. Посетено на 7 октомври 2026 г. - PEP 557, „Data Classes“ — документът, който предложи
dataclassи обяснява за какви случаи е измислен и за какви не. Посетено на 7 октомври 2026 г. - Python, „Enum HOWTO“ — официалното ръководство за изброимите типове, от основите до производните типове. Посетено на 7 октомври 2026 г.
- PEP 636, „Structural Pattern Matching: Tutorial“ — официалното учебно ръководство за
match, който стига доста по-далече от този урок. Посетено на 7 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx