Съдържание на курса

Урок 6 — Грешки, файлове и данни отвън

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 2 × 90 минути (две сесии; прекъсването е между раздели 6.2 и 6.3)

Какво изграждаш: четенето на конфигурацията на revisor: списъкът с услуги вече не е написан в кода, а се чете от файл servicios.toml

Какво научаваш: изключения (try, except, else, finally, собствено изключение и raise ... from); как се чете traceback отдолу нагоре; файлове с with и pathlib; изрично зададен UTF-8; форматите TOML и JSON; валидиране на това, което идва отвън; и защо не се използват eval и pickle

Откъде идваш. Носиш проекта от Урок 5: един revisor със своя модел на данните (Servicio, Estado, Resultado), класифицирането с match, отчета в таблица с неговото обобщение и списъка SERVICIOS, написан на ръка в revisor.py. Работи, но упражнението за проекта от урок 5 ти остави една неприятност: за да добавиш услуга към отчета, трябва да отвориш кода на Python и да го редактираш. Този, който администрира услугите, няма причина да умее да програмира, а този, който програмира, няма причина да пипа кода всеки път, когато се смени някой адрес. Този урок разделя двете неща: данните отиват във файл, а програмата се научава да го чете и да не му се доверява. Работиш в папката си ~/curso-python/revisor/, с активирана виртуална среда, и изпълняваш всичко с python.

Какво не прави този урок. Не използва никаква мрежа: revisar продължава да работи със симулираните данни от урок 5 и това не се променя до Урок 9. Нито превръща revisor в команда с опции (--config, --formato): това е работа на Урок 7. Днес името на файла е записано в една константа на програмата. Това, което се решава тук, е как се чете конфигурационен файл, без програмата да се чупи по неразбираеми начини, когато файлът не съществува, е написан погрешно или казва неща, които нямат смисъл.

За изпълненията. За да изпълниш дадена фигура, влез в папката на урока в хранилището и стартирай python fig06_01.py; фигурите, които живеят в собствена папка (fig06_06/, fig06_07/, fig06_18/), се изпълняват отвътре. Всички изходи в този урок са получени с Python 3.14.8 и са повторени с Python 3.12.12, този, който идва с Linux Mint. Те са еднакви, с изключение на няколко подчертаващи знака (~~~~^^), които докладът за грешката съдържа в едната версия, а в другата не; ако на твоя екран виждаш повече или по-малко знаци, това е нормално. Програмите са в programas/06-errores-archivos-datos/, в хранилището на курса.

След урока ще можеш да

  • Четеш доклада за грешка (traceback) отдолу нагоре и да казваш какво се е объркало, къде и кой кого е извикал.
  • Пишеш try с except, else и finally, като прихващаш конкретното изключение, което умееш да обработиш, и оставяш другите да минат.
  • Дефинираш собствено изключение с един ред наследяване и да го предизвикваш с raise ... from, така че първоначалната грешка да не се загуби.
  • Отваряш и четеш файл с with и с pathlib.Path, винаги с изписано кодиране UTF-8, и да обясниш какво се случва, ако то не е изписано.
  • Четеш TOML файл с tomllib и да превръщаш данни в JSON и обратно с json, като четеш реда и колоната, когато JSON е погрешен.
  • Валидираш данни отвън с isinstance и urlsplit и да обясниш защо True минава през валидиране на цели числа, направено небрежно.
  • Обясниш защо текст, който идва отвън, никога не се подава на eval или на pickle.

Защо, преди как

Отвори своя revisor.py от урок 5 и погледни списъка SERVICIOS. Всеки път, когато екипът по експлоатация поиска да проверява нова услуга, някой трябва да редактира този файл, да го запази и да го изпълни отново. За програмиста това е неприятност; за всеки друг е стена. А има и по-дълбок проблем: този код и тези данни се променят по различни причини и в различен ритъм. Програмата се променя, когато откриеш грешка или поискаш нова функция; списъкът с услуги се променя, когато фирмата добави услуга. Смесването на двете неща в един файл принуждава едното да чака другото.

Решението е обичайното: данните живеят в конфигурационен файл, текст с правила, който всеки може да редактира, а програмата го чете при стартиране. Но щом програмата чете нещо, което е написал друг човек, тя навлиза в нова територия. До урок 5 всяка данна на revisor я беше написал ти, в същия файл като кода, и ако беше погрешна, го разбираше веднага. Един файл отвън може да се провали по четири начина и четирите са нормални, не изключителни:

  1. Не съществува или съществува в друга папка, а не в тази, която ти мислиш.
  2. Съществува, но е написан погрешно: липсва кавичка, има излишна запетая.
  3. Написан е правилно, но му липсва нещо: услуга без име, без адрес.
  4. Пълен е, но казва нещо абсурдно: адрес, който не е на уеб страница, отрицателен времеви лимит, две услуги с едно и също име.

Сериозната програма различава четирите и казва на човека кое от тях се е случило и как да го поправи, вместо да показва доклад за грешка от тридесет реда, написан за програмисти. Това се постига с два инструмента, които този урок преподава заедно, защото не се разбират поотделно: изключенията (как една програма съобщава, че нещо се е объркало, и как друга го прихваща) и валидирането (как се проверява, че това, което е пристигнало, има смисъл).

Пътят е в две сесии. Първата обхваща понятията 6.1 и 6.2: изключенията и файловете. Като я завършиш, ще умееш да четеш доклад за грешка и да отваряш текстов файл без изненади. Втората обхваща 6.3 и 6.4: форматите (TOML и JSON) и валидирането. Като я завършиш, ще имаш revisor, който чете списъка си с услуги от файл и се оплаква на ясен испански, когато файлът не става. Имайки предвид тази цел, ето какво ще видиш в края на урока, когато изпълниш revisor от друга папка, без да си минавал оттам, където е файлът:

error de configuración: no existe el archivo servicios.toml

Съобщение от един ред вместо доклад за грешка. За да стигнем дотам, първо трябва да разберем какво е изключение.

Понятията

6.1 Изключения

6.1.1 Какво е изключение и как се чете докладът му

Досега, когато нещо се е провалило в твоите програми, Python е спирал и е отпечатвал блок текст в червено, traceback (в този курс „докладът за грешката“). Прочете го за първи път в Урок 1 и го виждаш във всеки урок. Сега ще видим какво стои зад него: този доклад е известието за едно изключение, което е начинът на Python да каже „тук се случи нещо, което не знам как да реша“. Когато един ред от програмата не може да направи това, което му е поискано, той предизвиква изключение. Изключението се изкачва през функциите, които са били в изпълнение, от най-новата към най-старата, и търси някого, който знае какво да направи с него. Ако стигне догоре, без никой да го прихване, програмата спира и Python отпечатва доклада.

Ще предизвикаме едно нарочно, със случай, който наистина ще се случи, когато revisor чете файлове: стойност, която трябва да е число, а идва с нещо друго. За това ти трябва нова функция: int(texto) превръща текст в цяло число, също както str() (урок 2) превръща стойност в текст. int("142") дава числото 142. Но какво дава int("142 ms")? Изпълни го с fig06_01.py:

# fig06_01.py
def leer_tiempo(texto: str) -> int:
    return int(texto)


def main() -> None:
    tiempo_ms = leer_tiempo("142 ms")
    print(f"tiempo: {tiempo_ms}")


if __name__ == "__main__":
    main()

Програмата дефинира две функции, leer_tiempo и main, и main извиква leer_tiempo с текст, който има залепени буквите „ms“. Изпълни я:

$ python fig06_01.py
Traceback (most recent call last):
  File "fig06_01.py", line 12, in <module>
    main()
    ~~~~^^
  File "fig06_01.py", line 7, in main
    tiempo_ms = leer_tiempo("142 ms")
  File "fig06_01.py", line 3, in leer_tiempo
    return int(texto)
ValueError: invalid literal for int() with base 10: '142 ms'

Това е докладът и си струва да се научиш да го четеш добре, защото ще го четеш стотици пъти. Чете се отдолу нагоре. Последният ред е най-важният: ValueError: invalid literal for int() with base 10: '142 ms'. Той казва две неща: типа на изключението, ValueError („невалидна стойност“: типът на данните беше правилен, текст, но съдържанието му не върши работа за това, което се искаше), и съобщение, което обяснява какво се е случило. На български: „невалиден текст за превръщане в цяло число при основа 10: '142 ms'“. „Основа 10“ е бройната система, която използваме всеки ден; съобщението казва само, че int е очаквал число, написано с цифри от 0 до 9, и е открило след 142 интервал и буквите ms. (Интервалите в началото или в края не му пречат: int(" 142 ") дава 142; това, което не приема, е всичко, което не е част от числото.)

Редовете отгоре казват къде се е случило и са в обратен ред на този, в който са станали събитията. Всяка двойка редове, която започва с File, е кадър (frame): функция, която е била в изпълнение. Последният кадър (in leer_tiempo, ред 3) е мястото, където се е родило изключението: редът return int(texto). Кадърът над него (in main, ред 7) е този, който е извикал leer_tiempo. А първият (in <module>, ред 12) е самата програма, която е извикала main(). Чети доклада като купчина чинии: най-долната чиния е най-новата и там е започнал проблемът. Редовете с ~~~~^^ са помощ от по-новите версии на Python (PEP 657): подчертават точната част от реда, която се е провалила, за да не се налага да гадаеш кое от трите извиквания на един дълъг ред е било.

Докато няма кой да прихване изключението, това е цялата история: то се предизвиква, изкачва се по стека, програмата умира. Официалната документация за грешките и изключенията разказва същото с повече примери. Нека видим сега как да го прихванем.

6.1.2 try, except, else и finally

Да прихванеш изключение значи да кажеш на Python: „опитай това; ако се провали по този начин, направи онова вместо да умреш“. Пише се с инструкцията try (на английски „опитай“). Виж програмата fig06_02.py, която превръща два текста, един добър и един лош:

# fig06_02.py
def leer_tiempo(texto: str) -> int | None:
    try:
        tiempo_ms = int(texto)
    except ValueError:
        print(f"  '{texto}' no es un número entero")
        return None
    else:
        print(f"  '{texto}' se convirtió en {tiempo_ms}")
        return tiempo_ms
    finally:
        print("  (fin de la conversión)")


def main() -> None:
    for texto in ["142", "142 ms"]:
        print(f"leyendo '{texto}'")
        tiempo_ms = leer_tiempo(texto)
        print(f"  resultado: {tiempo_ms}")


if __name__ == "__main__":
    main()
