Перенести итератор версии прошивки в общий генератор

Увеличивать версию при прямом подключении precompile из CCS и Keil.
Сохранять счётчик атомарно и оставлять ручной режим без включённого флага.
Добавить проверки итератора и обновить примеры подключения.
This commit is contained in:
2026-10-19 12:02:07 +03:00
parent 0c4eab4615
commit d42f2e1622
5 changed files with 123 additions and 13 deletions

View File

@@ -0,0 +1,83 @@
"""Проверяет общий сохраняемый итератор версии до компиляции CCS, Keil и CLI.""" # Запуск: python -m unittest discover -s c/firmware-info/tests -p test_version_iterator.py.
import importlib.util # Загружает тот же генератор, который вызывается штатными BAT templates.
import json # Проверяет исходный JSON и метаданные после резервирования версии.
from pathlib import Path # Изолирует проверочные настройки и generated во временном каталоге.
import tempfile # Удаляет только собственные файлы после каждой проверки.
import unittest # Проверяет последовательность номеров, ручной режим и отказы записи.
from unittest.mock import patch # Воспроизводит ошибки файловой системы без изменения прав пользователя.
TOOL = Path(__file__).resolve().parents[1] / "tools/identity.py" # Находит общую реализацию относительно файла теста.
spec = importlib.util.spec_from_file_location("shared_iterator", TOOL) # Не импортирует проектные обёртки или другой генератор из PATH.
identity = importlib.util.module_from_spec(spec) # Создаёт отдельный экземпляр модуля для изолированной подмены отказов.
spec.loader.exec_module(identity) # Загружает функции без выполнения CLI и без изменения рабочих версий.
class VersionIteratorTests(unittest.TestCase): # Проверяет поведение общего prepare независимо от IDE.
def setUp(self): # Готовит настройки другого изделия только во временном каталоге.
temporary = tempfile.TemporaryDirectory(prefix="shared iterator ") # Пробелы в пути не должны влиять на атомарную запись настроек.
self.addCleanup(temporary.cleanup) # Удаляет временные файлы даже после ожидаемого отказа генерации.
self.root = Path(temporary.name) # Ограничивает все изменения проверки собственным каталогом.
self.config = self.root / "config.json" # Отделяет исходную настройку от generated/firmware_identity.json.
self.output = self.root / "generated" # Моделирует отдельный каталог текущей конфигурации сборки.
self.settings = {"product": "Другое изделие", "target": "host", "version": [1, 0, 4], "increment_version": True, "extra": {"keep": "настройка"}} # Дополнительное поле проверяет сохранность проектных настроек в UTF-8.
self.save() # Устанавливает номер, на котором обнаружилась остановка прямого подключения.
def save(self): # Сохраняет выбранные входные настройки для текущего сценария.
self.config.write_text(json.dumps(self.settings, ensure_ascii=False), encoding="utf-8") # Не использует рабочий счётчик какого-либо проекта.
def prepare(self): # Запускает публичный общий API с детерминированным состоянием Git.
with patch.object(identity, "git_state", return_value={"commit": None, "dirty": None}): # Доступ к Git не влияет на проверку счётчика.
return identity.prepare(self.root, self.root, self.config, self.output) # Вызывает тот же prepare, что CCS, Keil и CLI.
def test_sequence_and_matching_outputs(self): # Воспроизводит увеличение после 1.0.4 и отсутствие повторного номера.
for expected in ([1, 0, 5], [1, 0, 6]): # Каждый общий pre-build должен занимать ровно один номер.
metadata = self.prepare() # Генерирует реальный заголовок и JSON после атомарного резервирования.
settings = json.loads(self.config.read_text(encoding="utf-8")) # Читает сохранённый источник следующей версии.
self.assertEqual(settings["version"], expected) # Проверяет увеличение исходного JSON, а не только отображаемой версии.
self.assertEqual(settings["extra"], self.settings["extra"]) # Сохраняет неизвестные общему генератору поля проекта.
self.assertEqual(metadata["version"], expected) # Возвращаемые метаданные содержат уже занятый номер.
self.assertEqual(json.loads((self.output / "firmware_identity.json").read_text(encoding="utf-8"))["version"], expected) # Сохранённый паспорт совпадает с источником версии.
self.assertIn(f"#define FIRMWARE_VERSION_PATCH {expected[2]}U", (self.output / "firmware_identity.h").read_text(encoding="utf-8")) # Компилятор получает тот же patch, что manifest.
def test_manual_mode_preserves_config_bytes(self): # Старые проекты без флага и явно отключённый итератор сохраняют ручную версию.
for enabled in (None, False): # None обозначает отсутствие поля, а не допустимое значение JSON-флага.
self.settings.pop("increment_version", None) # Сначала моделирует старую конфигурацию без нового переключателя.
if enabled is False: self.settings["increment_version"] = False # Затем проверяет явное отключение без удаления поля.
self.save() # Записывает исходный формат, который генератор не должен переформатировать.
original = self.config.read_bytes() # Сравнивает байты, чтобы заметить любую лишнюю запись.
self.assertEqual(self.prepare()["version"], [1, 0, 4]) # Ручной номер используется без увеличения.
self.assertEqual(self.config.read_bytes(), original) # Исходный JSON остаётся побайтно прежним.
def test_carry_and_exhaustion(self): # Проверяет ограничения трёх разрядов контракта firmware-info v1.
for previous, expected in (([1, 0, 999], [1, 1, 0]), ([1, 255, 999], [2, 0, 0])): # Patch переносится после 999, minor после 255.
self.settings["version"] = previous # Задаёт последнюю версию перед границей разряда.
self.save() # Передаёт граничное значение публичному prepare через JSON.
self.assertEqual(self.prepare()["version"], expected) # Проверяет перенос вместе с реальной записью настроек.
self.settings["version"] = [255, 255, 999] # Устанавливает последний допустимый номер всего контракта.
self.save() # Сохраняет исходный предел перед ожидаемым отказом.
original = self.config.read_bytes() # Переполнение не должно сбрасывать номер в JSON.
with self.assertRaises(ValueError): self.prepare() # Требует явную ошибку вместо повторного использования версии.
self.assertEqual(self.config.read_bytes(), original) # Сохраняет последний занятый номер без частичной записи.
def test_invalid_flag_does_not_change_config(self): # Не допускает неявное включение счётчика строками, числами или null.
for value in ("true", "false", 1, 0, None, [], {}): # Только JSON true и false являются допустимыми значениями переключателя.
self.settings["increment_version"] = value # Передаёт неверный тип через настоящий JSON.
self.save() # Фиксирует исходные байты для проверки отказа до резервирования.
original = self.config.read_bytes() # Ошибка настройки не должна занимать номер версии.
with self.assertRaises(ValueError): self.prepare() # Останавливает генерацию до изменения счётчика.
self.assertEqual(self.config.read_bytes(), original) # Подтверждает сохранение исходного JSON при отказе проверки.
def test_generation_failure_consumes_number(self): # Номер неудачной попытки не должен использоваться следующей сборкой.
with patch.object(identity, "write_atomic", side_effect=OSError("header unavailable")): # Воспроизводит отказ записи заголовка после сохранения следующей версии.
with self.assertRaises(OSError): self.prepare() # Передаёт ошибку вызывающему BAT без маскировки успешным завершением.
self.assertEqual(json.loads(self.config.read_text(encoding="utf-8"))["version"], [1, 0, 5]) # Резервирование фиксируется до генерации выходных файлов.
self.assertEqual(self.prepare()["version"], [1, 0, 6]) # Следующая попытка занимает следующий номер, даже если прежняя не дошла до компиляции.
def test_failed_config_replace_preserves_original(self): # Атомарная замена не должна портить настройки при отказе файловой системы.
original = self.config.read_bytes() # Сохраняет эталон исходного номера и остальных полей.
with patch.object(identity.Path, "replace", side_effect=PermissionError("config unavailable")): # Имитирует отказ фиксации временного файла вместо записи нового JSON.
with self.assertRaises(PermissionError): self.prepare() # Останавливает pre-build, если номер нельзя надёжно сохранить.
self.assertEqual(self.config.read_bytes(), original) # Исходные настройки не заменены частичным или неверным файлом.
self.assertEqual(list(self.root.glob(".firmware-version-*")), []) # При отказе не остаются собственные временные файлы счётчика.
if __name__ == "__main__": unittest.main() # Любое нарушение общего итератора возвращает ненулевой код процесса.

