k12. Мини-проект CLI «Fake GCS»
UAV Python Pro · Конспект K12 · Модуль M1. Python intensive: мини-проект CLI «Fake GCS» · Редакция 1.0 · После CHECKPOINT-K11 · Сдача гейта G1
За одиннадцать контрольных точек вы накопили полный набор инструментов: типы и коллекции, циклы и функции, модули и пакеты, файлы JSON и CSV, исключения и журналирование, классы с dataclass и Enum, потоки и очереди, а затем автоматические тесты pytest с заглушками. По отдельности эти инструменты остаются упражнениями; профессией они становятся тогда, когда собираются в одну работающую программу. На этом уроке вы соберёте первый мини-проект — программу командной строки «Fake GCS», которая имитирует наземную станцию управления: читает конфигурацию из JSON-файла, ведёт виртуальный аппарат с prearm-проверками, получает телеметрию из канала-заглушки, сохраняет трек в CSV, пишет журнал и полностью покрыта тестами.
Мини-проект отличается от упражнения тем, что его части связаны между собой: парсер команд получает настройки из модуля конфигурации, модуль конфигурации задаёт пороги для модуля аппарата, модуль аппарата получает отсчёты из канала-заглушки, а тесты проверяют все слои — от чистой функции до всей программы целиком через вызов main(). Когда вы один раз соберёте такую структуру своими руками, переход к настоящему MAVLink в модулях M4–M5 пойдёт быстрее: вы замените один модуль (канал связи), а остальная архитектура программы останется прежней.
Содержание
- 1. Цели урока
- 2. Словарь урока
- 3. Задание мини-проекта: что такое Fake GCS
- 4. Структура проекта: файлы и роли
- 5. Новый инструмент: argparse и подкоманды
- 6. Конфигурация и состояние: JSON между запусками
- 7. Канал-заглушка FakeLink и CSV-трек
- 8. Аппарат, режимы и prearm-проверка
- 9. Логирование мини-проекта
- 10. Тесты: от чистых функций до CLI целиком
- 11. Типичные ошибки
- 12. Практика
- Задача 12.1. Модуль конфигурации
- Задача 12.2. Модуль аппарата
- Задача 12.3. Канал-заглушка и CSV
- Задача 12.4. Точка входа CLI
- Задача 12.5. Пример конфигурации
- Задача 12.6. Тесты мини-проекта
- Задача 12.7. Памятка команд
- Задача 12.8. Прогон CLI (обязательно)
- Задача 12.9. Прогон тестов (обязательно)
- Задача 12.10. Сохранение в Git
- 13. Проверьте себя
- 14. Чек-лист самопроверки
- 15. Гейт G1: критерии сдачи
- 16. Что дальше
1. Цели урока
После выполнения этого урока вы получите следующие результаты.
- Вы соберёте первый мини-проект из пяти файлов: конфигурация, аппарат, канал-заглушка, точка входа командной строки и тесты.
- Вы освоите модуль стандартной библиотеки
argparseи построите парсер с подкомандамиconfig,status,arm,disarm,modeиtele. - Вы сохраните состояние аппарата между запусками программы в JSON-файле с атомарной записью.
- Вы реализуете prearm-проверки перед командой
armи отказ через собственное исключениеPrearmError. - Вы запишете трек телеметрии в CSV-файл и настроите журналирование в файл и в консоль.
- Вы покроете мини-проект тестами
k12_test_app.py: от проверки конфигурации до запуска всего CLI через функциюmain()с фикстуройtmp_path. - Вы подготовите артефакты для сдачи гейта G1 — перехода из модуля M1 в модуль M2.
2. Словарь урока
| Термин | Простыми словами |
|---|---|
| CLI | Command line interface, интерфейс командной строки: программа получает команды текстом при запуске и печатает результат в консоль. |
| argparse | Модуль стандартной библиотеки Python: разбирает аргументы командной строки по правилам, которые описал программист, и сам рисует справку. |
| Подкоманда | Именованное действие CLI-программы, например status или arm. Образец — команды Git: git commit, git push. |
| Код возврата | Целое число, которое программа возвращает операционной системе при завершении: ноль означает успех, ненулевое значение — ошибку. |
| Файл конфигурации | JSON-файл с настройками программы: путь к журналу, пороги напряжения, имена файлов состояния и трека. |
| Файл состояния | JSON-файл, в котором программа хранит меняющиеся данные (взведён ли аппарат, режим, напряжение), чтобы они не пропадали между запусками. |
| Канал-заглушка | Объект, который имитирует канал связи с аппаратом: вместо настоящих байтов из порта выдаёт отсчёты телеметрии по известной формуле. |
| Prearm-проверка | Проверка обязательных условий перед взведением моторов: батареия, режим, отсутствие запретов. Не пройдена — взведение отклоняется. |
| Идемпотентная команда | Команда, повтор которой так же безопасен, как однократное выполнение: второй disarm ничего не ломает. |
| Интеграционный тест | Тест, который проверяет не одну функцию, а взаимодействие нескольких частей программы — например, весь запуск main() с аргументами. |
| Фикстура tmp_path | Встроенная фикстура pytest: создаёт для каждого теста уникальный временный каталог и передаёт его в тест как аргумент. |
3. Задание мини-проекта: что такое Fake GCS
«Fake GCS» — это учебная наземная станция управления (ground control station), которая обладает всеми внешними признаками настоящей программы, но не подключается ни к реальному полётному контроллеру, ни к симулятору SITL. Пользователь запускает её из командной строки, передаёт подкоманду и аргументы, получает напечатанный результат, запись в журнале и обновлённый файл состояния. Внутри программы живёт виртуальный аппарат: класс FakeVehicle хранит флаг взведения, режим полёта, напряжение батареи и счётчик отсчётов телеметрии.
Разделим честно, что в проекте настоящее, а что имитируется. Это разделение важно понимать, чтобы не перенести ложные ожидания на будущую работу с железом.
| Настоящее (остаётся с вами в следующих модулях) | Имитация (заменится в модулях M4–M5) |
|---|---|
| Структура программы: отдельные модули с ясными ролями | Канал связи: нет ни serial-порта, ни UDP-сокета, ни MAVLink |
| Конфигурация в JSON с проверкой и своим исключением | Телеметрия: числа получаются по формуле, а не с датчиков |
| Файл состояния с атомарной записью | Аппарат: класс в памяти и JSON-файл, а не автопилот ArduPilot |
| Логика prearm-проверок и кодов возврата | Команда arm: моторы не вращаются, их просто нет |
| Логирование в файл и в консоль | — |
| CSV-трек и автоматические тесты pytest | — |
Архитектура мини-проекта показана на схеме ниже. Точка входа k12_cli.py разбирает аргументы, загружает конфигурацию и вызывает нужные модули; тесты k12_test_app.py проверяют каждый модуль по отдельности и всю программу целиком.
k12_cli.py (точка входа: argparse, логирование, коды возврата)
│
├── k12_config.py (JSON-конфигурация: AppConfig, load_config, ConfigError)
├── k12_vehicle.py (FlightMode, TelemetrySample, FakeVehicle, PrearmError)
├── k12_link.py (FakeLink: канал-заглушка; save_track_csv: трек в CSV)
└── k12_test_app.py (pytest: конфигурация, аппарат, канал, CLI целиком)
на диске после работы:
k12_config_example.json (пример конфигурации, входит в репозиторий)
k12_gcs.log (журнал, артефакт запуска)
k12_state.json (состояние аппарата, артефакт запуска)
k12_track.csv (трек телеметрии, артефакт запуска)
FakeLink будут заменены на настоящий MAVLink через pymavlink, а внешние вызовы останутся прежними.4. Структура проекта: файлы и роли
Все файлы мини-проекта лежат в каталоге code/M01_python рядом с практиками предыдущих уроков. Такое плоское размещение — осознанный выбор для модуля M1: импорты вида from k12_config import load_config работают без дополнительной настройки путей, а команды запускаются из одного каталога. В крупных проектах модули группируют в пакеты (урок K06), и вы сделаете это позже, когда проект действительно вырастет.
| Файл | Роль в проекте | Что повторяет из пройденного |
|---|---|---|
k12_config.py |
Класс настроек AppConfig, функция load_config(), исключение ConfigError |
K07 (JSON), K08 (исключения), K09 (dataclass) |
k12_vehicle.py |
Режимы FlightMode, отсчёт TelemetrySample, класс FakeVehicle, исключение PrearmError |
K09 (Enum, dataclass, классы), K11 (метки батареи) |
k12_link.py |
Канал-заглушка FakeLink, запись трека save_track_csv() |
K07 (CSV), K08 (logging), K09 (property) |
k12_cli.py |
Точка входа: argparse, логирование, подкоманды, коды возврата |
K06 (if __name__ == "__main__"), K08 (журнал) |
k12_config_example.json |
Пример файла конфигурации | K07 (JSON) |
k12_test_app.py |
Семнадцать тестов: конфигурация, аппарат, канал, CLI | K11 (pytest, MagicMock, pytest.raises) |
k12_run_note.py |
Памятка: все команды мини-проекта в комментариях | K11 (файл-памятка) |
code/M01_python в терминале с активным виртуальным окружением. В PyCharm укажите этот каталог как Working directory в настройках конфигурации запуска — тогда импорты модулей k12_* и относительные пути к файлам будут работать предсказуемо.5. Новый инструмент: argparse и подкоманды
До сих пор ваши программы либо запускались без аргументов, либо спрашивали данные у пользователя через функцию input() (урок K03). Программы командной строки устроены иначе: параметры передаются при запуске текстом после имени программы. В команде git commit -m "текст" имя программы — git, подкоманда — commit, аргумент — -m "текст". Такую строку можно разбирать вручную: список sys.argv содержит слова команды, и их можно перебирать проверками if. Однако ручной разбор быстро ломается: справку, проверку типов, сообщения об ошибках и порядок аргументов придётся писать самостоятельно для каждой программы.
В стандартную библиотеку Python входит модуль argparse — он берёт эту работу на себя. Программист описывает правила: какие аргументы существуют, какого они типа, какое значение по умолчанию и какая справка к ним прилагается. Дальше argparse сам разбирает строку запуска, сам печатает справку по флагу --help и сам сообщает об ошибках.
Чистый код
# Пример: минимальный парсер аргументов с одной подкомандой
import argparse
parser = argparse.ArgumentParser(
prog="demo.py",
description="Учебная CLI-программа.",
)
parser.add_argument("--config", default="config.json", help="путь к конфигурации")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("status", help="показать состояние")
args = parser.parse_args(["--config", "my.json", "status"])
print(args.command) # status
print(args.config) # my.json
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
import argparse # → Модуль стандартной библиотеки: установка через pip не требуется. parser = argparse.ArgumentParser( prog="demo.py", description="Учебная CLI-программа.", ) # → Создаём парсер: объект, который знает правила разбора аргументов. # → prog — имя программы в справке, description — одна фраза о назначении. parser.add_argument("--config", default="config.json", help="путь к конфигурации") # → Описываем необязательный аргумент: на экране он виден как --config значение. # → default — значение, если аргумент не передали; help — строка справки. sub = parser.add_subparsers(dest="command", required=True) # → Регистрируем группу подкоманд. dest="command" означает: # → выбранная подкоманда попадёт в поле args.command. # → required=True запрещает запуск без подкоманды. sub.add_parser("status", help="показать состояние") # → Каждая подкоманда — свой маленький парсер со своей справкой. args = parser.parse_args(["--config", "my.json", "status"]) # → Разбираем аргументы. В списке переданы те же слова, # → которые пользователь набрал бы в терминале после имени программы. # → В тестах список передают вручную; при обычном запуске # → parse_args() без аргумента берёт строку запуска из sys.argv. print(args.command) # status print(args.config) # my.json # → Результат разбора — объект Namespace: «коробка с полями», # → где каждый описанный аргумент стал атрибутом с именем.
Метод parse_args() возвращает объект Namespace — простую «коробку с полями»: каждому описанному аргументу соответствует атрибут, к которому программа обращается через точку (args.command, args.config). Отдельно разберём три приёма, которые использует мини-проект: позиционный аргумент со списком допустимых значений, целочисленный аргумент и флаг-переключатель.
Чистый код
# Пример: позиционный аргумент с choices, число и флаг
mode_parser = sub.add_parser("mode", help="установить режим полёта")
mode_parser.add_argument(
"name",
choices=["STABILIZE", "LOITER", "RTL"],
help="имя режима",
)
tele_parser = sub.add_parser("tele", help="читать телеметрию")
tele_parser.add_argument("--count", type=int, default=5, help="число отсчётов")
tele_parser.add_argument("--csv", action="store_true", help="сохранить трек в CSV")
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
mode_parser = sub.add_parser("mode", help="установить режим полёта") # → Подкоманда mode получает собственный парсер. mode_parser.add_argument( "name", choices=["STABILIZE", "LOITER", "RTL"], help="имя режима", ) # → Аргумент без дефиса — позиционный: пользователь пишет его # → сразу после подкоманды, например: mode RTL. # → choices — список допустимых значений. При другом значении # → argparse сам напечатает ошибку и завершит программу с кодом 2. tele_parser = sub.add_parser("tele", help="читать телеметрию") # → Подкоманда tele — чтение отсчётов из канала-заглушки. tele_parser.add_argument("--count", type=int, default=5, help="число отсчётов") # → type=int превращает текст "5" в целое число 5. # → Если передать не число, argparse сообщит об ошибке сам. tele_parser.add_argument("--csv", action="store_true", help="сохранить трек в CSV") # → action="store_true" — флаг-переключатель: без значения. # → Флаг указан — в args.csv окажется True, не указан — False.
Официальное описание модуля argparse доступно в документации Python по адресу https://docs.python.org/3/library/argparse.html. Курс сознательно выбирает argparse, а не сторонние библиотеки вроде typer: стандартная библиотека не требует установки, одинаково работает на Windows и на Raspberry Pi и полностью закрывает потребности модуля M1.
argparse обнаруживает ошибку разбора (неизвестная подкоманда, значение не из списка choices), он печатает сообщение и завершает программу с кодом 2. Своя логика мини-проекта добавляет ещё два кода: 0 — успех, 2 — ошибка конфигурации, 3 — ошибка выполнения команды. Скрипты и тесты ориентируются именно на эти числа, а не на текст сообщений.6. Конфигурация и состояние: JSON между запусками
Мини-проект хранит на диске данные двух разных видов. Первый вид — конфигурация: настройки, которые задаёт человек и которые меняются редко (путь к журналу, пороги напряжения, имена файлов). Второй вид — состояние: данные, которые меняет сама программа при каждом запуске (взведён ли аппарат, текущий режим, напряжение батареи, счётчик отсчётов). Оба вида хранятся в JSON-файлах — формат вы знаете с урока K07.
Конфигурацию загружает функция load_config() из модуля k12_config.py. Прочитать файл недостаточно: программа обязана проверить то, что она прочитала. Функция выполняет четыре проверки: файл читается, текст является корректным JSON, все обязательные ключи присутствуют, а пороги напряжения согласованы (критический порог строго меньше нижнего). При нарушении любого условия поднимается собственное исключение ConfigError — приём из урока K08.
Чистый код
# Фрагмент k12_config.py: проверка конфигурации при загрузке
def load_config(path: Path) -> AppConfig:
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"файл конфигурации не читается: {path}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigError("файл конфигурации не является корректным JSON") from exc
missing = [key for key in REQUIRED_KEYS if key not in data]
if missing:
raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}")
...
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def load_config(path: Path) -> AppConfig: # → Функция принимает путь и возвращает готовый объект настроек. try: text = path.read_text(encoding="utf-8") except OSError as exc: raise ConfigError(f"файл конфигурации не читается: {path}") from exc # → OSError ловит все проблемы чтения: файла нет, нет прав доступа. # → Конструкция from exc сохраняет исходную ошибку в цепочке — # → приём из урока K08: видно и наше сообщение, и причину. try: data = json.loads(text) except json.JSONDecodeError as exc: raise ConfigError("файл конфигурации не является корректным JSON") from exc # → Отдельная проверка: текст может существовать, но не быть JSON. missing = [key for key in REQUIRED_KEYS if key not in data] if missing: raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}") # → Списковое включение собирает все отсутствующие обязательные ключи, # → чтобы сообщить пользователю сразу обо всех пропусках, а не по одному. ... # → Дальше в файле следуют построение AppConfig и проверка порогов; # → полный текст смотрите в задаче 12.1.
Теперь важная особенность состояния, которую необходимо понять именно сейчас. Каждый запуск CLI-программы — это новый процесс операционной системы. Когда процесс завершается, все переменные в оперативной памяти исчезают: поле armed, установленное в значение True внутри одного запуска, не будет видно в следующем запуске. Поэтому станция управления, которая «помнит» аппарат между запусками, обязана сохранять состояние на диск — в файл состояния. Команда arm в конце работы записывает поля armed, mode, battery_v и tick в файл k12_state.json, а следующий запуск читает этот файл методом FakeVehicle.load() и восстанавливает объект аппарата.
Чистый код
# Фрагмент k12_vehicle.py: атомарное сохранение состояния
def save_state(self) -> None:
path = Path(self.config.state_file)
tmp_path = Path(str(path) + ".tmp")
payload = {
"armed": self.armed,
"mode": self.mode.value,
"battery_v": self.battery_v,
"tick": self.tick,
}
tmp_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=False),
encoding="utf-8",
)
os.replace(tmp_path, path)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def save_state(self) -> None: # → Метод класса FakeVehicle: сохранить текущее состояние на диск. path = Path(self.config.state_file) # → Имя файла состояния берётся из конфигурации, а не «зашивается» в код. tmp_path = Path(str(path) + ".tmp") # → Временный файл: k12_state.json.tmp рядом с целевым. payload = { "armed": self.armed, "mode": self.mode.value, "battery_v": self.battery_v, "tick": self.tick, } # → Словарь для JSON. У перечисления FlightMode берём .value — # → строку "LOITER", потому что сам Enum в JSON не сериализуется. tmp_path.write_text( json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8", ) # → Шаг 1 атомарной записи: весь текст уходит во временный файл. os.replace(tmp_path, path) # → Шаг 2: операционная система заменяет целевой файл временным. # → Если программа упадёт посреди записи, старый k12_state.json # → останется целым — приём из урока K07.
7. Канал-заглушка FakeLink и CSV-трек
Канал-заглушка — это объект, который имитирует канал связи с аппаратом. Класс FakeLink из модуля k12_link.py вместо настоящих байтов из serial-порта или UDP-сокета выдаёт объекты TelemetrySample, вычисленные по простой формуле от счётчика. Интерфейс класса намеренно повторяет устройство будущей настоящей связи: метод open() открывает канал, метод read_sample() читает следующий отсчёт, метод close() закрывает канал. Чтение из неоткрытого канала запрещено и приводит к исключению RuntimeError — это привычка, которая спасёт вас при работе с настоящими портами.
Чистый код
# Фрагмент k12_link.py: детерминированный отсчёт телеметрии
def read_sample(self) -> TelemetrySample:
if not self._opened:
raise RuntimeError("канал не открыт: сначала вызовите open()")
self._tick += 1
altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2)
voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2)
return TelemetrySample(
tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v
)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def read_sample(self) -> TelemetrySample: # → Метод возвращает один отсчёт телеметрии — объект dataclass. if not self._opened: raise RuntimeError("канал не открыт: сначала вызовите open()") # → Защита от чтения из закрытого канала. Однo подчёркивание # → в имени _opened — соглашение: «внутреннее поле класса». self._tick += 1 # → Счётчик отсчётов увеличивается на единицу при каждом чтении. altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2) # → Высота растёт на 0.1 м за отсчёт и ограничена сверху значением 50 м: # → встроенная min выбирает меньшее из двух чисел, round округляет до 2 знаков. voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2) # → Напряжение медленно падает и ограничено снизу значением 13.2 В: # → встроенная max не даёт числу уйти ниже «пола». return TelemetrySample( tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v ) # → Отсчёт собирается как dataclass из урока K09: # → три именованных поля вместо «голого» кортежа чисел.
Значения в заглушке вычисляются по формуле, а не выбираются случайно. Такая последовательность называется детерминированной: при одинаковом стартовом счётчике канал всегда выдаёт одинаковые числа. Случайные значения выглядели бы «живее», но сделали бы тесты ненадёжными: тест, который сегодня прошёл, завтра мог бы упасть без всякой ошибки в коде. Для гейта G1 детерминированность — правильный выбор; «живые» источники данных появятся в следующих модулях, когда тесты будут проверять не числа, а поведение.
Функция save_track_csv() записывает список отсчётов в CSV-файл с заголовком tick,altitude_m,voltage_v — формат урока K07. Команда tele --csv использует её, чтобы сохранить прочитанный трек в файл из конфигурации.
pymavlink, а вызовы в k12_cli.py и тесты почти не изменятся. Умение сохранить интерфейс и заменить реализацию — одна из центральных идей разработки бортового и наземного программного обеспечения.8. Аппарат, режимы и prearm-проверка
Класс FakeVehicle объединяет приёмы урока K09: перечисление FlightMode хранит допустимые режимы полёта, структура TelemetrySample описывает один отсчёт телеметрии, а сам класс ведёт состояние аппарата и методы команд. Главная логика живёт в методе arm().
В настоящем ArduPilot перед взведением моторов автопилот выполняет prearm-проверки: достаточно ли напряжения батареи, есть ли спутники GPS, корректны ли параметры, не запрещает ли взведение переключатель безопасности. Если хотя бы одна проверка не пройдена, автопилот отказывает во взведении, а наземная станция показывает причину отказа. Наш Fake GCS имитирует это поведение двумя проверками: аппарат не должен быть уже взведён, а напряжение батареи не должно быть ниже критического порога из конфигурации.
Чистый код
# Фрагмент k12_vehicle.py: взведение с проверками
def arm(self) -> None:
if self.armed:
raise PrearmError("аппарат уже взведён (armed)")
if self.battery_v < self.config.critical_voltage_v:
raise PrearmError(
f"напряжение батареи {self.battery_v:.2f} В ниже критического порога "
f"{self.config.critical_voltage_v:.2f} В"
)
self.armed = True
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def arm(self) -> None: # → Метод ничего не возвращает: он либо меняет состояние, # → либо поднимает исключение. if self.armed: raise PrearmError("аппарат уже взведён (armed)") # → Первая проверка: повторное взведение считается ошибкой, # → потому что команда arm должна приходить из состояния DISARMED. if self.battery_v < self.config.critical_voltage_v: raise PrearmError( f"напряжение батареи {self.battery_v:.2f} В ниже критического порога " f"{self.config.critical_voltage_v:.2f} В" ) # → Вторая проверка: порог берётся из конфигурации, а не из кода. # → Сообщение содержит оба числа — оператор сразу видит причину отказа. self.armed = True # → Обе проверки пройдены: аппарат взведён. # → Вызывающий код (CLI) обязан после этого сохранить состояние на диск.
Команда disarm намеренно написана идемпотентной: метод disarm() просто устанавливает armed в значение False, каким бы ни было предыдущее состояние. Повтор команды disarm безопасен, и программа не выдаёт ошибку «уже снят с взведения». По такому же принципу проектируют команды реальных наземных станций: повторная отправка команды снятия с взведения не должна приводить к отказу или к неожиданному поведению.
Метод battery_label() возвращает учебную метку батареи OK, LOW или CRITICAL по порогам из конфигурации — это прямое продолжение функции classify_voltage из урока K11, только пороги теперь не зашиты в аргументы по умолчанию, а читаются из файла конфигурации.
9. Логирование мини-проекта
Журналирование настраивается один раз при старте программы функцией setup_logging(): уровень берётся из конфигурации, а сообщения уходят сразу в два обработчика — в консоль и в файл. Файл журнала k12_gcs.log накапливает историю всех запусков: это прообраз журнала наземной станции, который после полёта разбирают в поисках причины сбоя (урок K08).
Чистый код
# Фрагмент k12_cli.py: логирование из настроек конфигурации
def setup_logging(config: AppConfig) -> None:
level = getattr(logging, config.log_level.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(config.log_file, encoding="utf-8"),
],
force=True,
)
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def setup_logging(config: AppConfig) -> None: # → Функция вызывается один раз в main() после загрузки конфигурации. level = getattr(logging, config.log_level.upper(), logging.INFO) # → В конфигурации уровень хранится текстом: "INFO", "DEBUG". # → getattr возвращает атрибут модуля по строковому имени: # → getattr(logging, "INFO") даст числовое значение уровня. # → Третий аргумент — запасное значение, если имя уровня ошибочно. logging.basicConfig( level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler(config.log_file, encoding="utf-8"), ], force=True, ) # → basicConfig настраивает корневой логгер: формат строки и обработчики. # → StreamHandler(sys.stdout) печатает сообщения в консоль. # → FileHandler пишет их же в файл из конфигурации в кодировке UTF-8. # → force=True разрешает перенастроить логирование, даже если оно # → уже было настроено раньше: это важно в тестах, где main() # → вызывают несколько раз подряд в одном процессе.
10. Тесты: от чистых функций до CLI целиком
Тесты мини-проекта устроены слоями, от простого к сложному. Первый слой проверяет конфигурацию: функция load_config() тестируется через заглушку MagicMock для объекта Path — приём урока K11, настоящий диск не нужен. Второй слой проверяет аппарат: методы arm(), disarm(), battery_label() и set_mode() работают с объектами в памяти, поэтому тесты короткие и быстрые. Третий слой проверяет канал-заглушку: два канала с одинаковым стартовым счётчиком обязаны выдать одинаковые последовательности. Четвёртый слой — интеграционные тесты: они вызывают функцию main() со списком аргументов и проверяют сразу всё — код возврата, файл состояния и CSV-трек на диске.
Четвёртому слою настоящие файлы на диске всё-таки нужны, но создавать их рядом с проектом нельзя: мусорные файлы попали бы в Git, а тесты начали бы зависеть друг от друга через общее состояние. pytest предлагает встроенное решение — фикстуру tmp_path. Слово «фикстура» встречалось в словаре урока K11; теперь вы видите фикстру в действии: pytest сам создаёт для каждого теста уникальный временный каталог и передаёт его тесту как аргумент с именем tmp_path. Всё, что тест записал в этот каталог, не мешает ни проекту, ни соседним тестам.
Чистый код
# Фрагмент k12_test_app.py: интеграционный тест CLI
def test_cli_arm_persists_state(tmp_path):
config_path = write_config(tmp_path)
assert main(["--config", str(config_path), "arm"]) == EXIT_OK
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
assert state["armed"] is True
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с последовательности символов «# →», содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
def test_cli_arm_persists_state(tmp_path): # → Аргумент tmp_path — фикстура pytest: уникальный временный каталог теста. config_path = write_config(tmp_path) # → Вспомогательная функция записывает в этот каталог настоящую # → конфигурацию, у которой все пути (журнал, состояние, трек) # → тоже ведут внутрь tmp_path. assert main(["--config", str(config_path), "arm"]) == EXIT_OK # → Запускаем всю программу через main() со списком аргументов # → и проверяем код возврата: EXIT_OK равен нулю. state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) # → Читаем файл состояния, который программа записала на диск. assert state["armed"] is True # → Проверяем главное: команда arm сохранила взведённое состояние. # → Это и есть «память» программы между запусками.
Обратите внимание на сигнатуру main(argv: list[str] | None = None): список аргументов передаётся явно, чтобы тесты могли «запустить программу» без настоящей командной строки. При обычном запуске argv равен None, и argparse сам читает sys.argv. Запись типа list[str] | None означает «список строк либо None» — объединение типов через вертикальную черту встречалось вам в уроке K09.
11. Типичные ошибки
| Ошибка | Что происходит | Как следует рассуждать |
|---|---|---|
Запуск CLI не из каталога code/M01_python |
ModuleNotFoundError: No module named 'k12_config' или «файл конфигурации не читается» |
Относительные пути и импорты считаются от текущего каталога; запускайте из code/M01_python или укажите Working directory в PyCharm |
add_subparsers() без dest и required |
Поле args.command равно None, программа «молча» ничего не делает |
Всегда пишите dest="command" и required=True |
Забыли save_state() после команды, меняющей состояние |
Следующий запуск status показывает старое состояние |
Каждая команда, меняющая аппарат, обязана сохранить состояние на диск |
| Запись состояния прямо в целевой файл, без временного | При сбое во время записи файл оказывается наполовину пустым | Сначала временный файл, затем os.replace() — приём K07 |
| Тесты пишут журнал и состояние в каталог проекта | Артефакты попадают в Git; тесты влияют друг на друга | В интеграционных тестах все пути уводите внутрь tmp_path |
Коммитите артефакты запуска: k12_gcs.log, k12_state.json, k12_track.csv |
Репозиторий засоряется меняющимися файлами | В Git коммитьте только исходники и пример конфигурации; артефакты запуска добавьте в .gitignore |
| Проверяете в тесте «на глаз» текст консоли | Тест ломается при безобидной правке формулировки | Проверяйте устойчивые факты: код возврата, содержимое файлов, значения полей |
12. Практика
Мини-проект собирается из семи файлов. Порядок работы следующий: создайте файлы задач 12.1–12.7 в каталоге code/M01_python вашего проекта PyCharm, затем выполните прогон команд (задача 12.8), затем прогон тестов (задача 12.9) и сохраните исходники в Git (задача 12.10). В карточках ниже в аккордеонах лежит полный текст каждого файла; он совпадает с содержимым репозитория курса.
Задача 12.1. Модуль конфигурации
Файл практики: k12_config.py.
Создайте модуль конфигурации: класс настроек AppConfig с шестью полями, кортеж обязательных ключей REQUIRED_KEYS, собственное исключение ConfigError и функцию load_config() с проверкой чтения, формата JSON, состава ключей и согласованности порогов напряжения.
Ожидаемый результат. Импорт from k12_config import AppConfig, ConfigError, load_config проходит без ошибок.
- Полный текст файла k12_config.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_config.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_config.py # Конфигурация мини-проекта Fake GCS (K12): # чтение JSON, проверка обязательных ключей, своё исключение ConfigError. import json from dataclasses import dataclass from pathlib import Path class ConfigError(Exception): """Файл конфигурации отсутствует, не читается или содержит неверные значения.""" @dataclass class AppConfig: """Настройки приложения, собранные из JSON-файла.""" log_file: str log_level: str state_file: str csv_track: str low_voltage_v: float critical_voltage_v: float REQUIRED_KEYS = ( "log_file", "log_level", "state_file", "csv_track", "low_voltage_v", "critical_voltage_v", ) def load_config(path: Path) -> AppConfig: """Прочитать JSON-конфигурацию и вернуть проверенный AppConfig. Поднимает ConfigError, если файл не читается, если JSON повреждён, если отсутствуют обязательные ключи или если пороги напряжения противоречат друг другу. """ try: text = path.read_text(encoding="utf-8") except OSError as exc: raise ConfigError(f"файл конфигурации не читается: {path}") from exc try: data = json.loads(text) except json.JSONDecodeError as exc: raise ConfigError( f"файл конфигурации не является корректным JSON: {path}" ) from exc if not isinstance(data, dict): raise ConfigError("JSON-конфигурация должна быть объектом со строковыми ключами") missing = [key for key in REQUIRED_KEYS if key not in data] if missing: raise ConfigError(f"в конфигурации отсутствуют ключи: {', '.join(missing)}") try: config = AppConfig( log_file=str(data["log_file"]), log_level=str(data["log_level"]), state_file=str(data["state_file"]), csv_track=str(data["csv_track"]), low_voltage_v=float(data["low_voltage_v"]), critical_voltage_v=float(data["critical_voltage_v"]), ) except (TypeError, ValueError) as exc: raise ConfigError(f"поле конфигурации имеет неверный тип: {exc}") from exc if config.critical_voltage_v >= config.low_voltage_v: raise ConfigError( "порог critical_voltage_v должен быть строго меньше порога low_voltage_v" ) return config
Задача 12.2. Модуль аппарата
Файл практики: k12_vehicle.py.
Создайте модуль аппарата: перечисление режимов FlightMode, структуру отсчёта TelemetrySample, исключение PrearmError и класс FakeVehicle с загрузкой состояния, атомарным сохранением, командами arm(), disarm(), set_mode() и меткой батареи battery_label().
Ожидаемый результат. Импорт from k12_vehicle import FakeVehicle, FlightMode, PrearmError, TelemetrySample проходит без ошибок.
- Полный текст файла k12_vehicle.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_vehicle.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_vehicle.py # Модель «аппарата» для фейковой наземной станции управления: # перечисление режимов, отсчёт телеметрии, класс FakeVehicle с prearm-проверками. import json import os from dataclasses import dataclass from enum import Enum from pathlib import Path from k12_config import AppConfig class FlightMode(Enum): """Учебные режимы полёта (имена как у ArduPilot Copter).""" STABILIZE = "STABILIZE" LOITER = "LOITER" GUIDED = "GUIDED" AUTO = "AUTO" RTL = "RTL" LAND = "LAND" @dataclass class TelemetrySample: """Один отсчёт телеметрии из канала-заглушки.""" tick: int altitude_m: float voltage_v: float class PrearmError(Exception): """Prearm-проверка не пройдена: взведение моторов запрещено.""" class FakeVehicle: """Фейковый аппарат: состояние хранится в JSON-файле между запусками CLI.""" def __init__( self, config: AppConfig, armed: bool = False, mode: FlightMode = FlightMode.STABILIZE, battery_v: float = 15.2, tick: int = 0, ): self.config = config self.armed = armed self.mode = mode self.battery_v = battery_v self.tick = tick @classmethod def load(cls, config: AppConfig) -> "FakeVehicle": """Загрузить состояние из файла; если файла нет — взять значения по умолчанию.""" path = Path(config.state_file) if not path.exists(): return cls(config) data = json.loads(path.read_text(encoding="utf-8")) return cls( config, armed=bool(data.get("armed", False)), mode=FlightMode(str(data.get("mode", "STABILIZE"))), battery_v=float(data.get("battery_v", 15.2)), tick=int(data.get("tick", 0)), ) def save_state(self) -> None: """Сохранить состояние атомарно: сначала временный файл, затем os.replace.""" path = Path(self.config.state_file) tmp_path = Path(str(path) + ".tmp") payload = { "armed": self.armed, "mode": self.mode.value, "battery_v": self.battery_v, "tick": self.tick, } tmp_path.write_text( json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8", ) os.replace(tmp_path, path) def arm(self) -> None: """Взвести моторы, если prearm-проверки пройдены.""" if self.armed: raise PrearmError("аппарат уже взведён (armed)") if self.battery_v < self.config.critical_voltage_v: raise PrearmError( f"напряжение батареи {self.battery_v:.2f} В ниже критического порога " f"{self.config.critical_voltage_v:.2f} В" ) self.armed = True def disarm(self) -> None: """Снять моторы с взведения. Команда идемпотентна: повтор безопасен.""" self.armed = False def set_mode(self, name: str) -> None: """Установить режим полёта по имени; при неизвестном имени — ValueError.""" self.mode = FlightMode(name.upper()) def apply_sample(self, sample: TelemetrySample) -> None: """Обновить напряжение и счётчик отсчётов по последнему отсчёту телеметрии.""" self.battery_v = sample.voltage_v self.tick = sample.tick def battery_label(self) -> str: """Учебная метка состояния батареи: OK / LOW / CRITICAL (как в K11).""" if self.battery_v < self.config.critical_voltage_v: return "CRITICAL" if self.battery_v < self.config.low_voltage_v: return "LOW" return "OK" def status_text(self) -> str: """Короткая человекочитаемая сводка состояния для команды status.""" state = "ARMED" if self.armed else "DISARMED" return ( f"mode={self.mode.value} state={state} " f"battery={self.battery_v:.2f} V ({self.battery_label()}) tick={self.tick}" )
Задача 12.3. Канал-заглушка и CSV-трек
Файл практики: k12_link.py.
Создайте модуль канала: класс FakeLink с интерфейсом open() / read_sample() / close(), детерминированными формулами телеметрии и функцию save_track_csv() для записи трека.
Ожидаемый результат. Импорт from k12_link import FakeLink, save_track_csv проходит без ошибок.
- Полный текст файла k12_link.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_link.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_link.py # Канал-заглушка телеметрии вместо настоящего serial/UDP-порта # и сохранение трека в CSV. import csv import logging from pathlib import Path from k12_vehicle import TelemetrySample log = logging.getLogger("fake_gcs.link") class FakeLink: """Заглушка канала связи: детерминированная телеметрия без реальных портов. Интерфейс open() / read_sample() / close() намеренно похож на будущую реальную связь с SITL по MAVLink: в модуле M5 внутри заменится реализация, а вызывающий код останется прежним. """ def __init__(self, start_tick: int = 0): self._tick = start_tick self._opened = False @property def tick(self) -> int: """Текущий счётчик отсчётов.""" return self._tick def open(self) -> None: """«Открыть порт»: разрешить чтение отсчётов.""" if self._opened: raise RuntimeError("канал уже открыт") self._opened = True log.info("канал-заглушка открыт (fake link)") def close(self) -> None: """«Закрыть порт». Закрытый канал можно открыть снова.""" if self._opened: self._opened = False log.info("канал-заглушка закрыт") def read_sample(self) -> TelemetrySample: """Вернуть следующий отсчёт телеметрии. Значения вычисляются по формулам от счётчика _tick, поэтому последовательность детерминирована: это удобно проверять тестами. """ if not self._opened: raise RuntimeError("канал не открыт: сначала вызовите open()") self._tick += 1 altitude_m = round(min(10.0 + 0.1 * self._tick, 50.0), 2) voltage_v = round(max(15.2 - 0.02 * self._tick, 13.2), 2) return TelemetrySample( tick=self._tick, altitude_m=altitude_m, voltage_v=voltage_v ) def save_track_csv(samples: list[TelemetrySample], path: Path) -> None: """Записать отсчёты телеметрии в CSV-файл с заголовком.""" with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle) writer.writerow(["tick", "altitude_m", "voltage_v"]) for sample in samples: writer.writerow([sample.tick, sample.altitude_m, sample.voltage_v]) log.info("трек сохранён в CSV: %s (отсчётов: %d)", path, len(samples))
Задача 12.4. Точка входа CLI
Файл практики: k12_cli.py.
Создайте точку входа мини-проекта: build_parser() с шестью подкомандами, setup_logging(), диспетчер run_command() и функцию main() с кодами возврата. Это самый большой файл проекта — сверяйте его с аккордеоном целиком.
Ожидаемый результат. Команда python k12_cli.py --help печатает справку со списком подкоманд config, status, arm, disarm, mode и tele.
- Полный текст файла k12_cli.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_cli.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_cli.py # Точка входа CLI мини-проекта Fake GCS (K12). # Запуск из каталога code/M01_python: # python k12_cli.py --config k12_config_example.json status # python k12_cli.py --config k12_config_example.json arm # python k12_cli.py --config k12_config_example.json tele --count 5 --csv import argparse import logging import sys from pathlib import Path from k12_config import AppConfig, ConfigError, load_config from k12_link import FakeLink, save_track_csv from k12_vehicle import FakeVehicle, FlightMode, PrearmError log = logging.getLogger("fake_gcs.cli") EXIT_OK = 0 EXIT_CONFIG_ERROR = 2 EXIT_COMMAND_ERROR = 3 def build_parser() -> argparse.ArgumentParser: """Собрать парсер аргументов командной строки и подкоманд.""" parser = argparse.ArgumentParser( prog="k12_cli.py", description="Fake GCS: учебная наземная станция управления без реального железа.", ) parser.add_argument( "--config", default="k12_config_example.json", help="путь к JSON-файлу конфигурации (по умолчанию k12_config_example.json)", ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("config", help="показать загруженную конфигурацию") sub.add_parser("status", help="показать состояние аппарата") sub.add_parser("arm", help="взвести моторы (выполняются prearm-проверки)") sub.add_parser("disarm", help="снять моторы с взведения") mode_parser = sub.add_parser("mode", help="установить режим полёта") mode_parser.add_argument( "name", choices=[m.value for m in FlightMode], help="имя режима, например RTL или LOITER", ) tele_parser = sub.add_parser("tele", help="прочитать телеметрию из канала-заглушки") tele_parser.add_argument( "--count", type=int, default=5, help="число отсчётов телеметрии (по умолчанию 5)", ) tele_parser.add_argument( "--csv", action="store_true", help="сохранить трек в CSV-файл из конфигурации", ) return parser def setup_logging(config: AppConfig) -> None: """Настроить логирование в консоль и в файл (урок K08).""" level = getattr(logging, config.log_level.upper(), logging.INFO) logging.basicConfig( level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler(config.log_file, encoding="utf-8"), ], force=True, ) def format_config(config: AppConfig) -> str: """Вернуть конфигурацию в виде текста для команды config.""" lines = [ f"log_file = {config.log_file}", f"log_level = {config.log_level}", f"state_file = {config.state_file}", f"csv_track = {config.csv_track}", f"low_voltage_v = {config.low_voltage_v}", f"critical_voltage_v = {config.critical_voltage_v}", ] return "\n".join(lines) def run_command(args: argparse.Namespace, config: AppConfig) -> int: """Выполнить выбранную подкоманду и вернуть код возврата.""" vehicle = FakeVehicle.load(config) if args.command == "config": print(format_config(config)) return EXIT_OK if args.command == "status": print(vehicle.status_text()) return EXIT_OK if args.command == "arm": vehicle.arm() vehicle.save_state() log.info("команда arm выполнена") print(vehicle.status_text()) return EXIT_OK if args.command == "disarm": vehicle.disarm() vehicle.save_state() log.info("команда disarm выполнена") print(vehicle.status_text()) return EXIT_OK if args.command == "mode": vehicle.set_mode(args.name) vehicle.save_state() log.info("установлен режим %s", vehicle.mode.value) print(vehicle.status_text()) return EXIT_OK if args.command == "tele": if args.count < 1: raise ValueError("значение --count должно быть не меньше 1") link = FakeLink(start_tick=vehicle.tick) link.open() try: samples = [link.read_sample() for _ in range(args.count)] finally: link.close() for sample in samples: print( f"tick={sample.tick} alt={sample.altitude_m:.2f} m " f"volt={sample.voltage_v:.2f} V" ) vehicle.apply_sample(samples[-1]) vehicle.save_state() if args.csv: save_track_csv(samples, Path(config.csv_track)) print(f"CSV-трек сохранён: {config.csv_track}") return EXIT_OK raise ValueError(f"неизвестная команда: {args.command}") def main(argv: list[str] | None = None) -> int: """Точка входа: разобрать аргументы, настроить лог, выполнить команду. Параметр argv нужен тестам: в них список аргументов передают вручную. При обычном запуске argv равен None, и argparse берёт sys.argv сам. """ parser = build_parser() args = parser.parse_args(argv) try: config = load_config(Path(args.config)) except ConfigError as exc: print(f"Ошибка конфигурации: {exc}") return EXIT_CONFIG_ERROR setup_logging(config) log.info("запуск Fake GCS, команда: %s", args.command) try: return run_command(args, config) except (PrearmError, ValueError, RuntimeError, OSError) as exc: log.error("команда %s завершилась ошибкой: %s", args.command, exc) print(f"Ошибка команды: {exc}") return EXIT_COMMAND_ERROR if __name__ == "__main__": sys.exit(main())
Задача 12.5. Пример конфигурации
Файл практики: k12_config_example.json.
Создайте JSON-файл конфигурации с шестью ключами. Значения можно менять: например, повысьте critical_voltage_v и убедитесь, что prearm-проверка начинает отклонять команду arm раньше.
Ожидаемый результат. Команда python k12_cli.py --config k12_config_example.json config печатает все шесть настроек.
- Полный текст файла k12_config_example.json
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_config_example.jsonв проекте PyCharm. Это пример конфигурации мини-проекта; артефакты программы (журнал, состояние, трек) будут создаваться рядом с ним.{ "log_file": "k12_gcs.log", "log_level": "INFO", "state_file": "k12_state.json", "csv_track": "k12_track.csv", "low_voltage_v": 14.4, "critical_voltage_v": 14.0 }
Задача 12.6. Тесты мини-проекта
Файл практики: k12_test_app.py.
Создайте файл тестов: четыре проверки конфигурации, пять проверок аппарата, две проверки канала-заглушки и шесть интеграционных проверок CLI с фикстурой tmp_path — всего семнадцать тестов.
Ожидаемый результат. Команда python -m pytest k12_test_app.py -v показывает 17 passed.
- Полный текст файла k12_test_app.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_test_app.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_test_app.py # Тесты мини-проекта Fake GCS: конфигурация, аппарат, канал-заглушка, CLI. # Запуск из каталога code/M01_python: # python -m pytest k12_test_app.py -v import json from pathlib import Path from unittest.mock import MagicMock import pytest from k12_cli import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_OK, main from k12_config import AppConfig, ConfigError, load_config from k12_link import FakeLink from k12_vehicle import FakeVehicle, PrearmError VALID_CONFIG = { "log_file": "k12_gcs.log", "log_level": "INFO", "state_file": "k12_state.json", "csv_track": "k12_track.csv", "low_voltage_v": 14.4, "critical_voltage_v": 14.0, } def make_fake_config_path(payload: dict) -> MagicMock: """Заглушка Path, у которой read_text возвращает заданный JSON (приём из K11).""" fake_path = MagicMock(spec=Path) fake_path.read_text.return_value = json.dumps(payload) return fake_path def loaded_config() -> AppConfig: """Загрузить корректную конфигурацию через заглушку пути.""" return load_config(make_fake_config_path(VALID_CONFIG)) def make_vehicle(battery_v: float) -> FakeVehicle: """Создать аппарат с заданным напряжением батареи (файл состояния не нужен).""" return FakeVehicle(loaded_config(), battery_v=battery_v) def write_config(tmp_path: Path) -> Path: """Записать настоящую конфигурацию во временный каталог теста.""" payload = dict(VALID_CONFIG) payload["log_file"] = str(tmp_path / "gcs.log") payload["state_file"] = str(tmp_path / "state.json") payload["csv_track"] = str(tmp_path / "track.csv") config_path = tmp_path / "config.json" config_path.write_text(json.dumps(payload), encoding="utf-8") return config_path # --- конфигурация (JSON + проверка) ------------------------------------- def test_load_config_ok(): config = loaded_config() assert config.low_voltage_v == 14.4 assert config.state_file == "k12_state.json" def test_load_config_missing_key(): payload = dict(VALID_CONFIG) del payload["csv_track"] with pytest.raises(ConfigError): load_config(make_fake_config_path(payload)) def test_load_config_bad_json(): fake_path = MagicMock(spec=Path) fake_path.read_text.return_value = "{not-json" with pytest.raises(ConfigError): load_config(fake_path) def test_load_config_bad_thresholds(): payload = dict(VALID_CONFIG) payload["critical_voltage_v"] = 15.0 # больше low_voltage_v — так нельзя with pytest.raises(ConfigError): load_config(make_fake_config_path(payload)) # --- аппарат и prearm-проверки ------------------------------------------ def test_arm_and_disarm(): vehicle = make_vehicle(15.0) vehicle.arm() assert vehicle.armed is True vehicle.disarm() assert vehicle.armed is False def test_arm_twice_raises(): vehicle = make_vehicle(15.0) vehicle.arm() with pytest.raises(PrearmError): vehicle.arm() def test_arm_on_critical_battery_raises(): vehicle = make_vehicle(13.5) with pytest.raises(PrearmError): vehicle.arm() def test_battery_label(): assert make_vehicle(15.0).battery_label() == "OK" assert make_vehicle(14.2).battery_label() == "LOW" assert make_vehicle(13.5).battery_label() == "CRITICAL" def test_set_mode_unknown_raises(): vehicle = make_vehicle(15.0) vehicle.set_mode("loiter") assert vehicle.mode.value == "LOITER" with pytest.raises(ValueError): vehicle.set_mode("ACROBATICS") # --- канал-заглушка ------------------------------------------------------ def test_link_requires_open(): link = FakeLink() with pytest.raises(RuntimeError): link.read_sample() def test_link_deterministic(): first = FakeLink() first.open() second = FakeLink() second.open() samples_a = [first.read_sample() for _ in range(3)] samples_b = [second.read_sample() for _ in range(3)] assert samples_a == samples_b assert samples_a[0].altitude_m == 10.1 assert samples_a[0].voltage_v == 15.18 # --- CLI целиком (фикстура tmp_path) ------------------------------------- def test_cli_status_ok(tmp_path): config_path = write_config(tmp_path) assert main(["--config", str(config_path), "status"]) == EXIT_OK def test_cli_arm_persists_state(tmp_path): config_path = write_config(tmp_path) assert main(["--config", str(config_path), "arm"]) == EXIT_OK state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) assert state["armed"] is True def test_cli_tele_writes_csv(tmp_path): config_path = write_config(tmp_path) code = main(["--config", str(config_path), "tele", "--count", "3", "--csv"]) assert code == EXIT_OK lines = (tmp_path / "track.csv").read_text(encoding="utf-8").splitlines() assert lines[0] == "tick,altitude_m,voltage_v" assert len(lines) == 4 # заголовок + три отсчёта def test_cli_missing_config_returns_exit_code(tmp_path): missing = tmp_path / "missing.json" assert main(["--config", str(missing), "status"]) == EXIT_CONFIG_ERROR def test_cli_bad_count_returns_exit_code(tmp_path): config_path = write_config(tmp_path) code = main(["--config", str(config_path), "tele", "--count", "0"]) assert code == EXIT_COMMAND_ERROR def test_cli_unknown_mode_exits_via_argparse(tmp_path): config_path = write_config(tmp_path) with pytest.raises(SystemExit) as exc_info: main(["--config", str(config_path), "mode", "ACROBATICS"]) assert exc_info.value.code == 2 # так argparse сообщает об ошибке выбора
Задача 12.7. Памятка команд
Файл практики: k12_run_note.py.
Создайте файл-памятку и откройте его: в комментариях перечислены все команды мини-проекта в порядке выполнения, как в задаче 12.8.
Ожидаемый результат. Файл открывается в редакторе; при запуске печатается одно напоминание.
- Полный текст файла k12_run_note.py
Скопируйте приведённое ниже содержимое в файл
code/M01_python/k12_run_note.pyв проекте PyCharm (виртуальное окружение). Ниже приведён полный учебный текст файла; он совпадает с файлом в репозитории курса.# k12_run_note.py # Памятка команд мини-проекта K12 (выполняйте в venv из каталога code/M01_python). # # 1) Показать конфигурацию: # python k12_cli.py --config k12_config_example.json config # 2) Состояние аппарата: # python k12_cli.py --config k12_config_example.json status # 3) Взвести моторы (выполняются prearm-проверки): # python k12_cli.py --config k12_config_example.json arm # 4) Установить режим: # python k12_cli.py --config k12_config_example.json mode LOITER # 5) Телеметрия с сохранением CSV-трека: # python k12_cli.py --config k12_config_example.json tele --count 5 --csv # 6) Снять с взведения: # python k12_cli.py --config k12_config_example.json disarm # 7) Тесты мини-проекта: # python -m pytest k12_test_app.py -v # # После работы в каталоге появятся артефакты: k12_gcs.log, k12_state.json, # k12_track.csv. В git коммитьте только исходники (.py и пример конфигурации). print("Откройте комментарии этого файла и выполните команды по порядку в терминале.")
Задача 12.8. Прогон CLI (обязательно выполнить)
Выполните команды основного сценария по порядку из каталога code/M01_python с активным виртуальным окружением. Перед каждой напечатанной строкой программа выводит запись журнала с точным временем — ваше время будет другим, это нормально.
# Каталог: code/M01_python (venv активен)
python k12_cli.py --config k12_config_example.json config
python k12_cli.py --config k12_config_example.json status
python k12_cli.py --config k12_config_example.json arm
python k12_cli.py --config k12_config_example.json mode LOITER
python k12_cli.py --config k12_config_example.json tele --count 5 --csv
python k12_cli.py --config k12_config_example.json disarm
python k12_cli.py --config k12_config_example.json status
Ожидаемый результат. Ключевые строки вывода (записи журнала опущены):
mode=STABILIZE state=DISARMED battery=15.20 V (OK) tick=0 # status до arm
mode=STABILIZE state=ARMED battery=15.20 V (OK) tick=0 # после arm
mode=LOITER state=ARMED battery=15.20 V (OK) tick=0 # после mode LOITER
tick=1 alt=10.10 m volt=15.18 V # tele: пять отсчётов
tick=2 alt=10.20 m volt=15.16 V
tick=3 alt=10.30 m volt=15.14 V
tick=4 alt=10.40 m volt=15.12 V
tick=5 alt=10.50 m volt=15.10 V
CSV-трек сохранён: k12_track.csv
mode=LOITER state=DISARMED battery=15.10 V (OK) tick=5 # после disarm
Затем воспроизведите сценарий отказа prearm-проверки: сначала «разрядите батарею» длинной серией отсчётов, убедитесь в отказе команды arm, затем удалите файл состояния и взведите аппарат снова.
# Каталог: code/M01_python (venv активен)
# «Разряжаем батарею»: сто отсчётов уронят напряжение до 13.20 В
python k12_cli.py tele --count 100
python k12_cli.py status
# Ожидается строка: battery=13.20 V (CRITICAL)
# Команда arm отклоняется prearm-проверкой, код возврата 3
python k12_cli.py arm
# Ожидается: Ошибка команды: напряжение батареи 13.20 В ниже критического порога 14.00 В
# «Меняем батарею»: удаляем файл состояния и взводим снова
# (в cmd и PowerShell работает del; аналог в PowerShell — Remove-Item)
del k12_state.json
python k12_cli.py arm
# Ожидается: mode=STABILIZE state=ARMED battery=15.20 V (OK) tick=0
Ожидаемый результат. Первый arm отклонён с кодом возврата 3 и внятной причиной; после удаления k12_state.json второй arm выполнен успешно. Файлы k12_track.csv и k12_gcs.log существуют в каталоге.
Задача 12.9. Прогон тестов (обязательно выполнить)
Запустите весь набор тестов мини-проекта. Фикстура tmp_path создаёт временные каталоги автоматически — готовить файлы на диске не нужно.
# Каталог: code/M01_python (venv активен, pytest установлен на уроке K11)
python -m pytest k12_test_app.py -v
# Ожидается: все семнадцать тестов passed
Ожидаемый результат. В отчёте pytest семнадцать строк PASSED и итог вида «17 passed». Если тест упал, прочитайте раздел вывода FAILED: в нём показаны фактическое и ожидаемое значения.
Задача 12.10. Сохранение в Git
Добавьте в репозиторий только исходники мини-проекта и пример конфигурации. Артефакты запуска (k12_gcs.log, k12_state.json, k12_track.csv) коммитить не нужно: они пересоздаются при каждом запуске. При желании добавьте их в файл .gitignore.
# Из корня репозитория курса
git add code/M01_python/k12_config.py code/M01_python/k12_vehicle.py code/M01_python/k12_link.py code/M01_python/k12_cli.py code/M01_python/k12_test_app.py code/M01_python/k12_run_note.py code/M01_python/k12_config_example.json
git commit -m "K12: Fake GCS mini-project - argparse CLI, JSON config/state, CSV track, 17 tests (G1)"
Ожидаемый результат. Команда git status показывает чистое рабочее дерево по файлам k12_*, а в истории появился коммит с сообщением про мини-проект K12 и гейт G1.
13. Проверьте себя
- Почему каждый запуск CLI-программы — это новый процесс, и какое следствие это имеет для состояния аппарата?
- Что делает модуль
argparseи что пришлось бы писать вручную при разбореsys.argv? - Чем необязательный аргумент
--configотличается от позиционного аргументаnameподкомандыmode? - Какие четыре проверки выполняет
load_config()и почему она поднимаетConfigError, а не подставляет значения по умолчанию молча? - Зачем состояние сохраняется атомарно через временный файл и
os.replace()? - Что означает детерминированность канала
FakeLinkи почему она важна для тестов? - Какие две проверки выполняет метод
arm()и что происходит при их нарушении? - Что значит «команда
disarmидемпотентна» и зачем это свойство настоящим наземным станциям? - Зачем интеграционным тестам фикстура
tmp_pathи почему нельзя писать журнал и состояние в каталог проекта? - Какие коды возврата использует
k12_cli.pyи в каких ситуациях возвращается каждый из них?
14. Чек-лист самопроверки
- Все семь файлов мини-проекта созданы в каталоге
code/M01_python. - Команда
python k12_cli.py --helpпечатает справку со списком подкоманд. - Я выполнил все команды задачи 12.8 и убедился, что состояние сохраняется между запусками.
- Я воспроизвёл отказ prearm-проверки после «разряда батареи» и понял причину отказа.
- Файл
k12_track.csvсодержит заголовок и отсчёты телеметрии. - Файл
k12_gcs.logсодержит записи уровнейINFOиERROR. - Команда
python -m pytest k12_test_app.py -vзавершается результатом 17 passed. - Я могу объяснить назначение каждого модуля мини-проекта и связь между ними.
- Я сделал git commit с исходниками мини-проекта без артефактов запуска.
- Я понимаю, какие критерии гейта G1 теперь выполнены.
K12 чек-лист закрыт. Это последняя контрольная точка модуля M1: после неё ментор проверяет гейт G1, и курс переходит к модулю M2 «Математика дрона» (конспект K13).15. Гейт G1: критерии сдачи
Гейт G1 — условие перехода из модуля M1 (Python intensive) в модуль M2 (математика дрона). Формулировка гейта в мастер-плане: «CLI-утилита + тесты + работа с JSON/CSV + заглушка serial/UDP». Таблица ниже показывает, каким артефактом мини-проекта закрыт каждый критерий. Ментор проверит эти артефакты после вашей фразы K12 чек-лист закрыт.
| Критерий гейта G1 | Артефакт мини-проекта |
|---|---|
| CLI-утилита с аргументами и подкомандами | k12_cli.py: argparse, шесть подкоманд, коды возврата |
| Работа с JSON | k12_config_example.json, load_config(), файл состояния k12_state.json |
| Работа с CSV | save_track_csv(), трек k12_track.csv по команде tele --csv |
| Заглушка «serial/UDP» | Класс FakeLink с интерфейсом open() / read_sample() / close() |
| Логирование | setup_logging(), файл журнала k12_gcs.log |
| Исключения и безопасность команд | ConfigError, PrearmError, идемпотентный disarm |
| Автоматические тесты | k12_test_app.py: 17 тестов, включая интеграционные через main() |
| Контроль версий | git commit с исходниками мини-проекта |
Закрытие гейта G1 означает, что вы готовы к модулям M2 и M3: математика полёта (K13–K15) и протоколы с интерфейсами (K16–K22). В лабораторной работе K22 на месте FakeLink встанет настоящий UDP-сокет с бинарным протоколом, а в модуле M5 — MAVLink через pymavlink. Архитектурные привычки, которые вы сформировали сейчас — конфигурация снаружи, состояние на диске, журнал, тесты, понятные коды возврата — останутся неизменными.
16. Что дальше
- Предыдущий урок: K11 · Тесты pytest и заглушки
- Текущий урок: K12 · Мини-проект CLI «Fake GCS» (гейт G1)
- Следующий урок: K13 · Векторы и системы координат: body, NED, ENU — начало модуля M2 «Математика дрона»
code. Вводный alert излагает мысль без клишированного заголовка. Служебные врезки про особенности публикации в шапке отсутствуют. Dual-code с подписью «Чистый код». Стандарт: docs/05_EDITOR_STYLE_GUIDE.md.