$ python fig06_02.py
leyendo '142'
  '142' se convirtió en 142
  (fin de la conversión)
  resultado: 142
leyendo '142 ms'
  '142 ms' no es un número entero
  (fin de la conversión)
  resultado: None

Има четири блока и всеки има ясно правило:

  • try: съдържа само реда, който може да се провали, тук tiempo_ms = int(texto). Ако няма грешка, програмата продължава към блока else. Ако има, Python напуска try на същия този ред и скача към съответния except.
  • except ValueError: съдържа това, което се прави, ако грешката е от този тип. В случая: да се съобщи, че текстът не е число, и да се върне None (стойността, която в този курс значи „няма данни“). Между except и двоеточието се поставя типът на изключението, което се прихваща; всеки друг тип би минал покрай него.
  • else: (в този контекст „ако не е имало грешка“) се изпълнява само ако try е завършил без изключение. Тук съхранява това, което се прави, когато превръщането е успяло.
  • finally: („накрая“) се изпълнява винаги: независимо дали е имало грешка, или не, независимо дали си върнал стойност, или не. Използва се за почистване, например за затваряне на нещо, което е било отворено.

Обърни внимание на изхода, защото съдържа изненада, която е добре да видиш веднъж. С текста „142“ първо се отпечатва „se convirtió en 142“ (от else) и след това „(fin de la conversión)“ (от finally), въпреки че return tiempo_ms в else стои по-рано в кода. С „142 ms“ except отпечатва своето предупреждение, връща None и finally въпреки всичко се изпълнява, преди функцията да завърши. Тоест: finally се изпълнява дори когато блокът вече е решил да излезе с return. Тази гаранция е причината за неговото съществуване.

Остават два разумни въпроса: защо else, след като print и return биха могли да са вътре в try? Защото един try трябва да е възможно най-малък. Всичко, което поставиш вътре, е защитено от except и ако на този излишен ред стане грешка от същия тип, която не си очаквал, except ще я погълне и ти ще мислиш, че е била грешката, която очакваш. Правилото е: в try влиза това, което може да се провали по начина, който умееш да обработиш, и нищо повече. Това, което трябва да стане, ако всичко е минало добре, отива в else.

Вторият въпрос: могат ли да се прихващат няколко типа? Да, по два начина. Единият е да поставиш няколко блока except един след друг, всеки със своя тип и със своя отговор. Другият е да поставиш типовете заедно в скоби, разделени със запетаи: except (ValueError, TypeError):, когато отговорът е един и същ. Пиши винаги скобите: в Python 3.12 са задължителни, а макар че Python 3.14 позволява да се пропуснат, когато except няма as (PEP 758), кодът на курса работи и в двете версии.

6.1.3 Прихващай конкретното изключение, никога „което и да е“

Това е най-важното правило на раздела и се разбира най-добре, като се види какво става, когато се наруши. Виж fig06_03.py. Това е същата функция, но с две промени: except казва Exception вместо ValueError и някой е написал погрешно име вътре в try (stirp вместо strip, метода, който премахва интервалите в краищата на един текст):

# fig06_03.py
def leer_tiempo(texto: str) -> int | None:
    try:
        return int(texto.stirp())
    except Exception:  # noqa: BLE001
        print(f"'{texto}' no es un número entero")
        return None


def main() -> None:
    print(leer_tiempo("142"))


if __name__ == "__main__":
    main()
$ python fig06_03.py
'142' no es un número entero
None

Виж какво се случи: текстът „142“ е напълно валидно число, а програмата каза, че не е. Функцията няма нищо общо с числата: тя има печатна грешка. Без try Python би спрял програмата с AttributeError: 'str' object has no attribute 'stirp'. Did you mean: 'strip'? („текстът няма атрибут stirp; искаше ли да кажеш strip?“) и ти би поправил грешката за минута. С прекалено широк except това съобщение се превърна в лъжа, която излиза на екрана, а истинската грешка остава заровена, докато някой не попита защо нито едно време не се чете добре.

За да се разбере това, трябва да се знае, че изключенията образуват семейство. На върха е BaseException; под него Exception, от която висят почти всички грешки, които ще срещнеш; а оттам излизат конкретните типове, някои с роднини. ValueError е един от тях. KeyError и IndexError (които видя в урок 4) са деца на LookupError. FileNotFoundError е частен случай на OSError, изключението за проблемите с операционната система. UnicodeDecodeError, което ще видиш по-нататък, е вид ValueError. Пълното дърво е в списъка с изключенията в документацията. Прихващането на едно изключение прихваща и всичките му деца: затова except Exception прихваща почти всичко, включително AttributeError от печатната грешка, и затова except ValueError прихваща и грешките при четене на TOML и на JSON, както ще видиш в 6.3.

И има най-лошата версия: except: без тип, наричан „гол“. Той прихваща дори това, което е над Exception, включително KeyboardInterrupt, което Python предизвиква, когато натиснеш Ctrl+C, за да спреш една програма. Програма с „гол“ except: може да не умре, когато ѝ го поискаш. Ръководството за стил PEP 8 препоръчва винаги, когато е възможно, да се назовават конкретните изключения вместо да се използва, и да се пази за броени случаи; в този курс правилото е по-строго и няма изключения: никога.

Правилата, значи, са три. Първо: прихващай конкретния тип, който умееш да обработиш, с конкретния отговор, който има смисъл. Второ: ако не знаеш какво да правиш с една грешка, не я прихващай: остави я да се изкачи, нека програмата спре с доклада си и някой ще го види. Програма, която спира с ясен доклад, е по-добра от такава, която продължава да работи с погрешни данни. Трето: ако наистина трябва да прихванеш Exception (например в последната линия на защита на програма, която не бива да пада), направи го на едно-единствено място, запиши какво се е случило и не го оставяй да мине мълчаливо. Коментарът # noqa: BLE001, който виждаш във fig06_03.py, е знак за ruff, инструмента, който проверява стила на кода и който ще опознаеш в Урок 8: казва му „знам, че този ред прихваща прекалено широко изключение, нарочно е“. Правилото на ruff, което протестира, се нарича точно „прихващане на сляпо изключение“.

6.1.4 Собствено изключение и raise ... from

Досега прихващаше изключения, които Python вече носи. За revisor е нужно още едно, защото трябва да различаваш „конфигурационният файл има проблем“ от „моята програма има грешка“. Двете ситуации заслужават различни отговори: първата се обяснява на човека, който е написал файла; втората е дефект, който трябва да спре програмата с нейния доклад, за да го поправи програмист. Ако всичко се предизвикваше като ValueError, програмата не би могла да различи едното от другото.

Решението е изключение със собствено име, което се дефинира с един ред наследяване. Виж fig06_04.py:

# fig06_04.py
class ErrorDeConfiguracion(Exception):
    """La configuración no se puede usar."""


def leer_timeout_ms(texto: str) -> int:
    try:
        return int(texto)
    except ValueError as error:
        mensaje = f"'timeout_ms' debe ser un entero, llegó '{texto}'"
        raise ErrorDeConfiguracion(mensaje) from error


def main() -> None:
    try:
        timeout_ms = leer_timeout_ms("cinco mil")
    except ErrorDeConfiguracion as error:
        print(f"error de configuración: {error}")
        print(f"causa original: {error.__cause__}")
    else:
        print(f"timeout_ms = {timeout_ms}")


if __name__ == "__main__":
    main()
$ python fig06_04.py
error de configuración: 'timeout_ms' debe ser un entero, llegó 'cinco mil'
causa original: invalid literal for int() with base 10: 'cinco mil'

Първата инструкция е нова по форма, макар че вече видя class в урок 5 като „шаблон за данни“: class ErrorDeConfiguracion(Exception): дефинира нов шаблон, който се казва ErrorDeConfiguracion, а скобите казват, че той наследява от Exception. Да наследяваш значи „е Exception и освен това има собствено име“: всичко, което Python умее да прави с което и да е изключение (да го предизвика, да го прихване, да отпечата съобщението му, да покаже доклада му), умее да го прави и с твоето, без да пишеш нищо повече. Това е едно-единствено ниво на наследяване и е всичко, което ти трябва; курсът не навлиза по-навътре. Текстът в тройни кавички вътре в класа е низ за документация (docstring): едно изречение, което обяснява за какво служи и което Python съхранява за този, който иска да го прочете. Не е нужна никаква друга инструкция вътре в класа.

Функцията leer_timeout_ms прави нещо, което ще повтаряш в целия курс: прихваща грешка от ниско ниво (ValueError, от int) и я превежда в такава от домейна на програмата (ErrorDeConfiguracion), със съобщение, което говори на езика на този, който е писал конфигурацията: „'timeout_ms' debe ser un entero, llegó 'cinco mil'“ („'timeout_ms' трябва да е цяло число, пристигна 'cinco mil'“). Съобщението на int („invalid literal for int()…“) е полезно за програмиста, но не и за този, който редактира конфигурационен файл. Инструкцията raise (предизвиквам) хвърля изключението, а опашката from error прави нещо важно: запазва първоначалната грешка като причина. Тази причина остава запазена вътре в новото изключение, в error.__cause__ (двете долни черти преди и след са начинът, по който Python отбелязва имена, които резервира за себе си). Вторият ред на изхода го показва: програмата отпечата своето предупреждение на испански и, отделно, първоначалната причина на английски, за всеки случай.

Какво става, ако никой не прихване изключението? Виж fig06_05.py, което е същата програма без try в main:

$ python fig06_05.py
Traceback (most recent call last):
  File "fig06_05.py", line 8, in leer_timeout_ms
    return int(texto)
ValueError: invalid literal for int() with base 10: 'cinco mil'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "fig06_05.py", line 20, in <module>
    main()
    ~~~~^^
  File "fig06_05.py", line 15, in main
    timeout_ms = leer_timeout_ms("cinco mil")
  File "fig06_05.py", line 11, in leer_timeout_ms
    raise ErrorDeConfiguracion(mensaje) from error
ErrorDeConfiguracion: 'timeout_ms' debe ser un entero, llegó 'cinco mil'

