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

Урок 7 — Пакет с команден ред

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

Време: 90 минути

Какво изграждаш: revisor като истински пакет: папка със структура, файл pyproject.toml, който го описва, команда с опции (--config, --formato, --version) и код на изход, който други програми могат да прочетат

Какво научаваш: структурата на пакет с разположение src/ (__init__.py, __main__.py, абсолютни импорти); python -m revisor; pyproject.toml ([build-system], [project], [project.scripts]); argparse; кодовете на изход; стандартния изход срещу изхода за грешки; и редактируемата инсталация с pip install -e .

Откъде идваш. Носиш проекта от Урок 6: един revisor, който вече не държи услугите написани в кода, а ги чете от servicios.toml, валидира ги и се оплаква на испански, когато файлът не става. Това са шест файла в една папка (revisor.py, modelo.py, revision.py, formato.py, configuracion.py и помощният скрипт ver_json.py) плюс servicios.toml. Работи, но с три ограничения, които днес си личат. Първо: работи само ако си застанал в тази папка и напишеш python revisor.py; модулите се намират един друг, защото са наредени един до друг, а името на конфигурационния файл е записано в константа на програмата. Второ: за да видиш отчета в JSON, трябва да изпълниш друг скрипт, защото програмата няма как да ѝ кажеш какво искаш. Трето: програмата не предупреждава никого, ако е намерила проблеми: завършва също толкова гладко, когато всичко работи, колкото и когато половината са паднали, а скриптът, планировчикът на задачи или системата за непрекъсната интеграция не могат да познаят разликата. Работиш в папката си ~/curso-python/revisor/, с виртуалната среда от Урок 1 активирана.

Какво не прави този урок. Не се обръща към никаква мрежа (данните си остават симулираните от урок 5; мрежата идва в Урок 9) и не пише тестове (това е Урок 8). Днес се решава как един набор от файлове се превръща в програма, която се инсталира, изпълнява се с една команда и общува с останалата част от системата.

За изпълненията. Всички изходи са получени с Python 3.14.8 и са повторени с Python 3.12.12, този, който идва с Linux Mint. Те са идентични с едно-единствено изключение, посочено там, където се случва: текстът на една грешка на argparse. Пътищата, които ще видиш в съобщенията, започват с /home/tu_usuario/curso-python/; твоят ще започва с твоята собствена папка. Целият проект е в programas/07-paquete-linea-de-comandos/fig07_01/, в хранилището на курса; двете отделни фигури (fig07_02.py и fig07_03.py) се изпълняват от папката на урока.

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

  • Обясняваш разликата между модул и пакет и защо кодът живее в src/revisor/, а не до pyproject.toml.
  • Изпълняваш пакет с python -m revisor и обясняваш какво прави __main__.py.
  • Четеш и пишеш pyproject.toml с трите му таблици: [build-system], [project] и [project.scripts].
  • Дефинираш опции на командния ред с argparse: една със стойност по подразбиране, една със затворен набор от стойности и една --version.
  • Караш програма да завърши с код на изход, който казва какво се е случило (0, 1 или 2), и го четеш в терминала с echo $?.
  • Пращаш съобщенията за грешки към изхода за грешки, а отчета към стандартния изход, и проверяваш разликата, като пренасочиш единия от двата.
  • Инсталираш пакета в редактируем режим с pip install -e . и обясняваш защо промяната в кода си личи без преинсталиране, а промяната в pyproject.toml не.

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

Помисли как би използвал revisor в реалния живот. Някой от екипа по операциите го пуска всяка сутрин от планировчик на задачи, за да разбере дали някоя услуга не е паднала през нощта. Този планировчик на задачи не гледа таблицата, която се отпечатва: той е програма и не умее да чете таблица. Единственото, което може да попита друга програма, когато тя свърши, е едно нещо: „излезе ли успешно, или не?“. В Linux този отговор е число, кодът на изход, а конвенцията е, че 0 означава „всичко е наред“, а всяко друго число означава „нещо се е случило“. Ако revisor не знае да отговори на този въпрос, автоматизацията е сляпа. А ако освен това човекът иска отчета в JSON, за да го подаде на друга система, трябва да може да го поиска с опция, а не като редактира файл.

Програма, която се изпълнява така, с опции, с код на изход и от коя да е папка, е програма с команден ред. За да бъде такава, ѝ трябва структура: не можеш да я направиш с шест разпръснати файла. Python има стандартен начин да я сглобиш, който се състои от три части: пакет (папка, която Python разпознава като единица, годна за импортиране), файл pyproject.toml, който описва проекта за инструментите, и команда за инсталиране, която оставя всичко достъпно в твоята среда. Това са трите раздела на урока, плюс онзи, който го завършва: как една програма общува с този, който я е извикал, с кодове на изход и с два различни текстови канала.

Урокът е механичен и това е справедливо предупреждение: има много имена на файлове и много правила, които се научават, като ги правиш, а не като ги четеш. Най-добрият начин да го следваш е да държиш терминала отворен в ~/curso-python/revisor/ и да изпълняваш всяка стъпка. В края, като изпълниш python -m revisor --formato json, ще видиш на екрана отчета в JSON, а като попиташ терминала как е излязла програмата с echo $?, ще видиш 1, защото сред услугите има една бавна и една паднала. В това изречение има много скрити неща (една опция, един формат, едно число, което излиза накрая), и урокът ги разплита едно по едно.

Понятията

7.1 Пакет с разположение src/

7.1.1 Модул, пакет и как се намират

