скрипты: запись настроек перестала уходить мимо и молчать

Найдено ревью, каждое воспроизведено на фикстуре.

Граница репозитория. find_root обходил всех предков в поисках .av-dev.toml и
только потом смотрел на .git: вложенный проект объявлялся здоровым по конфигу
соседа, а init дописывал секцию в чужой репозиторий. Подъём останавливается на
первом .git. Туда же ключ [tasks] dir: он не судился как путь, и «../соседний»
уводил запись за пределы репозитория с зелёным кодом.

Запись TOML. Значения склеивались в кавычки без экранирования — имя индекса с
кавычкой ломало файл целиком, и оба скрипта после этого отвечали кодом 3 на
любую команду. Заголовок секции искался точным сравнением строки, так что
«[tasks]  # комментарий» — ровно та возможность, ради которой взят формат, — не
находился, и дописывалась вторая таблица. set_version правил version внутри
секции и ломал файл, если версия в кавычках.

Молчание вместо отказа. Неизвестные ключи не отвергались: migrations, положенный
верхним уровнем (а именно так его перенесут руками из .docs.json), давал
«проверка неприменима» и зелёный итог. Неверный dir проваливался в поиск вверх, и
скрипт работал в другом каталоге, не сказав о ключе. Прежние файлы узнавались
только когда нового нет — половина переезда проходила молча.