Този доклад има две части, разделени с една фраза: „The above exception was the direct cause of the following exception“ („изключението отгоре беше пряката причина за следващото изключение“). Това е следата на from error. Чете се както преди, отдолу нагоре: последният ред, ErrorDeConfiguracion: 'timeout_ms' debe ser un entero, llegó 'cinco mil', е това, което е стигнало догоре. Но сега, освен това, Python запазва историята: отгоре е първоначалното ValueError, с точния му ред (return int(texto)). Ако не беше написал from error, докладът би казал вместо това „During handling of the above exception, another exception occurred“ („по време на обработката на изключението отгоре възникна друго“), което загатва за нова грешка, станала вътре в обработката на друга. Python пак би показал двете изключения (първоначалното остава запазено като контекст, в атрибута __context__), но докладът вече не би казал, че едното е следствие от другото: би казал, че второто е станало „междувременно“, сякаш е отделен инцидент. С from първоначалното остава записано като причина (__cause__), което е това, което наистина се е случило. Правилото: когато превеждаш едно изключение, винаги пиши from. Този, който дебъгва програмата ти, ще ти бъде благодарен. Обяснението на техниката е в PEP 3134 и в раздела за верижното свързване на изключения на учебното ръководство.

С това имаш речника на изключенията. Липсва място, където да го използваш, и това е следващото: четенето на файлове, където нещата се провалят по причини, които не контролираш.

6.2 Файлове

6.2.1 Отваряне, четене, затваряне: with

Един файл на диска не се „чете“ просто така: първо се отваря, което иска от операционната система достъп до него; чете се съдържанието му; и накрая се затваря, което връща достъпа. Файл, който се отвори и не се затвори, изразходва ресурси на системата (всяка програма може да има отворени само ограничен брой) и, ако си писал, може да остави последната част незапазена. Най-грижливият начин да се напише, със знанията ти за try, би бил този:

archivo = open("nota.txt", encoding="utf-8")
try:
    texto = archivo.read()
finally:
    archivo.close()

Работи и finally гарантира затварянето, дори read() да се провали. Но това е много ритуал за нещо толкова често и е лесно да забравиш finally. Python предлага инструкцията with („с“), която прави точно това вместо теб. Виж fig06_06/leer.py и файла nota.txt, който чете:

Revisión de las 9:00
reportes: sin conexión
# fig06_06/leer.py
with open("nota.txt", encoding="utf-8") as archivo:
    texto = archivo.read()
    print(f"dentro del with: cerrado = {archivo.closed}")

print(f"fuera del with: cerrado = {archivo.closed}")
print(texto.splitlines())
$ cd fig06_06
$ python leer.py
dentro del with: cerrado = False
fuera del with: cerrado = True
['Revisión de las 9:00', 'reportes: sin conexión']

open("nota.txt", encoding="utf-8") отваря файла и връща обект, който представлява това отваряне. with ... as archivo: казва „докато трае този блок, файлът се казва archivo; когато излезеш от блока, затвори го, каквото и да стане“. Изходът го доказва: вътре в with archivo.closed (въпросът „затворен ли е?“) казва False; щом блокът свърши, казва True и никой не е писал close(). Ако блокът завърши с изключение, файлът също се затваря: with е, на практика, try/finally, написан в съкратен вид. Справката за инструкцията with обяснява пълния механизъм; за този курс е достатъчно правилото: всеки файл, който отваряш, го отваряй с with.

archivo.read() връща цялото съдържание като един-единствен текст. В програмата се отпечатва texto.splitlines(), метод на текстовете, който ги разделя на списък с по един низ на ред и който премахва знаците за нов ред; така с един поглед виждаш, че файлът има два реда. Обърни внимание и на encoding="utf-8": това е част от open, която не е украса, а причината за нея се обяснява в 6.2.3.

6.2.2 pathlib.Path: път като стойност, не като текст

Името на файла, "nota.txt", е път: адресът на един файл вътре в диска. Досега го пишехме като текст и за един-единствен файл в текущата папка няма проблем. Но пътищата имат собствени правила: в Linux и в macOS папките се разделят с /, в Windows с \; един път може да е **абсолютен** (започва от корена на диска, като /home/ana/curso-python/revisor/servicios.toml) или **относителен** (брои се от папката, в която си, като servicios.toml). Работата с пътища като с текстове, със събиране и изрязване, се чупи, щом се промени което и да е от тези правила.

За това модулът pathlib от стандартната библиотека предлага класа Path: стойност, която знае какво е път. Построяваш такъв с Path("nota.txt") и оттам нататък той има собствени операции, които се извикват с точка, също както методите на списъците и речниците, които вече познаваш. Виж fig06_07/ruta.py, което използва същия nota.txt:

# fig06_07/ruta.py
from pathlib import Path

ruta = Path("nota.txt")
print(ruta.name, ruta.suffix, ruta.exists())
print(Path("no-esta.txt").exists())

otra = Path("datos") / "servicios.toml"
print(otra)

texto = ruta.read_text(encoding="utf-8")
print(texto.splitlines())
$ cd fig06_07
$ python ruta.py
nota.txt .txt True
False
datos/servicios.toml
['Revisión de las 9:00', 'reportes: sin conexión']

Ред по ред:

  • Path("nota.txt") създава пътя. Още не докосва диска: само описва един адрес, независимо дали файлът съществува, или не.
  • ruta.name е името на файла (nota.txt), ruta.suffix е окончанието му с точката (.txt), а ruta.exists() пита диска дали съществува (True). Вторият ред задава същия въпрос за файл, който го няма, и отговаря False: въпросът дали съществува файл, който го няма, не предизвиква никакво изключение. (Нюанс на версиите: в 3.12, ако въпросът се сблъска с друг проблем на системата, например липса на право да се надникне в папката, exists() все още може да хвърли OSError; от 3.14 отговаря False във всички такива случаи.)
  • Path("datos") / "servicios.toml" използва оператора / с ново значение: съединява две части на един път с разделителя, който се полага на системата. Резултатът се отпечатва като datos/servicios.toml и не докосва диска. Много по-сигурно е, отколкото да събираш текстове.
  • ruta.read_text(encoding="utf-8") отваря файла, чете всичко, затваря го и връща текста, с едно-единствено извикване. Това е начинът, който ще използваме в revisor, защото не оставя нищо отворено. Документацията на pathlib изброява останалото, което може да прави един път (да изброява папки, да създава файлове); в този курс е нужно само това, което виждаш тук.

Има практическа последица от това, че относителните пътища „се броят от мястото, където си“: същата програма, със същия Path("nota.txt"), работи или не в зависимост от папката, от която я изпълниш. Какво става, когато файлът не съществува? Виж fig06_08.py, което се опитва да отвори файл, който го няма:

# fig06_08.py
with open("no-esta.txt", encoding="utf-8") as archivo:
    texto = archivo.read()
print(texto)
$ python fig06_08.py
Traceback (most recent call last):
  File "fig06_08.py", line 2, in <module>
    with open("no-esta.txt", encoding="utf-8") as archivo:
         ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'no-esta.txt'

Предизвиква се FileNotFoundError („файлът не е намерен“), конкретно изключение, което можеш да прихванеш. Текстът на съобщението, [Errno 2] No such file or directory, идва от операционната система и е на английски: не го контролираш ти и затова няма да го показваш на този, който използва revisor. В 6.4 програмата ще прихване това изключение и ще го преведе в собствено съобщение.

6.2.3 Текст и байтове: защо винаги encoding="utf-8"

Един файл на диска не съхранява букви: съхранява байтове, числа от 0 до 255. За да са тези числа текст, някой трябва да реши кое число на коя буква отговаря. Това решение е кодирането и има много такива. Тази, която доминира днес, е UTF-8: може да представи всеки знак от всеки език, а буквите без ударение заемат един-единствен байт. Знаците с ударение, като „ó“ в „conexión“, заемат два. Старо кодиране, latin-1, представя „ó“ с един-единствен байт, но върши работа само за няколко европейски езика. Да го видиш е по-ясно, отколкото да го обясняваш. Изпълни fig06_09.py, което изработва байтовете на един и същ текст в двете кодирания:

# fig06_09.py
texto = "sin conexión"

en_utf8 = texto.encode("utf-8")
en_latin1 = texto.encode("latin-1")

print(en_utf8)
print(en_latin1)
print(len(texto), len(en_utf8), len(en_latin1))
print(en_utf8.decode("utf-8"))
$ python fig06_09.py
b'sin conexi\xc3\xb3n'
b'sin conexi\xf3n'
12 13 12
sin conexión

texto.encode("utf-8") превръща един текст в байтове с това кодиране; байтовете се отпечатват с b отпред (b'sin conexi\xc3\xb3n'), а \xc3\xb3 са двата байта (в шестнадесетичен вид), които UTF-8 използва за „ó“. С latin-1 „ó“ е един-единствен байт, \xf3. Третият ред го потвърждава със сметка: текстът има 12 знака; в UTF-8 са 13 байта, а в latin-1, 12. А bytes.decode("utf-8") извършва обратния път: от байтове към текст. С байтовете на UTF-8 се връща „sin conexión“.

Сега проблемът. Какво става, ако байтовете са записани в едно кодиране и се четат с друго? Изпълни fig06_10.py, което се опитва да прочете като UTF-8 байтовете на latin-1. Буквата b пред кавичките бележи литерал от байтове, а \xf3 записва байта, чиято стойност в шестнадесетичен вид (бройната система с основа 16, с която обикновено се записват байтове) е f3, тоест 243:

# fig06_10.py
en_latin1 = b"sin conexi\xf3n"

print(en_latin1.decode("utf-8"))
$ python fig06_10.py
Traceback (most recent call last):
  File "fig06_10.py", line 4, in <module>
    print(en_latin1.decode("utf-8"))
          ~~~~~~~~~~~~~~~~^^^^^^^^^
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf3 in position 10: invalid continuation byte

Последният ред казва: „кодекът utf-8 не може да декодира байта 0xf3 на позиция 10: невалидно продължение“. Байтът 0xf3 е този на „ó“ в latin-1, но в UTF-8 тази стойност значи „тук започва знак от четири байта“, а следващият байт няма вида, който отговаря на продължение. Python, правилно, отказва да гадае. Това е UnicodeDecodeError, което е вид ValueError.

Това обяснява защо се пише encoding="utf-8" винаги: когато не го пишеш, Python избира кодирането според настройките на системата, на която работи, съгласно документацията на open. Същата програма може да прочете добре един файл на твоя компютър и да се провали на този на колега, защото настройките са различни. Ако го напишеш, програмата се държи еднакво навсякъде. И не е каприз: двата формата, които ще използваш оттук нататък, изискват UTF-8. Спецификацията на TOML казва, че един TOML документ е валиден Unicode текст в UTF-8, а RFC 8259 постановява, че JSON, който се обменя между системи, извън затворените екосистеми, трябва да използва UTF-8. Ако искаш да разбереш по-добре въпроса в дълбочина, HOWTO за Unicode в документацията го разказва стъпка по стъпка.