Вече знаеш какво е модул: файл .py, чието съдържание друг файл може да импортира (урок 3). Досега твоят revisor има пет и те се импортират с инструкции като from modelo import Servicio. Това работи по една съвсем конкретна причина, която досега не беше назована: когато изпълняваш python revisor.py, Python търси модулите, които импортираш, в папката, където е този файл, а също и в папките на стандартната библиотека и в тези на виртуалната среда, където pip инсталира неща (папка на име site-packages). Този списък от места се обхожда по ред и той решава дали един import намира каквото търси, или се проваля с ModuleNotFoundError. Докато всичките ти файлове са заедно, всичко се намира. Щом нещо се премести или се изпълни от друго място, започва да се проваля.

Пакет е папка от модули, която Python третира като единица с име. Ако има папка revisor/ с файл __init__.py вътре и други модули, отвсякъде може да се напише from revisor.modelo import Servicio, което се чете „от пакета revisor, модула modelo, импортирай Servicio“. Точката разделя нивата. Този вид импортиране, който винаги започва с името на пакета, се нарича абсолютен импорт и е единственият, който използва този курс: най-ясен е за четене, защото всеки, който види реда, знае откъде идва името, без да трябва да гадае в коя папка е файлът. Разделът от учебното ръководство за пакетите обяснява механизма по-подробно.

7.1.2 Защо src/

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

revisor/                      ← la carpeta del proyecto (~/curso-python/revisor/)
  pyproject.toml              ← describe el proyecto
  servicios.toml              ← los datos, como en la lección 5
  src/
    revisor/                  ← el paquete
      __init__.py
      __main__.py
      cli.py
      modelo.py
      revision.py
      formato.py
      configuracion.py

Името revisor се появява два пъти и това не е недоглеждане: външната е папката на проекта (това, което изтегляш, което съдържа конфигурационните файлове, тестовете, които ще дойдат в урок 8), а вътрешната е пакетът (това, което Python импортира). Предимството на src/ е, че те принуждава да инсталираш пакета, преди да го използваш. Ако пакетът беше в корена, python -m revisor от тази папка щеше да го намери без инсталиране, защото текущата папка винаги е в списъка с места, където Python търси. А това крие грешки: можеш да имаш пакет, който работи в твоята папка, но след инсталиране му липсват файлове, и няма да разбереш, докато не го ползва някой друг. С src/ папката на пакета не е там, където Python търси по подразбиране, така че това, което изпълняваш, винаги е това, което си инсталирал. Ръководството за пакетиране обсъжда двата начина и техните компромиси; този курс възприема src/ без резерви.

7.1.3 __init__.py, __main__.py и python -m

Пакетът съдържа два файла, които не са част от твоята програма, а част от механизма. Първият е __init__.py. Самото му наличие казва на Python „тази папка е пакет“. Може да е празен; тук съдържа едно изречение в тройни кавички, което описва пакета и се нарича низ за документация (docstring):

# fig07_01/src/revisor/__init__.py
"""Revisa el estado de una lista de servicios y entrega un reporte."""

Вторият е __main__.py, който се изпълнява, когато някой напише python -m revisor. Опцията -m (от модул) казва на Python „не изпълнявай файл; изпълни този пакет като програма“. За един пакет това означава да се изпълни неговият __main__.py. Целият файл е това:

# fig07_01/src/revisor/__main__.py
from revisor.cli import main

raise SystemExit(main())

Първият ред импортира функцията main от модула cli (който ще видиш в 7.3). Вторият прави нещо, което прилича на формалност, но е сърцето на урока: raise SystemExit(main()). Вече знаеш какво е raise (урок 6): предизвиква изключение. SystemExit е специално изключение, което Python вече има и което никой не прихваща нарочно: когато стигне догоре, вместо да отпечата доклад за грешка, Python прекратява програмата, а стойността, с която го построиш, се превръща в код на изход. Така main() се изпълнява, връща число и това число се превръща в кода, с който програмата завършва. Ще го разгледаме подробно в 7.4; засега е достатъчно да знаеш, че -m търси именно това парче. Документацията за __main__ и тази за опцията -m го разказват подробно.

7.1.4 Преместване на файловете