View File

@@ -45,15 +45,34 @@ def write_atomic(path: Path, text: str):
temporary.replace(path)
def next_version(version): # Вычисляет следующий номер по ограничениям общего контракта firmware-info v1.
result = list(version) # Сохраняет исходный номер до успешной проверки полного переполнения.
for index, limit in ((2, 999), (1, 255), (0, 255)): # Patch имеет диапазон 0..999, minor и major занимают по одному октету 0..255.
if result[index] < limit: # Находит младший разряд, который ещё можно увеличить.
result[index] += 1 # Резервирует следующий номер попытки сборки, включая будущие неудачные компиляции.
return result # Старшие разряды сохраняются, переполненные младшие уже обнулены.
result[index] = 0 # Переносит единицу в следующий разряд после достижения предела текущего.
raise ValueError("Firmware version exhausted at 255.255.999") # Не допускает повторного использования номера после полного переполнения.
def prepare(repository: Path, templates: Path, config: Path, output: Path) -> dict:
settings = json.loads(config.read_text(encoding="utf-8-sig"))
version = settings["version"]
if (len(version) != 3 or any(type(v) is not int for v in version)
or not all(0 <= v <= limit for v, limit in zip(version, (255, 255, 999)))):
raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]")
if not isinstance(version, list) or len(version) != 3 or any(type(value) is not int or not 0 <= value <= limit for value, limit in zip(version, (255, 255, 999))): # Проверяет JSON-массив трёх целых разрядов до любого изменения сохраняемого счётчика.
raise ValueError("version must be [major 0..255, minor 0..255, patch 0..999]") # Ошибочные типы, логические значения и выход за пределы контракта останавливают pre-build.
if type(settings.get("increment_version", False)) is not bool: # Отсутствующий флаг сохраняет ручной режим старых проектов; строки true/false не считаются настройкой.
raise ValueError("increment_version must be true or false") # Требует явное логическое значение JSON вместо неявного преобразования Python.
for field in ("product", "target"):
if not isinstance(settings.get(field), str) or not settings[field].strip():
raise ValueError(f"missing {field}")
if settings.get("increment_version", False): # Прямые вызовы templates из CCS, Keil и CLI используют один и тот же сохраняемый итератор.
version = settings["version"] = next_version(version) # Вычисляет номер для JSON, заголовка и метаданных только один раз.
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=config.parent, prefix=".firmware-version-", delete=False) as stream: # Временный файл на том же диске позволяет атомарно заменить настройки без частичной записи.
temporary = Path(stream.name) # Запоминает только собственный файл, чтобы не затронуть пользовательские данные при очистке.
try: stream.write(json.dumps(settings, ensure_ascii=False, indent=2) + "\n") # Сохраняет новый номер и все дополнительные поля проекта в UTF-8.
except BaseException: stream.close(); temporary.unlink(); raise # Ошибка записи оставляет исходный JSON целым и удаляет незавершённый файл.
try: temporary.replace(config) # Занимает номер до дальнейших шагов, поэтому отказ генерации или компиляции не повторит его.
finally: temporary.unlink(missing_ok=True) # Удаляет временный файл при ошибке замены; после успеха он уже переименован.
source, dependency = git_state(repository), git_state(templates)
# A clean application built with a modified templates checkout is dirty too.
known = source["commit"] is not None and dependency["commit"] is not None

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,6 @@
{
"product": "MY_CONTROLLER",
"target": "TMS320F28335",
"version": [0, 1, 0]
"version": [0, 1, 0],
"increment_version": true
}

View File

@@ -1,5 +1,6 @@
{
"product": "MY_CONTROLLER",
"target": "STM32F407",
"version": [0, 1, 0]
"version": [0, 1, 0],
"increment_version": true
}