Тук свършва първата сесия. До този момент имаш понятията 6.1 и 6.2: изключения и файлове. Преди втората сесия изпълни фигурите от fig06_01.py до fig06_10.py и промени нещо във всяка: друг текст вместо „142 ms“, друг except, друго име на файл. Да предизвикаш сам грешката затвърждава повече, отколкото да я прочетеш. Следващата сесия започва с форматите.

6.3 Формати: TOML и JSON

6.3.1 Какво е формат и защо има два

Текстов файл с разпилени редове не стига, за да опише списък от услуги, всяка с име, адрес и може би времеви лимит. Нужен е формат: набор от правила, който казва как се пише всяко нещо, за да може всяка програма да го чете без двусмислие. Двата най-използвани формата в Python и в останалия свят за структурирани данни са TOML и JSON и изпълняват различни функции:

  • TOML е замислен така, че човек да го пише и да го чете: позволява коментари с #, не изисква къдрави скоби и запетаи навсякъде и трудно се пише двусмислено. Това е форматът на конфигурационните файлове и е този, който Python вече използва за конфигурацията на проектите (ще го видиш в урок 7, в pyproject.toml). revisor ще чете списъка си с услуги от TOML файл.
  • JSON е замислен така, че две програми да си предават данни. Не допуска коментари и изисква къдрави скоби, кавички и запетаи, но почти всички езици умеят да го четат и пишат. Това е форматът на отговорите на почти всяка уеб услуга и този, който revisor ще използва, за да предаде отчета си на други програми.

Стандартната библиотека на Python носи по един модул за всеки: tomllib (за четене на TOML) и json (за четене и писане на JSON). Има една асиметрия, която е добре да се знае: tomllib само чете. За да се пише TOML, трябва да се инсталира външна библиотека, а този курс не се нуждае от нея, защото никога не пише конфигурация, само я чете.

6.3.2 Четене на TOML с tomllib

Един TOML е поредица от редове llave = valor, със стойности от малко типове: текст в двойни кавички, цели и десетични числа, true и false, и списъци. Освен това има таблици, които групират ключове под име в квадратни скоби. Това, което ни интересува, е частен случай: списък от таблици, или array of tables, който се пише с двойни квадратни скоби, [[servicios]], и всеки път, когато се появи заглавието, започва нов елемент на списъка. Това е естественият начин да се напише „списък от услуги, всяка с полетата си“. Спецификацията на TOML има точната дефиниция; с един пример се разбира по-бързо. Виж fig06_11.py, което чете TOML, написан в самата програма:

# fig06_11.py
import tomllib

TEXTO = """
[[servicios]]
nombre = "catalogo"
url = "https://catalogo.example"

[[servicios]]
nombre = "notificaciones"
url = "https://notificaciones.example"
timeout_ms = 1000
"""

datos = tomllib.loads(TEXTO)
print(datos)

segundo = datos["servicios"][1]
print(segundo["nombre"], segundo["timeout_ms"], type(segundo["timeout_ms"]))
print(segundo.get("proxy"))
$ python fig06_11.py
{'servicios': [{'nombre': 'catalogo', 'url': 'https://catalogo.example'}, {'nombre': 'notificaciones', 'url': 'https://notificaciones.example', 'timeout_ms': 1000}]}
notificaciones 1000 <class 'int'>
None

Две подробности за четенето преди резултата. Текстът е в тройни кавички ("""..."""), които в Python служат за писане на текст от няколко реда със запазени знаци за нов ред. А tomllib.loads(TEXTO) е функцията, която чете: името идва от load string („зареди от текст“) и връща речник, структура, която вече познаваш. (Съществува и tomllib.load, което чете от файл, отворен в двоичен режим; в revisor ще четем текста сами с read_text, за да контролираме кодирането и грешките.)

Виж как е излязъл речникът. Има един-единствен ключ, 'servicios', чиято стойност е списък с два речника: по един за всяко [[servicios]]. Вторият речник има един ключ повече, timeout_ms, и стойността му е числото 1000: третият ред на изхода потвърждава, че е int. Така се чете всяка стойност от един TOML: datos["servicios"] е списъкът, datos["servicios"][1] е втората услуга, datos["servicios"][1]["nombre"] е нейното име. А последният ред на изхода показва нещо, което ще използваме много: segundo.get("proxy") пита за ключ, който не съществува, и връща None, докато segundo["proxy"] би предизвикало KeyError. За данните, които са по избор (като timeout_ms), .get е инструментът.

А ако TOML е написан погрешно? Виж fig06_12.py, където на реда с името липсва затварящата кавичка:

# fig06_12.py
import tomllib

TEXTO = """
[[servicios]]
nombre = "catalogo
url = "https://catalogo.example"
"""

try:
    tomllib.loads(TEXTO)
except tomllib.TOMLDecodeError as error:
    print(f"TOML inválido: {error}")
$ python fig06_12.py
TOML inválido: Illegal character '\n' (at line 3, column 19)

tomllib.TOMLDecodeError е изключението на невалиден TOML. Съобщението му, Illegal character '\n' (at line 3, column 19), казва „недопустим знак, нов ред, на ред 3, колона 19“: текстът е започнал с кавичка и е срещнал края на реда, без тя да е затворена. Забележи, че съобщението те отвежда до точното място и че го казва самият модул. В revisor ще включим това съобщение такова, каквото е, в грешката за конфигурацията. Функциите и изключенията на този модул са в документацията на tomllib; модулът влезе в стандартната библиотека в Python 3.11 с PEP 680.

6.3.3 Четене и писане на JSON с json

JSON има по-строги правила и по-малък речник. Един JSON обект се пише в къдрави скоби, с двойки "llave": valor; списък, в квадратни скоби; текстовете, в двойни кавички (винаги двойни); числата, без кавички; и съществуват три думи: true, false и null. Python има еквивалент за всяко парче и модулът json превръща от едното в другото:

JSON Python
обект {...} dict
масив [...] list
текст "..." str
цяло число int
число с десетични знаци float
true / false True / False
null None

Строгите правила са тези на стандарта; модулът json на Python по подразбиране е малко по-толерантен и неговата документация го казва открито: приема и записва NaN, Infinity и -Infinity („не е число“ и „безкрайност“ на float), които стандартът не допуска, а ако един обект повтаря ключ, остава с последната стойност. За revisor това няма значение, защото числата му са цели; ако някога ти трябва JSON, който всяка друга програма приема, json.dumps(..., allow_nan=False) отказва да записва тези стойности.

Превръщането от Python в JSON (сериализиране) се прави с json.dumps („dump string“: изсипи в текст), а обратното (десериализиране) с json.loads. Виж fig06_13.py, което използва резултат на revisor като пример:

# fig06_13.py
import json

resultado = {
    "nombre": "reportes",
    "estado": "CAIDO",
    "tiempo_ms": None,
    "detalle": "sin conexión",
    "activo": True,
}

texto = json.dumps(resultado, ensure_ascii=False, indent=2)
print(texto)

de_vuelta = json.loads(texto)
print(de_vuelta["tiempo_ms"] is None, de_vuelta == resultado)

print(json.dumps(resultado["detalle"]))
print(json.dumps(resultado["detalle"], ensure_ascii=False))
$ python fig06_13.py
{
  "nombre": "reportes",
  "estado": "CAIDO",
  "tiempo_ms": null,
  "detalle": "sin conexión",
  "activo": true
}
True True
"sin conexi\u00f3n"
"sin conexión"

Четири неща, които да научиш от този изход. Едно: None стана null и True стана true; JSON не е Python и всеки език има свой правопис на „нищо“ и на „истина“. Две: indent=2 кара dumps да запише резултата на няколко реда с отстъп от две интервала, четим за човек; без него всичко би излязло на един компактен ред. Три: проверката de_vuelta == resultado дава True, защото loads възстановява първоначалния речник, и de_vuelta["tiempo_ms"] is None също, защото null се връща като None. И четири, най-тънкото: последните два реда отпечатват един и същ текст, „sin conexión“, по два начина. По подразбиране dumps екранира всеки знак, който не е ASCII, и записва "sin conexi\u00f3n", което е валиден JSON, но нечетим за човек. С ensure_ascii=False записва „ó“ такава, каквато е. Тъй като revisor пише на испански, JSON отчетът ще носи винаги ensure_ascii=False. Това е видът, който курсът фиксира за целия отчет: json.dumps(datos, ensure_ascii=False, indent=2).

Едно предупреждение, което се научава по-добре по трудния начин. json.dumps разбира само типовете от таблицата горе (и кортежа, който записва като масив, също като списък). Данните на revisor са в собствени обекти, Resultado, Servicio и Estado, и ако се опиташ с json.dumps(Estado.CAIDO), получаваш TypeError: Object of type Estado is not JSON serializable („обект от тип Estado не може да се сериализира в JSON“). Решението е първо да сглобиш речник с прости типове, като използваш estado.value, за да извадиш текста на Enum, и да сериализираш този речник. Това е, което ще прави функцията reporte_a_json по-нататък.

И както TOML, така и JSON се проваля. Виж fig06_14.py, където липсва запетая между две двойки:

# fig06_14.py
import json

TEXTO = """{
  "nombre": "pagos",
  "tiempo_ms": 87
  "estado": "ARRIBA"
}"""

try:
    json.loads(TEXTO)
except json.JSONDecodeError as error:
    print(f"mensaje: {error.msg}")
    print(f"línea {error.lineno}, columna {error.colno}")
    print(f"completo: {error}")
$ python fig06_14.py
mensaje: Expecting ',' delimiter
línea 4, columna 3
completo: Expecting ',' delimiter: line 4 column 3 (char 43)

json.JSONDecodeError пази проблема в три полезни атрибута: msg („очаква се запетая като разделител“), lineno (редът) и colno (колоната). Тук ред 4, колона 3: след 87 от предишния ред е трябвало да има запетая и декодерът я е усетил като липсваща точно в началото на следващата двойка. Пълното съобщение, което се получава, като се отпечата грешката, носи и char 43, позицията, броейки от началото. Документацията на json описва всички параметри на dumps и loads, а RFC 8259 е спецификацията на формата. Това, че JSONDecodeError е също ValueError, ти казва нещо, което вече знаеш от 6.1.3: един except ValueError би го прихванал, заедно с много други грешки, които нямат нищо общо. Винаги е по-добре да се назове конкретният тип.

6.4 Валидиране на това, което идва отвън

6.4.1 Защо трябва да се валидира и къде

Един файл може да е написан правилно и въпреки това да е безполезен. Този TOML е напълно валиден:

[[servicios]]
nombre = 42
url = "ftp://archivos.example"
timeout_ms = -3