Честность доклада. Версию не двигал никто: set_version был написан и не подключён,
init печатал версию, которой не записал, adopt писал dir мимо --target. Теперь
повышение — команда docs.py bump, а init и adopt зовут общий write_config, который
пишет версию, дописывает ключи и вслух называет разошедшиеся. Отсутствие
shared/config.py давало трейсбек и код 1 вместо 3. Число зовётся LAYOUT_VERSION в
обоих скриптах вместо CANON_VERSION и FORMAT_VERSION.
This commit is contained in:
av
2026-08-13 10:56:49 +03:00
parent 7d559e60ec
commit 423f9798ef
4 changed files with 314 additions and 88 deletions
+126 -31
View File
@@ -71,14 +71,19 @@ def find_root(start: Path | None = None) -> Path | None:
Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже
можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам
решает, отказ это или неприменимость.
**Подъём останавливается на первом `.git`, и это не деталь.** Репозиторий
внутри репозитория — обычное дело, и без границы конфиг соседа выигрывал бы
у собственного: вложенный проект объявлялся бы здоровым по чужому файлу, а
запись настроек уходила бы в чужой репозиторий. Свой файл ищется **до**
границы включительно, чужой не ищется вовсе.
"""
here = (start or Path.cwd()).resolve()
for base in (here, *here.parents):
if (base / CONFIG_NAME).is_file():
return base
for base in (here, *here.parents):
if (base / ".git").exists():
return base
return base # корень репозитория есть, настроек в нём нет
return None
@@ -88,15 +93,40 @@ def read(root: Path) -> dict:
if not path.is_file():
return {}
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
# Читаем байтами: `tomllib.load` сам знает про кодировку TOML, а
# `read_text` на файле не в UTF-8 роняет UnicodeDecodeError — ошибку
# окружения, которая ушла бы наружу внутренним сбоем.
with path.open("rb") as fh:
data = tomllib.load(fh)
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from exc
except OSError as exc:
except (OSError, ValueError) as exc:
raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc
_validate(data)
return data
# Ключи верхнего уровня. Секции знают свои ключи сами: `[docs]` проверяет
# `docs.py`, `[tasks]` — `tasks.py`. Здесь только то, что образует сам файл.
TOP_KEYS = (VERSION_KEY, "docs", "tasks")
def check_keys(data: dict, known: tuple[str, ...], where: str) -> None:
"""Неизвестный ключ — отказ, а не безмолвный пропуск.
Ключ, положенный не туда (`migrations` верхним уровнем вместо `[docs]` —
ровно так он лежал в прежнем `.docs.json`, и ровно так его перенесут руками),
иначе не значит ничего: проверка объявляет себя неприменимой, отчёт выходит
зелёным, и на месте настройки оказывается тишина.
"""
unknown = sorted(set(data) - set(known))
if unknown:
raise ConfigError(
f"{CONFIG_NAME}: неизвестные ключи {where}: {', '.join(unknown)}"
f" (известны: {', '.join(known)})"
)
def _validate(data: dict) -> None:
got = data.get(VERSION_KEY)
if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)):
@@ -105,12 +135,13 @@ def _validate(data: dict) -> None:
f" ожидалось целое число, а не {got!r}"
)
for name in ("docs", "tasks"):
section = data.get(name)
if section is not None and not isinstance(section, dict):
got_section = data.get(name)
if got_section is not None and not isinstance(got_section, dict):
raise ConfigError(
f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек,"
f" а не {section!r}"
f" а не {got_section!r}"
)
check_keys(data, TOP_KEYS, "верхнего уровня")
def section(cfg: dict, name: str) -> dict:
@@ -144,21 +175,70 @@ def legacy_files(root: Path, tasks_dir: Path | None = None) -> list[str]:
return found
def quote(value: str) -> str:
"""Значение как строка TOML: экранирование, а не конкатенация в кавычки.
Без него имя файла с кавычкой или путь с обратной косой чертой ломают
**весь** файл: `tomllib` отказывается разбирать его целиком, и оба скрипта
после этого отвечают кодом 3 на любую команду. Пишет сюда машина, а
последствия достаются человеку, который такого имени не выбирал.
"""
out = value.replace("\\", "\\\\").replace('"', '\\"')
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
return f'"{out}"'
def _strip_comment(line: str) -> str:
"""Строка без хвостового комментария. Кавычки уважаются: `#` внутри них — текст."""
quoted = False
for i, ch in enumerate(line):
if ch == '"' and (i == 0 or line[i - 1] != "\\"):
quoted = not quoted
elif ch == "#" and not quoted:
return line[:i]
return line
def _is_header(line: str, name: str | None = None) -> bool:
"""Заголовок секции — по разбору, а не по совпадению строки.
`[tasks] # имена частей` — законный TOML и ровно та возможность, ради
которой формат и взят. Сравнение строк её не узнаёт, дописывает вторую
таблицу с тем же именем, и `tomllib` отвергает файл целиком.
"""
body = _strip_comment(line).strip()
if not (body.startswith("[") and body.endswith("]")):
return False
return name is None or body[1:-1].strip() == name
def set_version(root: Path, number: int) -> None:
"""Двинуть версию, не тронув остального: правится одна строка.
Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего
формат и выбран. Ключа нет вовсе — строка встаёт первой, до всякой секции:
ключ верхнего уровня, попавший под `[docs]`, читался бы как её настройка.
формат и выбран.
**Ищется только ключ верхнего уровня** — то есть выше первого заголовка
секции. `version` внутри `[docs]` принадлежит проекту и значит что угодно
своё; двинув его, мы объявили бы приведённым не то, о чём речь, и оставили
бы настоящую версию неназванной. Ключа нет вовсе — строка встаёт первой, до
всякой секции, по той же причине.
"""
path = root / CONFIG_NAME
text = path.read_text(encoding="utf-8") if path.is_file() else ""
pattern = re.compile(rf"(?m)^(\s*{VERSION_KEY}\s*=\s*)(\d+)(.*)$")
if pattern.search(text):
text = pattern.sub(rf"\g<1>{number}\g<3>", text, count=1)
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
end = next((i for i, ln in enumerate(lines) if _is_header(ln)), len(lines))
# Значение берётся до комментария и может быть каким угодно — в том числе
# строкой в кавычках: файл правят руками. Заменяется оно целиком, иначе
# рядом появился бы второй ключ `version`, и файл перестал бы разбираться.
pattern = re.compile(rf"^(\s*{VERSION_KEY}\s*=\s*)([^#]*?)(\s*(?:#.*)?)$")
for i in range(end):
match = pattern.match(lines[i])
if match:
lines[i] = f"{match.group(1)}{number}{match.group(3)}"
break
else:
text = f"{VERSION_KEY} = {number}\n" + text
path.write_text(text, encoding="utf-8")
lines.insert(0, f"{VERSION_KEY} = {number}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def merge_section(root: Path, name: str, values: dict) -> list[str]:
@@ -167,35 +247,50 @@ def merge_section(root: Path, name: str, values: dict) -> list[str]:
Правка построчная по той же причине, что и у версии: перезапись файла
целиком стёрла бы комментарии. Ключ, который в секции уже есть, не трогается
вовсе — файл в чужом репозитории правит человек, и затирать его значение
своим умолчанием нельзя.
своим умолчанием нельзя. **Что дописано, а что нет, решает зовущий:** список
возвращается, и молчать о неписаном ему нельзя.
"""
path = root / CONFIG_NAME
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
header = f"[{name}]"
start = next((i for i, ln in enumerate(lines) if ln.strip() == header), None)
start = next((i for i, ln in enumerate(lines) if _is_header(ln, name)), None)
if start is None:
added = [f'{k} = "{v}"' for k, v in values.items()]
if not added:
if not values:
return []
block = ([""] if lines and lines[-1].strip() else []) + [header, *added]
block = ([""] if lines and lines[-1].strip() else []) + [f"[{name}]"]
block += [f"{k} = {quote(v)}" for k, v in values.items()]
path.write_text("\n".join([*lines, *block]) + "\n", encoding="utf-8")
return list(values)
end = next((i for i in range(start + 1, len(lines))
if lines[i].lstrip().startswith("[")), len(lines))
end = next((i for i in range(start + 1, len(lines)) if _is_header(lines[i])),
len(lines))
body = lines[start + 1:end]
have = {ln.split("=", 1)[0].strip() for ln in body if "=" in ln and not
ln.lstrip().startswith("#")}
have = {ln.split("=", 1)[0].strip() for ln in map(_strip_comment, body)
if "=" in ln}
added = [k for k in values if k not in have]
if not added:
return []
insert = [f'{k} = "{values[k]}"' for k in added]
# Пустые строки в хвосте секции — отбивка перед следующим заголовком.
# Дописываем до неё, а её возвращаем на место: иначе файл слипается.
trailing = 0
while body and not body[-1].strip():
body.pop()
lines[start + 1:end] = [*body, *insert]
trailing += 1
insert = [f"{k} = {quote(values[k])}" for k in added]
lines[start + 1:end] = [*body, *insert, *([""] * trailing)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return added
def missing_keys(root: Path, name: str, values: dict) -> dict:
"""Ключи секции, которые уже есть и разошлись с тем, что мы собирались дать.
`merge_section` чужого значения не трогает — и правильно делает, — но
промолчать о расхождении нельзя: `dir` из настроек и `--dir` из вызова,
разойдясь, оставляют каталог, до которого потом не дотянется никто.
"""
have = section(read(root), name)
return {k: have[k] for k, v in values.items() if k in have and have[k] != v}
def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -> str:
"""Свежий файл с комментариями — тем, ради чего взят TOML.
@@ -215,14 +310,14 @@ def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -
if docs.get("migrations"):
out += [
"# каталог миграций: по нему docs.py сверяет схему с database.md",
f'migrations = "{docs["migrations"]}"',
f"migrations = {quote(docs['migrations'])}",
]
else:
out += ["# migrations = \"путь/к/миграциям\" — появится, когда появится БД"]
out += ['# migrations = "путь/к/миграциям" — появится, когда появится БД']
out += ["", "[tasks]",
"# каталог задач от корня репозитория; имена частей — умолчания скрипта",
f'dir = "{tasks.get("dir", "tasks")}"']
f"dir = {quote(tasks.get('dir', 'tasks'))}"]
for key in ("items", "backlog", "roadmap"):
if tasks.get(key):
out.append(f'{key} = "{tasks[key]}"')
out.append(f"{key} = {quote(tasks[key])}")
return "\n".join(out) + "\n"