Четирите модула, които вече имаше (modelo.py, revision.py, formato.py, configuracion.py), преминават в пакета без да промениш нито една функция: сигнатурите са същите като в урок 6. Променят се само импортите им, които престават да бъдат from modelo import ... и стават from revisor.modelo import .... Плоският revisor.py става cli.py и се пренаписва в 7.3. А скриптът ver_json.py изчезва, защото --formato json го замества. От папката на проекта, с активирана среда, стъпките са тези. Те използват три команди на терминала, които не си виждал, и е добре да знаеш какво прави всяка, преди да я напишеш:

  • mkdir -p src/revisor създава папката src и вътре в нея revisor, наведнъж. Без -p, mkdir би отказал да създаде revisor, защото src още не съществува; с -p (parents, „родителите“) създава и липсващите междинни папки и не се оплаква, ако вече са съществували.
  • mv origen destino (move) премества файлове или им сменя името. mv modelo.py revision.py formato.py configuracion.py src/revisor/ премества четирите файла в тази папка (наклонената черта накрая казва, че местоназначението е папка). mv revisor.py src/revisor/cli.py премества и преименува едновременно. Ако в местоназначението вече има файл с това име, mv го заменя без да пита.
  • rm archivo (remove) изтрива файл и не го праща в кошчето: не се възстановява. Тук изтрива ver_json.py, който вече не трябва, и накрая копията .bak, които оставя sed. Знакът * в src/revisor/*.bak означава „всички файлове от тази папка, които завършват на .bak“; преди rm със *, един ls със същия шаблон ти показва точно какво ще се изтрие.

Ето стъпките:

mkdir -p src/revisor
mv modelo.py revision.py formato.py configuracion.py src/revisor/
mv revisor.py src/revisor/cli.py
rm ver_json.py
sed -i.bak -E 's/^from (modelo|formato|configuracion|revision) import/from revisor.\1 import/' src/revisor/*.py
rm src/revisor/*.bak

Командата sed е текстов редактор без прозорец: търси във всеки файл редовете, които започват с from modelo import (или formato, configuracion, revision), и им добавя отпред revisor.. Параметърът -i.bak я моли да запази резервно копие на всеки файл с окончание .bak, което се изтрива веднага. Ако предпочиташ да не я използваш, редактирай на ръка редовете, които казват from modelo import, и провери с това какво трябва да остане във всеки файл:

Файл Импорти в края
revision.py from revisor.modelo import Resultado, Servicio, clasificar
formato.py import json и from revisor.modelo import Estado, Resultado
configuracion.py from revisor.modelo import Servicio, nombres_repetidos (плюс тези от стандартната библиотека, без промяна)
modelo.py не импортира нищо от твоя проект

cli.py се пренаписва изцяло в 7.3. Още не можеш да изпълниш нищо: остава да създадеш трите нови файла (__init__.py, __main__.py и pyproject.toml) и да напишеш cli.py. Продължи да четеш.

7.2 pyproject.toml: проектът, описан за инструментите

Един пакет сам по себе си е просто папка. За да знае pip как да го инсталира, трябва да се опише: как се казва, коя версия е, коя версия на Python му трябва, коя команда създава. Това се пише във файл на име pyproject.toml в корена на проекта. Това е TOML файл, форматът, който се научи да четеш в урок 6, и е официалният механизъм: ръководството за пакетиране го описва като мястото, където отиват метаданните на един проект на Python. Ето този на revisor:

[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "revisor"
version = "0.6.0"
description = "Revisa el estado de una lista de servicios"
requires-python = ">=3.12"
dependencies = []

[project.scripts]
revisor = "revisor.cli:main"

Има три таблици и всяка отговаря на различен въпрос.

[build-system]: с какво се изгражда? Инсталирането на пакет, дори да е твой и локален, включва стъпка на изграждане: чете се pyproject.toml, събират се файловете, получава се нещо инсталируемо. Тази работа я върши инструмент, наречен бекенд за изграждане (build backend), и проектът трябва да каже кой иска. requires изброява какво трябва да се изтегли, за да се изгради, а build-backend назовава бекенда. Тук се използва hatchling, този от официалното ръководство за пакетиране, и се иска версия 1.27 или по-нова (>=1.27). Има една подробност, която е добре да знаеш отнапред: първия път, когато инсталираш пакета, pip изтегля hatchling от интернет във временна среда, за да го изгради. Това е зависимост за изграждане, а не на твоята програма: revisor продължава да не зависи от нищо, за да работи. Но без връзка инсталацията би се провалила на тази стъпка. Правилата на тази таблица са в PEP 518, а интерфейсът на бекендите е в PEP 517; hatchling има своя собствена документация.

[project]: какво представлява този проект? Тук са данните, които всеки би очаквал в описанието на един пакет. name е името, с което се инсталира. version е версията: курсът я качва с една стъпка във всеки урок от днес нататък (0.6.0 е днешната, а урок 11 я оставя на 1.0.0). description е едно изречение. requires-python = ">=3.12" казва кои версии на Python могат да го изпълняват: pip ще откаже да го инсталира в по-стар Python, вместо да го остави да се провали по-късно със SyntaxError, който не се разбира. А dependencies = [] е списъкът с пакети на трети страни, от които програмата ти се нуждае, за да работи: днес няма нито един и затова е празен. В урок 9 ще се появи един. Стандартът, който определя тези полета, е PEP 621.

[project.scripts]: коя команда създава? Това е таблицата, която превръща един пакет в инструмент. Редът revisor = "revisor.cli:main" казва: „при инсталиране създай в средата команда на име revisor и когато някой я изпълни, нека извика функцията main на модула revisor.cli“. Формата paquete.modulo:función е конвенция, която фиксира спецификацията за входните точки. Когато го инсталираш, командата revisor ще застане до python на твоята среда и ще работи също като python -m revisor. И отново: стойността, която main връща, става код на изход на командата.

Копирай съдържанието в pyproject.toml, до servicios.toml, в папката на проекта. Още не можеш да инсталираш: липсва центърът на програмата.

7.3 argparse: опции на командния ред

7.3.1 Какво получава една програма от терминала

Когато напишеш python -m revisor --formato json, терминалът разделя написаното на думи и ги предава на програмата като списък от текстове, който Python пази в sys.argv. В този списък първата позиция е името на програмата, а останалите са аргументите: ["fig07_02.py", "--formato", "json"]. Можеш да го обходиш сам, с for, и да търсиш на ръка --formato и текста след него. Но това бързо омръзва: какво става, ако потребителят напише опцията два пъти?, ако забрави стойността?, ако напише --formato=json със знак за равенство? За да не пишеш тези правила наново във всяка програма, стандартната библиотека предлага модула argparse, който ги решава, сам генерира помощта и валидира това, което потребителят пише.

Виж една малка програма, която го показва, fig07_02.py. Декларира две опции и ги отпечатва:

# fig07_02.py
import argparse

parser = argparse.ArgumentParser(description="Demostración de argparse.")
parser.add_argument("--config", default="servicios.toml")
parser.add_argument("--formato", choices=["tabla", "json"], default="tabla")

argumentos = parser.parse_args()
print(f"config = {argumentos.config}")
print(f"formato = {argumentos.formato}")

argparse.ArgumentParser(description=...) създава анализатора (parser): обекта, който ще умее да тълкува командния ред. Всяко add_argument декларира една опция: нейното име (с две тирета отпред, конвенцията за дългите опции), стойност по подразбиране (default) и, във втората, choices, затвореният списък от позволени стойности. Накрая parse_args() чете sys.argv, проверява всичко и връща обект с по един атрибут за всяка опция, така че argumentos.formato има стойността, която е написал потребителят, или тази по подразбиране, ако не е написал нищо. Изпълни я без опции, а после с тях:

$ python fig07_02.py
config = servicios.toml
formato = tabla
$ python fig07_02.py --formato json --config otro.toml
config = otro.toml
formato = json

Без опции важат стойностите по подразбиране. С тях важат тези, които си написал, в какъвто ред искаш. Но ценното е това, което argparse прави, без да го искаш. Първо, помощта: -h и --help съществуват автоматично.

$ python fig07_02.py --help
usage: fig07_02.py [-h] [--config CONFIG] [--formato {tabla,json}]

Demostración de argparse.

options:
  -h, --help            show this help message and exit
  --config CONFIG
  --formato {tabla,json}

Тя се съставя от това, което си декларирал: употребата, описанието и всяка опция с нейния списък от стойности. (Фиксираните думи на argparse, като „options“ или „show this help message and exit“, са на английски, защото идват от библиотеката; текстовете, които ти пишеш с help= и description=, излизат така, както си ги написал, и точно това ще правим в revisor.) Второ, грешките: ако потребителят напише нещо, което не си декларирал, argparse го отхвърля със съобщение и прекратява програмата:

$ python fig07_02.py --verbose
usage: fig07_02.py [-h] [--config CONFIG] [--formato {tabla,json}]
fig07_02.py: error: unrecognized arguments: --verbose

Съобщението казва какво се е провалило (unrecognized arguments: --verbose, „неразпознати аргументи“) и повтаря реда за употреба, за да види потребителят как е правилната форма. Освен това прекратява програмата с код на изход, различен от нула, и по-точно 2: документацията на argparse го установява за всяка грешка при употреба. Това 2 ще бъде кодът на revisor, когато употребата е невалидна.

7.3.2 cli.py на revisor

С това в ръка централният модул на пакета се чете без изненади. cli.py („command-line interface“, интерфейсът на командния ред) замества плоския revisor.py от урок 6. Има две функции: едната сглобява анализатора, а другата е самата програма. Прочети го веднъж цял:

# fig07_01/src/revisor/cli.py
import argparse
import sys
from importlib.metadata import version
from pathlib import Path

from revisor.configuracion import ErrorDeConfiguracion, leer_servicios
from revisor.formato import encabezado, linea, reporte_a_json, resumen
from revisor.modelo import Estado
from revisor.revision import revisar_todos


def construir_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="revisor",
        description="Revisa el estado de una lista de servicios.",
    )
    parser.add_argument(
        "--config",
        type=Path,
        default=Path("servicios.toml"),
        help="archivo TOML con la lista de servicios (por omisión: servicios.toml)",
    )
    parser.add_argument(
        "--formato",
        choices=["tabla", "json"],
        default="tabla",
        help="forma del reporte (por omisión: tabla)",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"revisor {version('revisor')}",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    argumentos = construir_parser().parse_args(argv)

    try:
        servicios = leer_servicios(argumentos.config)
    except ErrorDeConfiguracion as error:
        print(f"error de configuración: {error}", file=sys.stderr)
        return 2

    resultados = revisar_todos(servicios)
    if argumentos.formato == "json":
        print(reporte_a_json(resultados))
    else:
        print(encabezado())
        for resultado in resultados:
            print(linea(resultado))
        print()
        print(resumen(resultados))

    problemas = [r for r in resultados if r.estado is not Estado.ARRIBA]
    if len(problemas) == 0:
        return 0
    return 1

construir_parser връща анализатора, с три опции. --config получава пътя до конфигурационния файл; type=Path казва на argparse да превърне текста, който потребителят напише, в Path (урок 6), а стойността му по подразбиране е Path("servicios.toml"), същата както винаги. --formato има две затворени възможности, tabla и json, и по подразбиране tabla: ако потребителят напише нещо друго, argparse го отхвърля, преди останалата част от main да види стойността, и не е нужно да пишеш if за това. А --version използва action="version", предефинирано действие, което отпечатва текст и прекратява: текстът е revisor, следван от номера на версията, който се чете от метаданните на инсталирания пакет с importlib.metadata.version("revisor"). Това решава много често срещан проблем: номерът на версията би се написал в pyproject.toml и още веднъж в кода и двете копия накрая биха се разминали. Така той съществува на едно-единствено място, в pyproject.toml. Аргументът prog="revisor" фиксира името, което излиза в употребата и в грешките; без него в Python 3.12 би излязло __main__.py, файлът, който се изпълнява с -m, а в 3.14 би излязло python -m revisor; с prog двете версии казват едно и също.

main е програмата. Получава незадължителен списък от аргументи, argv: list[str] | None = None, и връща цяло число. Ако argv е None, parse_args(None) чете истинския команден ред; ако му подадеш списък, използва него. Този параметър днес прилича на лукс, а е ключово парче в урок 8, където тестовете ще викат main(["--formato", "json"]), без да докосват терминала. Тялото е това, което вече познаваш: чете услугите с leer_servicios(argumentos.config) и ако се провали с ErrorDeConfiguracion, отпечатва съобщението и връща 2. Има две разлики спрямо урок 6 и те са двете идеи на 7.4: съобщението вече излиза с file=sys.stderr (към изхода за грешки, а не към стандартния) и връща 2, вместо да почисти и да продължи. После проверява услугите и според --formato отпечатва обичайната таблица или JSON от reporte_a_json. Накрая решава кода на изход: построява списъка от резултатите, чието състояние не е ARRIBA (операторът is not е отрицанието на is, урок 3), и ако е празен, връща 0, а ако не, 1.

За да е ясно, кодовете на изход на revisor са тези три, за целия курс:

Код Какво означава
0 Всичко е ARRIBA.
1 revisor проработи, но някоя услуга е LENTO или CAIDO.
2 revisor не успя да провери: употребата е невалидна (решава го argparse) или конфигурацията не става.

Разликата между 1 и 2 е много важна. Едно 1 е отговор: „проверих и намерих проблеми“. Едно 2 е повреда на самата програма: „не можах дори да проверя“. Който автоматизира revisor, ще ги третира различно: при 1 ще предупреди този, който се грижи за услугите; при 2 ще предупреди този, който поддържа revisor.

7.4 Кодове на изход, два текстови канала и редактируемата инсталация

7.4.1 Кодовете на изход

Всяка програма, която завършва, оставя на този, който я е извикал, цяло число между 0 и 255. Това е кодът на изход (exit status). По конвенция на Unix 0 означава успех, а всяко друго число означава някакъв вид неуспех. Няма нищо магическо: това е споразумение и цялата екосистема на терминала го спазва. В терминала (технически, командния интерпретатор, който в Linux Mint е bash) можеш да попиташ кода на последната команда със специалната променлива $?, която се чете с echo $?. Стандартът POSIX определя как я тълкува командният интерпретатор в раздела за изходното състояние и именно това позволява да се пишат неща като revisor && echo "todo bien" (echo се изпълнява само ако revisor е завършил с 0) или revisor || avisar.sh (втората се изпълнява само ако първата е завършила с нещо, различно от 0).

В Python има два начина да излезеш с код. Единият е да извикаш sys.exit(codigo), което вдига SystemExit. Другият е това, което вече видя в __main__.py: да вдигнеш SystemExit сам. Правят едно и също, а документацията на sys.exit и тази на SystemExit го обясняват. Важното е дизайнът: main връща кода, вместо сам да вика sys.exit, а __main__.py го превръща в изход. Така main може да се вика от друг код (например от тест) и да върне числото си, без да убива процеса, който го е извикал. Когато някой инсталира пакета, командата revisor, която създава [project.scripts], прави същото: вика main() и излиза с каквото той върне.

7.4.2 Два канала: стандартният изход и изходът за грешки

Една програма има, от момента на стартиране, две места, където да пише текст. Стандартният изход (stdout) е нормалният канал: това, което print пише по подразбиране, и където отиват резултатите. Изходът за грешки (stderr) е отделен канал за предупрежденията и грешките. В терминала и двата се появяват на екрана, един до друг, и затова изглеждат като едно и също. Но са различни канали и тази разлика се забелязва щом пренасочим единия. Виж fig07_03.py, който пише в двата:

# fig07_03.py
import sys

print("reporte: 4 servicios revisados")
print("aviso: reportes no respondió", file=sys.stderr)

print(..., file=sys.stderr) праща текста към изхода за грешки вместо към стандартния; sys е модул на стандартната библиотека, който дава достъп до неща на самия интерпретатор, между тях sys.stderr (документация). Изпълни я три пъти:

$ python fig07_03.py
reporte: 4 servicios revisados
aviso: reportes no respondió
$ python fig07_03.py > /dev/null
aviso: reportes no respondió
$ python fig07_03.py 2> /dev/null
reporte: 4 servicios revisados

Първото показва двата реда. Второто използва >, който пренасочва стандартния изход към друго място (тук /dev/null, специален файл на Linux, който отхвърля всичко, което му се пише): остава само предупреждението, което пътува по другия канал. Третото използва 2>, който пренасочва канал номер 2, този за грешки, и пропуска отчета. (Каналите имат номера: 0 е входът, 1 е стандартният, 2 е този за грешки.) Пренасочванията на командния интерпретатор са механизмът, който го позволява.

Това е, което прави разделянето полезно. Когато някой изпълни revisor --formato json > reporte.json, иска файлът reporte.json да съдържа само JSON. Ако програмата пишеше съобщение за грешка по стандартния изход, файлът би останал с размесен текст и би престанал да бъде валиден JSON. С грешката в stderr съобщението излиза на екрана, файлът остава чист (или празен, ако програмата не е стигнала да произведе нищо), а кодът на изход разказва какво се е случило. Затова cli.py праща грешките в конфигурацията към stderr, а отчета към stdout.

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

$ python -m revisor --config no-existe.toml
error de configuración: no existe el archivo no-existe.toml
$ echo $?
2

7.4.3 Инсталиране в редактируем режим

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

$ python -m revisor
/home/tu_usuario/curso-python/.venv/bin/python: No module named revisor

Съобщението, No module named revisor, е това, което се очакваше от 7.1.2: Python потърси модул на име revisor в текущата папка и в средата и не го намери, защото пакетът е вътре в src/, папка, където не търси. (Пътят в началото е този на python от твоята виртуална среда; твоят ще бъде този на твоята папка.) Това не е грешка в твоя код: това е, което src/ предизвиква нарочно, и се решава с инсталиране.

Инсталирането става с pip, вече вътре в средата, от папката на проекта (където е pyproject.toml):

python -m pip install -e .

Точката накрая означава „проектът в тази папка“, а -e („editable“) иска редактируема инсталация. Ще видиш няколко реда за напредъка, които се менят от една версия на pip към друга, а последният казва нещо такова:

Successfully installed revisor-0.6.0

Ако съобщението е друго, прочети причината: най-често не си в папката, която има pyproject.toml, или нямаш връзка, за да изтегли pip hatchling.

Какво означава „редактируема“? Обикновената инсталация копира файловете на пакета в папката site-packages на средата и оттам нататък инсталираният пакет и работната ти папка са две различни неща: ако редактираш кода си, инсталираният не разбира. Редактируемата инсталация, напротив, оставя в средата указател към твоята папка src/, така че Python импортира кода оттам, където е, точно както си го написал (PEP 660; документацията на pip обяснява двата вида локална инсталация). Това искаш, докато разработваш: променяш ред, изпълняваш и виждаш промяната, без да преинсталираш. С инсталиран пакет го изпълни; този път кода на изход го отпечатва echo $?:

$ python -m revisor
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
$ echo $?
1

Ето обичайната таблица, с обобщението, а кодът на изход е 1, защото сред четирите услуги има една LENTO (inventario) и една CAIDO (reportes). Това е поведението, което се искаше: програмата проработи и предупреждава, че е намерила проблеми. Опцията --formato json произвежда отчета за машини, който е договорът, фиксиран от урок 6:

$ python -m revisor --formato json
{
  "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
  }
}
$ echo $?
1

Опцията --version чете номера от инсталацията и работи, защото си инсталирал пакета:

$ python -m revisor --version
revisor 0.6.0

А --help ти дава помощта, съставена от текстовете, които си написал в help=:

$ python -m revisor --help
usage: revisor [-h] [--config CONFIG] [--formato {tabla,json}] [--version]

Revisa el estado de una lista de servicios.

options:
  -h, --help            show this help message and exit
  --config CONFIG       archivo TOML con la lista de servicios (por omisión:
                        servicios.toml)
  --formato {tabla,json}
                        forma del reporte (por omisión: tabla)
  --version             show program's version number and exit

Остава половината от това, което обещаваше [project.scripts]: командата revisor. Тя се създаде при инсталирането, до python на средата, и с активна среда се изпълнява без python -m:

$ revisor --version
revisor 0.6.0

Това, което прави -m, е полезно, защото работи винаги, дори командата да не е на показ. Но командата е начинът, по който хората ще използват revisor в урок 11, инсталиран с pipx.

Две уточнения за това, което „редактируема“ не покрива. Първото: следва само кода, не и метаданните. Ако промениш съдържанието на един модул, промяната се вижда веднага. Но ако промениш pyproject.toml (например версията или команда от [project.scripts]), средата остава с метаданните от последната инсталация, докато не изпълниш отново python -m pip install -e .. Упражнение 4 го проверява. Второто: редактируемата инсталация живее в твоята виртуална среда, така че нова среда (или току-що създадена) няма revisor, докато не го инсталираш наново. Това е точно поведението, което се иска: никоя инсталация не се смесва с Python на системата.

Незадължителна рамка: uv. Съществува по-нов инструмент, uv, който сам върши работата на venv и на pip и е по-бърз. С него същите стъпки се пишат uv venv (създава средата .venv) и uv pip install -e . (инсталира проекта в нея). Освен това може да инсталира други версии на Python: uv python install 3.14 сваля 3.14, без да докосва Python на системата, което е най-удобният начин да изпробваш този курс на най-новата версия освен на 3.12 на Mint. Ръководството за инсталиране на uv обяснява как да го получиш, а документацията му за версиите на Python и за интерфейса uv pip покрива останалото. Този курс не го изисква: всичко, което виждаш тук, работи с python и pip, които вече имаш.

7.4.4 Стъпката на проекта: сглобяване на всичко

Ако си следвал урока, папката ти ~/curso-python/revisor/ вече изглежда като в 7.1.2. Ето пълните стъпки, подред, за този, който ги иска наведнъж:

  1. Създай src/revisor/ и премести в нея четирите модула; преименувай revisor.py на cli.py и изтрий ver_json.py (7.1.4).
  2. Добави revisor. отпред на импортите на твоите модули (7.1.4).
  3. Напиши src/revisor/__init__.py и src/revisor/__main__.py (7.1.3).
  4. Напиши pyproject.toml (7.2).
  5. Пренапиши src/revisor/cli.py с construir_parser и main (7.3.2).
  6. С активирана среда и от папката на проекта изпълни python -m pip install -e . (7.4.3).
  7. Изпробвай: python -m revisor, python -m revisor --formato json, python -m revisor --version и след всяка, echo $?.

Има едно следствие от това, което току-що направи, което се вижда, като го изпълниш от друга папка. Качи се в папката отгоре и изпълни същата команда:

$ python -m revisor
error de configuración: no existe el archivo servicios.toml

Пакетът се намери (инсталиран е в средата и вече няма значение къде си), но конфигурационният файл не: --config е servicios.toml по подразбиране, относителен път, а папката отгоре няма такъв. Съобщението е това, което вече познаваш от урок 6, сега по изхода за грешки и с код на изход 2. Опцията --config съществува точно за това: python -m revisor --config revisor/servicios.toml би работило оттам. Къде живее кодът и къде живее конфигурацията са два различни въпроса и програмата ги разделя.

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

Този урок има две основни грешки, и двете от списъка отгоре, и две помощни, които се появяват, когато нещо от сглобяването е забравено.

No module named revisor. Вече я видя в 7.4.3: изпълнение на python -m revisor преди pip install -e .. Причината е структурата src/, а поправката е да инсталираш. Ако вече си инсталирал и все пак се появява, провери дали средата е активирана: съобщението започва с пътя на използвания python и ако този път е /usr/bin/python3 вместо този на твоя .venv, използваш Python на системата, който няма твоя пакет.

Грешката при употреба на argparse. Поискай формат, който не съществува:

$ python -m revisor --formato xml
usage: revisor [-h] [--config CONFIG] [--formato {tabla,json}] [--version]
revisor: error: argument --formato: invalid choice: 'xml' (choose from 'tabla', 'json')
$ echo $?
2

Прочети двата реда: първият повтаря употребата, с опциите, които съществуват, и техните позволени стойности във фигурни скоби ({tabla,json}); вторият казва кой аргумент се е провалил и защо: invalid choice: 'xml', „невалиден избор“. В скоби е списъкът на валидните. Кодът на изход е 2, този, който argparse използва винаги при невалидна употреба. Една подробност за версията: това е текстът на Python 3.14, който показва опциите в кавички, ('tabla', 'json'). В Python 3.12 списъкът излиза без кавички, (choose from tabla, json). Това е единственият изход в урока, който се различава между двете версии, и е промяна във формата на текста; значението е същото.

ModuleNotFoundError: No module named 'modelo'. Появява се, ако си преместил файловете, но си забравил да промениш някой импорт: пакетът се намира, но един от модулите му още импортира modelo без нищо отпред, а вече няма отделен modelo. Докладът, съкратен:

Traceback (most recent call last):
  ...
  File "src/revisor/__main__.py", line 2, in <module>
    from revisor.cli import main
  File "src/revisor/cli.py", line 9, in <module>
    from modelo import Estado
ModuleNotFoundError: No module named 'modelo'

Чете се отдолу нагоре, както винаги: последният ред казва кой модул не е намерен (modelo); редът над него, в кой файл и на кой ред е import-ът, който се проваля (cli.py, ред 9); а по-нагоре, веригата от това кой кого е импортирал. Поправката е from revisor.modelo import Estado. (Номерът на реда ще зависи от начина, по който си написал cli.py; важното е името на модула в последния ред.)

importlib.metadata.PackageNotFoundError: No package metadata was found for revisor. Появява се, ако изпълниш кода, без да си инсталирал пакета (например като си добавил src на ръка към списъка с места за търсене). Модулите се намират, но version("revisor"), което construir_parser изпълнява винаги, търси метаданните на една инсталация, а няма никаква. Това е доказателството, че номерът на версията идва от инсталацията, а не от кода: решението е python -m pip install -e ., а не да скриеш реда. Докладът завършва така:

importlib.metadata.PackageNotFoundError: No package metadata was found for revisor

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

Импортиране без името на пакета вътре в пакет. from modelo import Servicio работи, докато файловете са един до друг, и се проваля щом кодът се инсталира или се изпълни с -m. Вътре в пакета винаги се пише from revisor.modelo import Servicio.

Поправяне на импортите чрез промяна на sys.path. Това е рецепта, която обикаля: да добавиш на ръка папката на кода към списъка с места за търсене, за да заработят import-ите. Решава симптома и крие причината, която е, че пакетът не е инсталиран. С pip install -e . не е нужно.

Инсталиране с pip извън виртуалната среда. Вече знаеш защо не работи в Linux Mint (урок 1): Python на системата няма pip (No module named pip), а където го има, защитата на PEP 668 го спира с externally-managed-environment. А излизането, което понякога се предлага, да го наложиш със сила, чупи инструментите на самата система. Инсталира се вътре в .venv, винаги.

Писане на номера на версията на две места. Едно в pyproject.toml и друго в константа в кода: рано или късно се разминават и версията, която програмата казва, вече не е тази, която е инсталирана. Номерът живее в pyproject.toml, а програмата го чете с importlib.metadata.

Пращане на грешките към стандартния изход. Смесва предупреждението с отчета и revisor --formato json > reporte.json произвежда файл, който не е валиден JSON. Грешките, към sys.stderr.

Завършване винаги с код 0. Програма, която излиза еднакво гладко при всичко паднало и при всичко работещо, не върши работа на никакъв автоматизъм. Кодът на изход е част от договора на програмата, колкото и форматът на отчета.

Измисляне на кодове на изход без да се документират. Едно 3 за това, едно 4 за онова, без никой да ги знае, принуждава този, който автоматизира, да чете изходния код на програмата. Има три кода и са в таблица.

Четене на sys.argv на ръка. Работи, докато някой не напише --formato=json вместо --formato json, или не забрави стойността, или не поиска помощта. argparse вече е решил тези случаи.

Промяна на pyproject.toml без преинсталиране. Редактируемата инсталация следва кода, не метаданните. Ако промениш версията или команда и средата остава с предишното, липсва един python -m pip install -e ..

Упражнения

Упражнение 1 — Отчетът в JSON и неговият код

С инсталирания пакет изпълни python -m revisor --formato json и веднага след това echo $?. Преди да изпълниш, запиши какво очакваш да видиш: колко услуги има в масива servicios, каква стойност има tiempo_ms за reportes и какъв код на изход. После пренасочи изхода към файл с python -m revisor --formato json > reporte.json и виж съдържанието на reporte.json с cat reporte.json (cat отпечатва на екрана съдържанието на файл). Промени ли се кодът на изход при пренасочването?

Упражнение 2 — Трите кода

Предизвикай трите кода на изход на revisor, един по един, и ги провери с echo $?: (а) 0, за което ти трябва конфигурация, в която всички услуги са ARRIBA (напиши файл solo-arriba.toml с една-единствена услуга, pagos, и го използвай с --config); (б) 1, с обичайния servicios.toml; (в) 2 заради невалидна конфигурация, с roto.toml, в който има услуга без nombre; (г) още едно 2, този път заради невалидна употреба, със стойност на --formato, която не съществува. За всеки случай запиши първо какво очакваш да видиш и по кой канал се появява съобщението.

Упражнение 3 — Къде живее кодът и къде живее конфигурацията

Излез от папката на проекта с cd .. и изпълни python -m revisor. Какво съобщение получаваш и какъв код на изход? После изпълни същата команда, като подадеш пътя до конфигурацията с --config. Обясни със свои думи защо пакетът се намира от коя да е папка, а конфигурационният файл не.

Упражнение 4 — Какво следва и какво не следва редактируемата инсталация

С пакета, инсталиран в редактируем режим: (а) в src/revisor/formato.py смени думата promedio от обобщението с media и изпълни revisor без да преинсталираш: личи ли си промяната? Върни го както беше. (б) В pyproject.toml смени версията на 0.6.1 и изпълни python -m revisor --version без да преинсталираш: коя версия казва? (в) Изпълни python -m pip install -e . и поискай отново версията. (г) Върни версията на 0.6.0 и преинсталирай. Обясни разликата между (а) и (б).

Решения

Решение 1

Прогнозата: масивът servicios има четири елемента, по един на услуга; този на reportes има "tiempo_ms": null (не е отговорила, а None се записва null в JSON); а кодът на изход е 1, заради бавната и падналата услуга. Изходът на python -m revisor --formato json е този, който видя в 7.4.3, а echo $? отпечатва 1.

При пренасочването съдържанието на reporte.json е точно същият JSON: нищо от терминала не се е смесило с него, защото предупрежденията (ако има такива) вървят по друг канал. А кодът на изход не се променя: пренасочването мести текста, не резултата. Това е свойството, което позволява да се пише revisor --formato json > reporte.json || avisar.sh и || да продължи да работи.

Решение 2

(а) С една-единствена услуга, която отговаря добре:

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

Изпълнено с python -m revisor --config solo-arriba.toml:

SERVICIO    ESTADO    TIEMPO  DETALLE
pagos       ARRIBA      87ms  200

1 servicio · 1 arriba · 0 lentos · 0 caídos · promedio 87ms

и echo $? отпечатва 0. (Забележи, че обобщението казва „1 servicio“ и „0 lentos“: множественото число го решава бройката, както в урок 5.)

(б) С обичайния servicios.toml: таблицата с четири услуги и кодът 1.

(в) С roto.toml, чиято единствена услуга няма nombre:

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

Изпълнено с python -m revisor --config roto.toml, по изхода за грешки излиза съобщението error de configuración: servicio 1: falta 'nombre' („грешка в конфигурацията: услуга 1: липсва 'nombre'“), а кодът е 2. За да провериш канала, повтори командата с 2> /dev/null: на екрана не се появява нищо, защото единственото отпечатано вървеше по stderr; кодът на изход продължава да е 2.

(г) С python -m revisor --formato xml, грешката на argparse, която вече видя: върви по изхода за грешки, а кодът е 2.

Решение 3

От папката отгоре python -m revisor отпечатва, по изхода за грешки, error de configuración: no existe el archivo servicios.toml („грешка в конфигурацията: файлът servicios.toml не съществува“), а кодът на изход е 2. С python -m revisor --config revisor/servicios.toml програмата намира файла, отпечатва таблицата с четири услуги и излиза с 1.

Причината: пакетът се намира, защото е инсталиран в средата, а средата не зависи от папката, в която си. За разлика от него servicios.toml е файл с данни с относителен път, а относителният път винаги се брои от мястото, където си застанал. Това са два различни механизма за търсене: Python търси код в папките на средата; програмата търси конфигурацията там, където ѝ кажат. Затова съществува --config.

Решение 4

(а) Да, личи си: обобщението казва media 843ms вместо promedio 843ms, без преинсталиране, защото редактируемата инсталация импортира кода от твоята папка src/. (б) Не си личи: --version продължава да казва revisor 0.6.0, защото версията се чете от метаданните, запазени при последната инсталация, а редактирането на pyproject.toml не ги обновява. (в) След python -m pip install -e ., --version казва revisor 0.6.1. (г) Като върнеш версията на 0.6.0 и преинсталираш, отново казва revisor 0.6.0.

Разликата: кодът се импортира от мястото, където е, всеки път, когато Python стартира; метаданните (версия, команди) се копират в средата при инсталиране. Това, което -e избягва, е копирането на кода, а не на метаданните.

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

  • python -m revisor отпечатва таблицата с четири услуги с обобщението 4 servicios · 2 arriba · 1 lento · 1 caído · promedio 843ms, а echo $? отпечатва 1.
  • python -m revisor --formato json отпечатва JSON с "promedio_ms": 843, а python -m revisor --formato json > reporte.json оставя във файла валиден JSON и нищо друго.
  • python -m revisor --version отпечатва revisor 0.6.0, а командата revisor --version отпечатва същото.
  • python -m revisor --formato xml завършва с грешката на argparse и кода 2.
  • python -m revisor --config no-existe.toml отпечатва error de configuración: no existe el archivo no-existe.toml и завършва с 2; с 2> /dev/null на екрана не се вижда нищо.
  • Можеш да обясниш със свои думи защо python -m revisor се проваля с No module named revisor преди инсталирането и защо това е, което src/ цели.
  • Можеш да кажеш кой канал използва всяко съобщение на revisor (отчет, грешка в конфигурацията, грешка при употреба) и какъв код придружава всеки случай.
  • Записа в дневника си коя част от сглобяването (пътища, импорти, инсталиране) ти е струвала най-много.

Обобщение

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

  1. Каква е разликата между модул и пакет и какво прави една папка пакет?
  2. Какво предимство има слагането на пакета вътре в src/? Какво би станало с python -m revisor от корена, ако не беше там?
  3. Какво изпълнява python -m revisor и какво прави raise SystemExit(main())?
  4. На какъв въпрос отговаря всяка таблица на pyproject.toml: [build-system], [project] и [project.scripts]?
  5. Защо pip install -e . има нужда от интернет първия път, ако revisor няма зависимости?
  6. Какво връща parse_args()? Какво прави argparse, когато потребителят напише опция, която не съществува, и с какъв код завършва?
  7. Какво означават кодовете 0, 1 и 2 на revisor? Как се чете кодът на последната команда в терминала?
  8. Каква е разликата между стандартния изход и изхода за грешки? Какво правят > и 2>?
  9. Какво следва редактируемата инсталация и какво не? Как се обновява това, което не следва?
  10. Откъде идва числото, което отпечатва --version, и защо не се пише в кода?

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

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

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