Всеки ред следва правилата на TOML. Но името е число, адресът не е на уеб страница и времевият лимит е отрицателен. Ако програмата го приеме, без да го погледне, грешката ще се появи по-късно, далеч от причината си, на ред, който няма нищо общо с файла: TypeError при опит да се подравни име или заявка, която никога не излиза. Да валидираш е да провериш, в момента, в който данните влизат, че те имат формата и смисъла, които програмата очаква.

Има специфична причина за Python да се прави на ръка. В Урок 0 видя, че анотациите на типове (nombre: str) не се налагат при изпълнение: те са бележка за теб и за инструментите, не катинар. Един Servicio(nombre=42, ...) се създава без протест. Затова границата на програмата, точката, където влиза това, което идва отвън, е мястото, където типовете се проверяват наистина. Веднъж валидирани, останалата част от програмата може да им се доверява, без да гледа отново. Тази идея, да валидираш на границата и да се доверяваш вътре, подрежда целия revisor: един-единствен модул, configuracion.py, се грижи да не се доверява на външния свят.

Основното правило е това, което формулира ръководството на OWASP за валидиране на входни данни: това, което идва отвън, не е надеждно, докато не е проверено, а проверката, която работи най-добре, е списък на разрешеното: изброява се кое важи и се отхвърля всичко останало, вместо да се опитва да се изброи всичко, което е погрешно.

6.4.2 isinstance и капанът с bool

За да се попита „тази стойност от този тип ли е?“, съществува isinstance(valor, tipo), функция, която връща True или False. isinstance(5000, int) е True; isinstance("5000", int) е False, защото текстът „5000“ не е цяло число, макар да прилича на такова. С нея може да се напише валидирането на времевия лимит: „трябва да е цяло число, по-голямо от нула“. И тук има капан, който заслужава собствена фигура. Виж fig06_15.py:

# fig06_15.py
print(isinstance(True, int))
print(isinstance(True, bool))
print(True + True)


def timeout_valido(valor: object) -> bool:
    return isinstance(valor, int) and not isinstance(valor, bool) and valor > 0


for valor in [5000, 0, -1, True, "5000", 2.5]:
    print(valor, type(valor), timeout_valido(valor))
$ python fig06_15.py
True
True
2
5000 <class 'int'> True
0 <class 'int'> False
-1 <class 'int'> False
True <class 'bool'> False
5000 <class 'str'> False
2.5 <class 'float'> False

Първите три реда са капанът. isinstance(True, int) е True: в Python bool е вид цяло число. True е буквално числото 1, а False е 0 и затова True + True дава 2. Не е случайност: това е старо дизайнерско решение на езика, описано в документацията на вградените типове. Последицата за теб е конкретна. Едно наивно валидиране като isinstance(timeout_ms, int) and timeout_ms > 0 приема true като времеви лимит, защото true е положително цяло число: струва 1. Някой, който напише timeout_ms = true във файла (разбираемо объркване), би получил времеви лимит от една милисекунда, без никой да се оплаче.

Функцията timeout_valido от програмата го избягва: пита isinstance(valor, int) и освен това not isinstance(valor, bool). Последните шест реда на изхода показват резултата с шест стойности: цялото число 5000 минава; 0 и -1 не минават заради условието valor > 0; True не минава; текстът "5000" не минава (той е str); и десетичното 2.5 също (то е float). Забележи, че проверката е направена със списък от представителни стойности, включително редките случаи: така се тества едно валидиране, като се гледат точно стойностите, които биха могли да се промъкнат.

6.4.3 Адреси: разрешената схема

Друга стойност, която идва отвън в revisor, е адресът (url) на всяка услуга. Един адрес има части и първата е схемата: това, което стои преди двете точки и което казва кой протокол да се използва (https, http, ftp, file...). За проверител на уеб услуги имат смисъл само две: http и https. Защо е толкова важно? Защото по-нататък програмата ще прави заявка към този адрес и с библиотеката urllib адрес file:///... кара програмата да прочете файл от диска вместо да попита една услуга, докато други схеми биха я накарали да говори с друг вид сървър. Конфигурационен файл, който друг човек е редактирал, не бива да може да постигне това.

Стандартната библиотека носи urllib.parse.urlsplit, което разделя един адрес на съставните му части. Виж fig06_16.py, което изпитва шест адреса:

# fig06_16.py
from urllib.parse import urlsplit

DIRECCIONES = [
    "https://catalogo.example",
    "http://127.0.0.1:8099/catalogo",
    "ftp://archivos.example",
    "file:///etc/hosts",
    "catalogo.example",
    "javascript:alert(1)",
]

for direccion in DIRECCIONES:
    esquema = urlsplit(direccion).scheme
    permitida = esquema in {"http", "https"}
    print(f"{direccion:<32} esquema='{esquema}' permitida={permitida}")
$ python fig06_16.py
https://catalogo.example         esquema='https' permitida=True
http://127.0.0.1:8099/catalogo   esquema='http' permitida=True
ftp://archivos.example           esquema='ftp' permitida=False
file:///etc/hosts                esquema='file' permitida=False
catalogo.example                 esquema='' permitida=False
javascript:alert(1)              esquema='javascript' permitida=False

urlsplit(direccion).scheme е схемата. Валидирането е въпрос за принадлежност: esquema in {"http", "https"}, с множеството на разрешеното, както в списъка на разрешеното, за който говорихме. Забележи случаите: първите два адреса минават; ftp и file не; catalogo.example (без схема) има празна схема и не минава; а javascript:alert(1) има схемата javascript, която също не. Отбележи избора в дизайна: не е написан списък със забранени схеми (ftp, file, javascript...), защото утре някой измисля друга и се промъква. Написан е списъкът на двете разрешени. Стандартът, който определя как се разделя един адрес, е RFC 3986, а функциите са в документацията на urllib.parse.

Да гледаш само схемата обаче пропуска два вида счупени адреси и е добре да ги видим. Първият е адрес без сървър: https:// самичко или http:catalogo (без двете наклонени черти) имат правилната схема, но нямат сървър, до който да се обърнеш. Вторият е по-лош: има адреси, които urlsplit дори не може да раздели. С http://[::1 (квадратна скоба, която се отваря и не се затваря) urlsplit не връща нищо: хвърля ValueError: Invalid IPv6 URL. А с http://127.0.0.1:abc/ адресът се разделя, но когато се поиска портът (.port, числото след двете точки), излиза ValueError: Port could not be cast to integer value as 'abc'. Ако валидирането не прихване това ValueError, погрешно написан конфигурационен файл събаря програмата с traceback вместо с ясното съобщение, което обещахме. Документацията на urllib.parse предупреждава: urlsplit не валидира, а квадратните скоби без двойка хвърлят ValueError.

Има трети вид, по-скрит. В TOML, вътре в текст в кавички, \n е нов ред, така че url = "http://127.0.0.1:8099/cata\nlogo" стига до Python с нов ред по средата. urlsplit го премахва мълчаливо (документацията му го казва: премахва новите редове и табулациите, както правят браузърите) и адресът изглежда добър; но библиотеката, която ще прави заявката в урок 9, httpx, го вижда и го отхвърля със собствена грешка, която не е мрежова грешка. Нещо подобно може да стане с букви, които не са английски, в името на сървъра, които httpx трябва да превърне в специална форма и понякога не може. Простата защита е във файла с конфигурация да не се приема нищо, което не е обикновен текст.

Затова revisor не пита направо за схемата, а с една малка функция, която събира всички въпроси:

def es_url_http(url: str) -> bool:
    if not url.isascii() or not url.isprintable() or " " in url:
        return False
    try:
        partes = urlsplit(url)
        return (
            partes.scheme in {"http", "https"}
            and partes.hostname is not None
            and (partes.port is None or partes.port > 0)
        )
    except ValueError:
        return False

Чете се отгоре надолу. Първият if отхвърля това, което не е обикновен текст, с три метода на текстовете: isascii() е True, ако всички знаци са ASCII (буквите на английския, цифрите и знаците на една основна клавиатура; една é вече не е); isprintable() е True, ако никой не е управляващ знак, като новия ред; а " " in url пита дали има някакъв интервал. Ако се провали което и да е от трите, revisor не приема адреса. Внимание: това не значи, че е невалиден в интернет; https://catálogo.example е легитимен адрес. Това е правило на revisor, по-строго от това на браузърите: в неговата конфигурация такова име се записва в ASCII формата си, тази, която започва с xn--. След това всичко е вътре в един try, защото и разделянето на адреса, и четенето на порта могат да хвърлят ValueError, а за тази функция тази грешка има един-единствен отговор: адресът не става, False. Вътре има три условия, свързани с and: схемата е една от двете разрешени; partes.hostname, името на сървъра, съществува (при https:// самичко е None); и портът, ако го има, е число, по-голямо от 0. И трите са в скоби, за да могат да се разпределят на няколко реда. Подробност за and, която тук има значение: оценява отляво надясно и спира при първото невярно условие, така че портът се чете само ако първите две вече са изпълнени. С това шестте адреса от fig06_16.py дават същия резултат като преди, а всички счупени от тези абзаци излизат False. Това, което това валидиране не проверява, е дали сървърът съществува или отговаря: това се разбира само като му се отправи заявка и е работа на revisor от урок 9. Нито гарантира, че httpx ще приеме всичко, което минава: http://999.1.1.1:8099/pagos има схема, сървър и порт, но 999 не се побира в един IP адрес (всяка част е от 0 до 255) и httpx го отхвърля. Затова урок 9, освен че обяснява защо file:// е конкретен риск, когато се използва urllib, поставя втора мрежа при запитването: адрес, който httpx не може да използва, излиза в отчета като „dirección inválida“ („невалиден адрес“), без да събаря другите.

6.4.4 Защо не eval и не pickle

С речника на този урок вече може да се обясни нещо, което се вижда в много програми на начинаещи и е сериозен риск. Някой мисли: „за да чета списък с услуги, не ми трябва формат; записвам списъка като код на Python във файл и програмата го изпълнява“. Съществува функция, която го позволява: eval(texto), която взема текст, тълкува го като код и връща резултата му. Виж fig06_17.py, демонстрация на това, което не се прави:

# fig06_17.py
# DEMOSTRACIÓN de lo que NO se hace: eval con un texto que viene de fuera.
print(eval("2300 + 1"))

texto_de_fuera = "print('esto lo escribió otro y se ejecutó')"
eval(texto_de_fuera)
$ python fig06_17.py
2301
esto lo escribió otro y se ejecutó

Първото извикване изглежда невинно: eval("2300 + 1") изчислява и връща 2301. Второто показва проблема: текстът, който е дошъл отвън, беше една инструкция и eval я изпълни. Тук беше един безобиден print; можеше да е всичко, което Python умее да прави: да трие файлове, да чете пароли, да отваря връзка към друг компютър. Който контролира текста, контролира програмата. Документацията на eval го казва без заобикалки: изпълнява произволен код и ограничаването на функциите, които може да използва, не е механизъм за сигурност. Има по-предпазлив вариант, ast.literal_eval, който приема само литерални стойности (числа, текстове, списъци), но документацията на модула ast предупреждава, че и той не е подходящ за ненадеждни входни данни: много голям или много вложен текст може да изчерпи паметта или стека на Python.

Другият случай е pickle, модулът, който съхранява обекти на Python във файл и ги възстановява после. Удобен е, защото не се налага да се прави формат: обектът се запазва и се възстановява. Но възстановяването на един обект може да изпълни код и затова документацията на pickle започва с голямо предупреждение: никога не зареждай данни от източник, който не е надежден, защото нарочно направен файл може да изпълни на твоя компютър това, което авторът му поиска. Тук дори не се показва как се прави, защото този курс не го използва. Този клас слабост има име в каталога на слабостите на софтуера: CWE-502, десериализиране на ненадеждни данни. Решението е това, което вече имаш: данните отвън се четат с формати, които описват само данни. TOML и JSON описват стойности, не инструкции; колкото и да се старае някой да ги напише, tomllib.loads и json.loads могат да върнат само речници, списъци, текстове, числа и булеви стойности (плюс None в JSON и дати и часове в TOML, които tomllib връща като стойности на модула datetime). Нито един от тези типове не изпълнява нищо. Затова revisor ги използва.

Това, че не изпълняват нищо, не значи, че не могат да навредят по друг начин. Документациите на tomllib и на json носят същото предупреждение: текст, направен със зъл умисъл (огромен или вложен на хиляди нива), може да накара четенето да изразходва много процесорно време и много памет, и препоръчват да се ограничава размерът на това, което се чете. revisor не го прави и това е съзнателно решение: чете конфигурационен файл, който пише този, който го използва, от няколкостотин байта. Но ако твоя програма получава TOML или JSON от други (по мрежата, от форма), първото е да провериш размера преди да го прочетеш, например с ruta.stat().st_size, което казва колко байта е голям един файл, и да отхвърлиш това, което надхвърля разумен лимит.

Ако искаш да продължиш със сигурността на системите, които излагат услуги, в блога на Hábil има статия за сигурността на API: идентичност, шлюз и токени. А ако ти е интересно да видиш как друг език решава същия този проблем с данните отвън със същия revisor, курсът по TypeScript има цял урок за това.

6.4.5 Стъпката на проекта: revisor чете конфигурацията си

Имаш всички парчета. Остава да ги сглобиш. revisor от този урок изглежда така, в твоята папка ~/curso-python/revisor/:

revisor/
  servicios.toml      ← NUEVO: los datos, ahora fuera del código
  configuracion.py    ← NUEVO: lee, valida y traduce los errores
  formato.py          ← tiene una función más: reporte_a_json
  revisor.py          ← ya no tiene SERVICIOS escrito a mano
  modelo.py  revision.py   (sin cambios)

Започва се с данните. Това е файлът servicios.toml, със същите четири услуги както винаги; той замества списъка, който имаше в кода:

[[servicios]]
nombre = "catalogo"
url = "https://catalogo.example"

[[servicios]]
nombre = "inventario"
url = "https://inventario.example"

[[servicios]]
nombre = "pagos"
url = "https://pagos.example"

[[servicios]]
nombre = "reportes"
url = "https://reportes.example"

Сега новият модул, configuracion.py. Той е най-дългият в урока, но всяка част от него вече я познаваш. Прочети го цял веднъж и после се разглобява:

# fig06_18/configuracion.py
import tomllib
from pathlib import Path
from urllib.parse import urlsplit

from modelo import Servicio, nombres_repetidos


class ErrorDeConfiguracion(Exception):
    """El archivo de configuración no se puede usar."""


def leer_servicios(ruta: Path) -> list[Servicio]:
    try:
        texto = ruta.read_text(encoding="utf-8")
    except FileNotFoundError as error:
        raise ErrorDeConfiguracion(f"no existe el archivo {ruta}") from error

    try:
        datos = tomllib.loads(texto)
    except tomllib.TOMLDecodeError as error:
        raise ErrorDeConfiguracion(f"{ruta} no es TOML válido: {error}") from error

    lista = datos.get("servicios")
    if not isinstance(lista, list) or len(lista) == 0:
        raise ErrorDeConfiguracion("falta la lista [[servicios]]")

    servicios = [
        validar_servicio(datos_servicio, posicion)
        for posicion, datos_servicio in enumerate(lista, start=1)
    ]

    repetidos = nombres_repetidos(servicios)
    if len(repetidos) > 0:
        raise ErrorDeConfiguracion(f"nombres repetidos: {', '.join(sorted(repetidos))}")
    return servicios


def es_url_http(url: str) -> bool:
    if not url.isascii() or not url.isprintable() or " " in url:
        return False
    try:
        partes = urlsplit(url)
        return (
            partes.scheme in {"http", "https"}
            and partes.hostname is not None
            and (partes.port is None or partes.port > 0)
        )
    except ValueError:
        return False


def validar_servicio(datos: object, posicion: int) -> Servicio:
    if not isinstance(datos, dict):
        raise ErrorDeConfiguracion(f"servicio {posicion}: debe ser una tabla")

    nombre = datos.get("nombre")
    if nombre is None:
        raise ErrorDeConfiguracion(f"servicio {posicion}: falta 'nombre'")
    if not isinstance(nombre, str) or nombre.strip() == "":
        raise ErrorDeConfiguracion(
            f"servicio {posicion}: 'nombre' debe ser un texto que no esté vacío"
        )

    url = datos.get("url")
    if url is None:
        raise ErrorDeConfiguracion(f"servicio {posicion}: falta 'url'")
    if not isinstance(url, str) or not es_url_http(url):
        raise ErrorDeConfiguracion(
            f"servicio {posicion}: 'url' debe ser una dirección "
            "http:// o https:// válida"
        )

    if "timeout_ms" not in datos:
        return Servicio(nombre, url)

    timeout_ms = datos["timeout_ms"]
    if (
        not isinstance(timeout_ms, int)
        or isinstance(timeout_ms, bool)
        or timeout_ms <= 0
    ):
        raise ErrorDeConfiguracion(
            f"servicio {posicion}: 'timeout_ms' debe ser un entero mayor que 0"
        )
    return Servicio(nombre, url, timeout_ms)

Функцията leer_servicios получава път и връща списъка от Servicio. Върви на четири стъпки и във всяка правилото е едно и също: превеждай в домейна на конфигурацията всяка грешка, която може да има причина във файла.

  1. Четене на текста. ruta.read_text(encoding="utf-8") е вътре в try с един-единствен except, и то за FileNotFoundError. Това е единствената грешка при четене, която умее да се обясни добре („не съществува файлът“), затова е единствената, която се прихваща. Ако файлът съществува, но нямаш право да го четеш, грешката е PermissionError и не я прихваща: функцията няма какво полезно да каже за това и е по-добре програмата да спре с доклада на системата. (Упражнение 4 те моли да разшириш това с друга грешка, тази на кодирането.)
  2. Тълкуване на TOML. tomllib.loads е в собствен try със собствен except: ако има TOMLDecodeError, съобщението на tomllib се включва в нашето. И в двата случая се прави raise ... from error, по причината от 6.1.4.
  3. Проверка на общата форма. datos.get("servicios") търси списъка; ако не е списък или е празен, се съобщава, че липсва. Тази стъпка пази от TOML, който е валиден, но не е нашият.
  4. Проверка на всяка услуга. Един израз за построяване на списък (урок 4) извиква validar_servicio за всеки елемент, с неговата позиция. Функцията enumerate(lista, start=1), която видя в урок 3, предава позицията на всеки елемент заедно с елемента; параметърът start=1 кара броенето да започне от 1 вместо от 0, защото „услуга 2“ е начинът, по който говори човек. Накрая nombres_repetidos (урок 5) връща множеството от имена, които се появяват повече от веднъж. Ако не е празно (len(repetidos) > 0, същият въпрос за размера, който вече използва със списъците), се съобщава; sorted(repetidos) ги подрежда по азбучен ред, а ", ".join(...) ги съединява в един текст, разделен със запетаи (join, „съединявам“, е метод на текстовете, който събира елементите на един списък, като използва този текст за разделител).

Функцията validar_servicio получава datos: object, най-общия тип, който съществува, нарочно: не знае какво е, защото идва отвън. Първото е да провери с isinstance(datos, dict), че е речник. След това проверява поле по поле, в този ред, и спира при първия проблем:

  • nombre трябва да съществува и да е текст, който не е празен (nombre.strip() == "" хваща текста, който има само интервали);
  • url трябва да съществува и да минава es_url_http: разрешена схема, сървър и, ако носи, валиден порт (6.4.3);
  • timeout_ms, който е по избор, се проверява само ако присъства ("timeout_ms" not in datos; операторът in пита дали един ключ е в речника) и трябва да е цяло число, по-голямо от 0, което не е bool (6.4.2).

Ако всичко е наред, връща Servicio. Ако няма timeout_ms, не го подава: стойността по подразбиране 5000 я слага дефиницията на Servicio, която идва от урок 5. Всяко съобщение за грешка започва със servicio N:, за да знае този, който редактира файла, кой елемент да прегледа. Съобщенията, които модулът произвежда, са тези, като позицията се брои от 1:

Случай Съобщение
файлът не съществува no existe el archivo servicios.toml
погрешно написан TOML servicios.toml no es TOML válido: <mensaje de tomllib>
липсва списъкът falta la lista [[servicios]]
липсва поле servicio 2: falta 'nombre'
услугата не е таблица servicio 2: debe ser una tabla
празно име или такова, което не е текст servicio 2: 'nombre' debe ser un texto que no esté vacío
url, който не е валиден http/https адрес servicio 2: 'url' debe ser una dirección http:// o https:// válida
невалиден времеви лимит servicio 3: 'timeout_ms' debe ser un entero mayor que 0
повтарящи се имена nombres repetidos: catalogo

Файлът formato.py придоби една функция. Не промени нито една от предишните (formatear_tiempo, encabezado, linea, contar, resumen); добави се само import json в началото и тази функция накрая:

def reporte_a_json(resultados: list[Resultado]) -> str:
    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))
    datos = {
        "servicios": [
            {
                "nombre": r.servicio.nombre,
                "url": r.servicio.url,
                "estado": r.estado.value,
                "tiempo_ms": r.tiempo_ms,
                "detalle": r.detalle,
            }
            for r in resultados
        ],
        "resumen": {
            "total": len(resultados),
            "arriba": len([r for r in resultados if r.estado == Estado.ARRIBA]),
            "lento": len([r for r in resultados if r.estado == Estado.LENTO]),
            "caido": len([r for r in resultados if r.estado == Estado.CAIDO]),
            "promedio_ms": promedio_ms,
        },
    }
    return json.dumps(datos, ensure_ascii=False, indent=2)

Това е пряко приложение на 6.3.3: сглобява се речник с типове, които JSON разбира (затова r.estado.value, а не r.estado), и се сериализира с ensure_ascii=False, indent=2. Средното излиза като None (което JSON записва null), когато нито една услуга не е отговорила, защото нищо не може да се усредни. Обърни внимание и на едно повторение, което е оставено нарочно: сметките на resumen и тези на този речник са едни и същи, изчислени два пъти. Направено е така, за да не се пипа това, което вече работи в урок 5, но това е същият проблем, с който започна този урок, на една стойност, изчислявана на две места: ако някой ден правилото за средното се промени, ще трябва да се промени и на двете места. Запиши го в дневника си: това е вид дълг, който се плаща следващия път, когато се пипа отчетът.

Остава главната програма. Така изглежда revisor.py, който загуби списъка с услуги и придоби константа с пътя на файла:

# fig06_18/revisor.py
from pathlib import Path

from configuracion import ErrorDeConfiguracion, leer_servicios
from formato import encabezado, linea, resumen
from revision import revisar_todos

RUTA_CONFIG = Path("servicios.toml")


def main() -> None:
    try:
        servicios = leer_servicios(RUTA_CONFIG)
    except ErrorDeConfiguracion as error:
        print(f"error de configuración: {error}")
        return

    resultados = revisar_todos(servicios)
    print(encabezado())
    for resultado in resultados:
        print(linea(resultado))
    print()
    print(resumen(resultados))


if __name__ == "__main__":
    main()

RUTA_CONFIG = Path("servicios.toml") е константа (главни букви, както UMBRAL_LENTO_MS) с относителния път. В main извикването на leer_servicios е вътре в try, който прихваща само ErrorDeConfiguracion: това е собственото изключение от 6.1.4, единствената грешка, която тази програма умее да обясни. Ако се случи, отпечатва error de configuración: , последвано от съобщението, а return прекратява main, без да продължава нататък. Всяка друга грешка (например ZeroDivisionError заради дефект) не се прихваща и спира със своя доклад, за да я поправи някой.

Внимание към една последица: когато съобщава за грешка в конфигурацията, програмата завършва с код на изход 0, който в терминала значи „всичко е наред“. За човек, който чете екрана, няма значение; за друга програма, която я извиква и гледа само кода, прилича на успех. Това е дълг, който този урок оставя съзнателно, за да не смесва теми: в Урок 7 този клон ще завършва с код 2; тук само се отпечатва. Изпълни го:

$ cd fig06_18
$ 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

Това е същият отчет като в урок 5, със същата таблица и същото обобщение (4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms), но сега четирите услуги идват от servicios.toml. Ако добавиш услуга във файла, тя се появява в таблицата, без да пипаш Python (ще видиш случай в упражнение 1).

За да видиш JSON на отчета, проектът носи един помощен скрипт, ver_json.py. Не е част от програмата: само свързва четенето на конфигурацията, проверката и reporte_a_json, за да видиш резултата, преди в урок 7 да съществува опция на командния ред, с която да го поискаш:

# fig06_18/ver_json.py
from configuracion import leer_servicios
from formato import reporte_a_json
from revision import revisar_todos
from revisor import RUTA_CONFIG

servicios = leer_servicios(RUTA_CONFIG)
print(reporte_a_json(revisar_todos(servicios)))
$ python ver_json.py
{
  "servicios": [
    {
      "nombre": "catalogo",
      "url": "https://catalogo.example",
      "estado": "ARRIBA",
      "tiempo_ms": 142,
      "detalle": "200"
    },
    {
      "nombre": "inventario",
      "url": "https://inventario.example",
      "estado": "LENTO",
      "tiempo_ms": 2300,
      "detalle": "200"
    },
    {
      "nombre": "pagos",
      "url": "https://pagos.example",
      "estado": "ARRIBA",
      "tiempo_ms": 87,
      "detalle": "200"
    },
    {
      "nombre": "reportes",
      "url": "https://reportes.example",
      "estado": "CAIDO",
      "tiempo_ms": null,
      "detalle": "sin conexión"
    }
  ],
  "resumen": {
    "total": 4,
    "arriba": 2,
    "lento": 1,
    "caido": 1,
    "promedio_ms": 843
  }
}

Това е JSON, който курсът фиксира за отчета на revisor и който не се променя в следващите уроци: servicios е списък с по един запис за услуга, tiempo_ms е null за тази, която не е отговорила, а resumen повтаря сметките. Който консумира този отчет от друга програма, може да се опре на тази форма.

Грешката, която ще видиш

Урокът има една основна грешка, тази на файла, който не е там, където мислиш, и четири помощни съобщения, които вече видя и които е добре да се четат с един поглед.

Основната грешка. Изпълни revisor от папката отгоре, тази, която съдържа fig06_18, вместо да влезеш в нея:

$ python fig06_18/revisor.py
error de configuración: no existe el archivo servicios.toml

Програмата намира кода (ти даде пътя), но RUTA_CONFIG е относителен: Path("servicios.toml") значи „servicios.toml от папката, в която съм застанал“, не „този, който е до програмата“. От папката отгоре този файл не съществува. Обърни внимание на това, което програмата е направила добре: не показа доклад за грешка, нито изключението FileNotFoundError на английски с текста на системата; показа съобщението на испански, което ти написа и което казва какво липсва. И това е същата грешка, която ще видиш в който и да е ден, когато един скрипт работи на твоя компютър и се проваля на друг: девет от десет пъти файлът съществува, но папката, от която се изпълнява, е друга. За да разбереш в коя папка си, командата pwd ти го казва. В урок 7 --config ще ти позволи да посочиш пътя, без да зависиш от това, къде си.

Четирите помощни съобщения, които се четат отдолу нагоре:

  • ValueError: invalid literal for int() with base 10: '142 ms': текст, който е искано да се превърне в цяло число, а не е само от цифри (fig06_01.py). Провери какъв текст е пристигнал.
  • FileNotFoundError: [Errno 2] No such file or directory: 'no-esta.txt': пътят не сочи към нищо. Провери работната папка и правописа (fig06_08.py).
  • UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf3 in position 10: файлът не е в UTF-8, а байтът, който Python споменава, казва на коя позиция е спряло да се връзва (fig06_10.py).
  • TypeError: Object of type Estado is not JSON serializable: опита се да сериализираш собствен обект, без да го превърнеш в прости типове. Сглоби речник с .value.

Какво се прави погрешно

Да пишеш except: без тип или except Exception: за всеки случай. Вече видя цената в 6.1.3: печатна грешка се маскира като друга грешка; а при „гол“ except:, освен това, програмата може да не спре дори с Ctrl+C. Конкретното изключение, винаги.

Да погълнеш грешката мълчаливо. Един except ValueError: pass („не прави нищо“) кара програмата да продължи с данни, които не съществуват. Времето на inventario остава непрочетено, отчетът излиза с погрешен ред и никой не знае защо. Ако прихванеш едно изключение, направи нещо с него: съобщи, преведи, върни дефинирана стойност. Ако не знаеш какво да правиш, не го прихващай.

Огромен try. Да вмъкнеш двадесет реда в един try, за да „защитиш всичко“, кара except да прихваща грешки, които не си очаквал, от редове, които нямат общо, както във fig06_03.py. try съдържа реда, който може да се провали по начина, който познаваш, и нищо повече.

Да преведеш изключение без from. Докладът продължава да показва първоначалното, но вече не като причина: остава „по време на обработката на предишното изключение възникна друго“, което е по-лошо за този, който дебъгва. Ако сменяш една грешка с друга, raise ... from error.

Да отваряш файлове без with. Един archivo = open(...) без своя close() оставя файла отворен, ако нещо се провали преди да го затвориш, и не се забелязва, докато програмата не отвори стотици.

Да пропускаш encoding="utf-8". Работи на твоя компютър и се проваля (или, още по-лошо, чете погрешно ударенията) на друг. Пиши го винаги.

Да съединяваш пътища с + или с "/" вътре в текст. Чупи се, щом се смени операционната система или остане излишна черта. С Path и оператора / не трябва да мислиш за това.

Да валидираш с истинност или неистинност. if timeout_ms: пропуска True и, още по-лошо, отхвърля 0 със същия жест, с който отхвърля празното, без да различава случаите. Питай точно това, което искаш да знаеш: isinstance(timeout_ms, int) и timeout_ms > 0.

Да валидираш с assert. Инструкцията assert condicion се проваля, ако условието е невярно, и изглежда удобен начин за валидиране. Но Python може да се изпълнява в режим на оптимизация (python -O), в който всички assert се игнорират, съгласно справката за инструкцията. Валидиране, което изчезва, когато някой оптимизира програмата, не валидира нищо. assert са за тестовете, които ще видиш в урок 8; за данните отвън се използва if и raise.

Да подадеш данни отвън на eval или на pickle. Това е да оставиш автора на файла да решава какво прави твоята програма. Използва се TOML или JSON.

Упражнения

Упражнение 1 — Добави услуги, без да пипаш кода

Добави в своя servicios.toml двете допълнителни услуги на курса: facturacion (https://facturacion.example) и notificaciones (https://notificaciones.example, с timeout_ms = 1000). Тъй като revisar продължава да използва симулирани данни, добави и техния запис в SIMULADO на revision.py, както в упражнение 1 на урок 5: facturacion отговаря 503 за 95 ms, а notificaciones не отговаря (tiempo agotado). Преди да изпълниш, предскажи обобщението: колко услуги има, колко работят, колко са бавни и колко паднали, и колко е средното. След това изпълни и сравни. Забелязваш ли нещо странно в таблицата?

Упражнение 2 — По един повреден файл за всяка грешка

Създай копие на своя servicios.toml, наречено roto.toml, и, като смениш RUTA_CONFIG на Path("roto.toml"), напиши в него, един по един, файлове, които да предизвикат всяко от тези съобщения: (а) липсва списъкът [[servicios]]; (б) погрешно написан TOML (забрави една кавичка); (в) услуга без nombre на втората позиция; (г) услуга с url ftp://archivos.example; (д) timeout_ms = 0 в третата услуга; (е) две услуги с името catalogo. За всеки първо напиши съобщението, което очакваш, и после сравни с това, което отпечатва revisor. Запиши в дневника онези, в които не си познал.

Упражнение 3 — Капанът с bool, на живо

Напиши roto.toml с една-единствена услуга, която има timeout_ms = true. Изпълни revisor и запиши съобщението. Сега смени, временно, валидирането на timeout_ms в configuracion.py с наивната версия (not isinstance(timeout_ms, int) or timeout_ms <= 0, без частта за bool) и изпълни отново. Какво се случва? Кой предупреждава? Възстанови правилната версия и обясни със свои думи защо наивната пропуска true.

Упражнение 4 — Грешка, която списъкът няма

revisor превръща FileNotFoundError и TOMLDecodeError в ErrorDeConfiguracion, но не и UnicodeDecodeError. Предизвикай го с servicios.toml, записан в latin-1, друго кодиране, без да губиш своя. От папката на проекта: (1) запази резервно копие с cp servicios.toml servicios-bien.toml (cp копира файл; ако местоназначението вече съществува, го замества без да пита, така че избери име, което не използваш); (2) направи още едно копие, cp servicios.toml acento.toml, и в acento.toml, точно под url на първата услуга, добави реда descripcion = "catálogo de productos"; (3) преобразувай това копие в latin-1 и го запиши като servicios.toml: iconv -f utf-8 -t latin1 acento.toml > servicios.toml (iconv променя кодирането на един текст, а > записва изхода му във файла, изтривайки преди това всичко, което е имало: затова стъпка 1). Изпълни revisor и прочети доклада отдолу нагоре. После разшири leer_servicios, така че и тази грешка да се превръща в ErrorDeConfiguracion, със съобщение, което казва какво е станало, и изпълни отново revisor със същия servicios.toml в latin-1, за да видиш своето съобщение вместо traceback. Това съобщение не е в таблицата на проекта: е практическо разширение и проектът на следващите уроци не го включва. Едва накрая, когато вече си видял своето съобщение, върни конфигурацията си с cp servicios-bien.toml servicios.toml.

Решения

Решение 1

Двете нови услуги в servicios.toml, в края на файла:

[[servicios]]
nombre = "facturacion"
url = "https://facturacion.example"

[[servicios]]
nombre = "notificaciones"
url = "https://notificaciones.example"
timeout_ms = 1000

И двата нови записа на SIMULADO в revision.py, след този на reportes:

    "facturacion": (503, 95, None),
    "notificaciones": (None, None, "tiempo agotado"),

Предсказание. Шест услуги. catalogo и pagos продължават да работят (2). inventario остава бавна (1). reportes и notificaciones не са отговорили, а facturacion е отговорила с 503, което правилото за класифициране (код, по-голям или равен на 400) отбелязва като паднала: това са 3 паднали. Средното се изчислява само с тези, които са отговорили: catalogo (142), inventario (2300), pagos (87) и facturacion (95), която е отговорила, макар и с грешка; сборът е 2624 и разделен на 4 дава 656. Очакван резултат: 6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms.

Резултат. При изпълнение на 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
facturacion CAIDO       95ms  503
notificacionesCAIDO          —  tiempo agotado

6 servicios · 2 arriba · 1 lento · 3 caídos · promedio 656ms

Предсказанието съвпада с обобщението. Странното е в таблицата: notificaciones е дълго 14 букви, а колоната с името има фиксирана ширина 12 ({nombre:<12}), така че името нахлува в следващата колона и се залепва за „CAIDO“. facturacion се побира на косъм: има 11 букви и остава един-единствен интервал. Това не е грешка на този урок: форматът е написан за четирите канонични имена. Урок 11 го решава, като изчислява ширината от най-дългото име. Важното в това упражнение е друго: добави две услуги, без да промениш нито един ред логика, а това беше целта на урока.

Решение 2

Всеки повреден файл и съобщението, което произвежда. За (б) името на файла се появява в съобщението, защото leer_servicios използва пътя, който получава; ако го изпробваш със servicios.toml, ще каже servicios.toml.

(а) Файл без списъка:

titulo = "nada"

Съобщение: error de configuración: falta la lista [[servicios]]

(б) Забравена затваряща кавичка:

[[servicios]]
nombre = "catalogo

Съобщение: error de configuración: roto.toml no es TOML válido: Illegal character '\n' (at line 2, column 19)

(в) Втората услуга няма име:

[[servicios]]
nombre = "catalogo"
url = "https://catalogo.example"

[[servicios]]
url = "https://pagos.example"

Съобщение: error de configuración: servicio 2: falta 'nombre'

(г) Адрес, който не е уеб, във втората услуга:

[[servicios]]
nombre = "catalogo"
url = "https://catalogo.example"

[[servicios]]
nombre = "archivos"
url = "ftp://archivos.example"

Съобщение: error de configuración: servicio 2: 'url' debe ser una dirección http:// o https:// válida

(д) Времеви лимит нула, в третата услуга:

[[servicios]]
nombre = "a"
url = "https://a.example"

[[servicios]]
nombre = "b"
url = "https://b.example"

[[servicios]]
nombre = "c"
url = "https://c.example"
timeout_ms = 0

Съобщение: error de configuración: servicio 3: 'timeout_ms' debe ser un entero mayor que 0

(е) Две услуги с едно и също име:

[[servicios]]
nombre = "catalogo"
url = "https://catalogo.example"

[[servicios]]
nombre = "catalogo"
url = "https://otro.example"

Съобщение: error de configuración: nombres repetidos: catalogo

Три подробности за момента, в който сравняваше. Грешката на TOML (б) е единствената, чийто текст не си писал ти: идва от tomllib и носи реда и колоната. В (а) списъкът не липсва наполовина: няма нищо и съобщението казва точно това. А в (е) грешката се открива след валидирането на всяка услуга поотделно, защото имената им са валидни едно по едно; дефектът е на целия списък. Съобщенията са получени чрез изпълнение на всеки файл с модула от този урок.

Решение 3

С правилното валидиране съобщението е обичайното: error de configuración: servicio 1: 'timeout_ms' debe ser un entero mayor que 0.

С наивната версия revisor не се оплаква: отпечатва нормалната си таблица и обобщението излиза без проблем. Servicio е останал с timeout_ms=True и тъй като True е числото 1, времевият лимит, който програмата вярва, че има, е една милисекунда. Никой не предупреждава: това е грешка, която не вдига шум, и ще се появи в урок 9, когато revisor използва този времеви лимит: с една милисекунда изчакване времето на почти всяка истинска заявка би изтекло и услугата би излязла паднала по „tiempo agotado“ („изтекло време“) без видима причина. Получава се, защото в Python bool е вид цяло число (isinstance(True, int) е True), а наивното валидиране пита само това. Правилната версия добавя isinstance(timeout_ms, bool), за да отхвърли булевите стойности изрично.

Решение 4

При изпълнение с файла в latin-1 последният ред на доклада е:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe1 in position 85: invalid continuation byte

Байтът 0xe1 е „á“ от „catálogo“ в latin-1; позиция 85 е мястото, където пада във файла, броейки байтовете от 0: тя е тази, която излиза, ако си поставил реда там, където казва упражнението, и ще е друга, ако си го поставил на друго място. Редовете отгоре минават през read_text и стандартната библиотека: грешката се ражда вътре в четенето, не в твоя код, а ти я виждаш как се изкачва през твоите функции. За да се превърне, се добавя втори except в първата стъпка на leer_servicios:

    except FileNotFoundError as error:
        raise ErrorDeConfiguracion(f"no existe el archivo {ruta}") from error
    except UnicodeDecodeError as error:
        raise ErrorDeConfiguracion(f"{ruta} no está guardado en UTF-8") from error

С това revisor отпечатва error de configuración: servicios.toml no está guardado en UTF-8. Забележи, че UnicodeDecodeError е вид ValueError, но не е нужно да се прихваща ValueError: с конкретния тип ValueError от друга причина не се маскира.

Как разбирам, че съм успял

  • python fig06_01.py завършва с доклада за ValueError и можеш да кажеш коя функция коя е извикала, отдолу нагоре, без да гледаш урока.
  • python fig06_03.py казва, че „142“ не е число, и можеш да обясниш защо това е лъжа и кой ред от програмата има истинската грешка.
  • Във fig06_05.py виждаш фразата „The above exception was the direct cause of the following exception“ и знаеш коя инструкция от кода я е произвела.
  • От fig06_18/ python revisor.py отпечатва таблицата с четирите услуги с обобщението 4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms.
  • От папката отгоре python fig06_18/revisor.py отпечатва error de configuración: no existe el archivo servicios.toml и знаеш да обясниш защо.
  • Шестте повредени файла от упражнение 2 произвеждат шестте съобщения от таблицата и ти си ги предсказал преди да изпълниш.
  • python ver_json.py отпечатва JSON с null в tiempo_ms на reportes и с "promedio_ms": 843.
  • Можеш да обясниш защо isinstance(True, int) е True и кое валидиране на revisor го взема предвид.
  • Записа в своя дневник коя грешка от този урок ти е костала усилие да я прочетеш.

Обобщение

Отговори, без да гледаш урока:

  1. От коя страна се чете един доклад за грешка и какво казва последният му ред?
  2. Каква е разликата между else и finally на един try? Кой от двата се изпълнява, дори да има return?
  3. Защо не се пише except: без тип, нито except Exception: за всеки случай? Какво става с грешката, която не знаеш да обработиш?
  4. Какво прави raise ... from error и какво става в доклада, ако го пропуснеш?
  5. Какво гарантира един with? Защо се пише encoding="utf-8" винаги?
  6. Какво връща tomllib.loads? Каква е разликата между datos["servicios"] и datos.get("servicios"), ако ключът не съществува?
  7. Кои три неща правят различни json.dumps(x) и json.dumps(x, ensure_ascii=False, indent=2)? Защо се проваля json.dumps(Estado.CAIDO)?
  8. Защо isinstance(True, int) е True? Как се пише валидирането на положително цяло число, което отхвърля true?
  9. Защо схемата на един адрес се валидира със списък на разрешеното, а не със списък на забраненото?
  10. Какъв риск има eval с текст, който идва отвън, и кой формат се използва вместо него?

За допълнително четене

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx