Версий было две — канон 14 в docs/.docs.json и формат задач 1 в <каталог задач>/.tasks.json, — и порознь они двигались потому, что плагины ставились порознь. Плагин один, версия одна и начинается с 1; журналы обеих прежних нумераций закрыты и лежат рядом непереписанными, действующий журнал открывается записью о слиянии с перечнем шагов проекту. Формат TOML взят ради комментариев: файл живёт в репозитории проекта, и назначение числа читают из него самого. Отсюда правило записи — скрипты правят строку, а не переписывают файл. Читатель общий, shared/config.py: два разбора одной схемы были бы двумя домами. Каталог задач перестал узнаваться служебным файлом и называется ключом [tasks] dir; узнают его по индексу. Прежние файлы не читаются — увидев их, docs.py и tasks.py называют прежнюю раскладку и зовут upgrade.
229 lines
12 KiB
Python
229 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Служебный файл проекта `.av-dev.toml`: чтение, запись, конверсия старого.
|
||
|
||
**Это дом.** Файл один на весь плагин, поэтому и читатель у него один: `docs.py`
|
||
и `tasks.py` берут настройки отсюда, а не каждый своим разбором. Два разбора
|
||
одного формата — это два дома для одной схемы, и расходятся они молча: первым
|
||
разъезжается не значение ключа, а то, что скрипт делает, ключа не увидев.
|
||
|
||
Формат TOML выбран ради **комментариев**: файл лежит в чужом репозитории, и
|
||
человек, открывший его через полгода, обязан прочитать в нём, что означает
|
||
число. JSON комментариев не знает, и объяснение приходилось держать в
|
||
документации, то есть в другом файле.
|
||
|
||
Читается `tomllib` из стандартной библиотеки (python 3.11+), пишется руками:
|
||
писателя TOML в стандартной библиотеке нет, а комментарии переживают только
|
||
построчную правку. Поэтому версия двигается заменой одной строки, а не
|
||
перезаписью файла — иначе повышение канона стирало бы то, ради чего формат и
|
||
взят.
|
||
|
||
Схема:
|
||
|
||
version = 1 # версия раскладки av-dev, целое число
|
||
|
||
[docs]
|
||
migrations = "путь/к/миграциям" # необязателен: есть БД — есть ключ
|
||
|
||
[tasks]
|
||
dir = "tasks" # каталог задач от корня репозитория
|
||
items = "items" # имена частей каталога — необязательны
|
||
backlog = "BACKLOG.md"
|
||
roadmap = "ROADMAP.md"
|
||
|
||
Коды выхода зовущих скриптов — общий словарь av-dev; отсюда возвращается
|
||
исключение `ConfigError`, а решает по нему вызывающий.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import tomllib
|
||
from pathlib import Path
|
||
|
||
# Имя файла называет владельца: раскладку ведёт плагин `av-dev`. До слияния
|
||
# плагинов файлов было два — `docs/.docs.json` (версия канона) и
|
||
# `<каталог задач>/.tasks.json` (версия формата задач), и версии двигались
|
||
# порознь, потому что плагины ставились порознь. Плагин теперь один, версия
|
||
# одна, и дом у неё в корне репозитория: настройки нужны и проекту без `docs/`,
|
||
# и проекту без каталога задач, а корень есть у обоих.
|
||
CONFIG_NAME = ".av-dev.toml"
|
||
|
||
# Прежние дома. Читаются не для работы, а для узнавания: увидели — говорим
|
||
# «старая раскладка, нужен upgrade», и это одна строка вместо отказа, за которым
|
||
# человек идёт заводить второй файл рядом с первым.
|
||
LEGACY = ("docs/.docs.json", "docs/.pm.json")
|
||
LEGACY_TASKS = ".tasks.json"
|
||
|
||
# Версия раскладки — одна на плагин. Журнал версий — references/changelog.md
|
||
# скилла `doc-canon`, повышает его операция `upgrade`.
|
||
VERSION = 1
|
||
|
||
VERSION_KEY = "version"
|
||
|
||
|
||
class ConfigError(Exception):
|
||
"""Файл есть, но прочитать его нельзя: битый TOML или не та схема."""
|
||
|
||
|
||
def find_root(start: Path | None = None) -> Path | None:
|
||
"""Корень проекта: где лежит `.av-dev.toml`, иначе где лежит `.git`.
|
||
|
||
Обе опоры нужны: до `adopt` файла ещё нет, а работать по каталогу задач уже
|
||
можно. Возвращается None, когда нет ни того, ни другого, — тогда зовущий сам
|
||
решает, отказ это или неприменимость.
|
||
"""
|
||
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 None
|
||
|
||
|
||
def read(root: Path) -> dict:
|
||
"""Настройки проекта. Файла нет — пустой словарь, это не ошибка."""
|
||
path = root / CONFIG_NAME
|
||
if not path.is_file():
|
||
return {}
|
||
try:
|
||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||
except tomllib.TOMLDecodeError as exc:
|
||
raise ConfigError(f"{CONFIG_NAME} не разбирается как TOML: {exc}") from exc
|
||
except OSError as exc:
|
||
raise ConfigError(f"{CONFIG_NAME} не читается: {exc}") from exc
|
||
_validate(data)
|
||
return data
|
||
|
||
|
||
def _validate(data: dict) -> None:
|
||
got = data.get(VERSION_KEY)
|
||
if VERSION_KEY in data and (isinstance(got, bool) or not isinstance(got, int)):
|
||
raise ConfigError(
|
||
f"{CONFIG_NAME}: ключ «{VERSION_KEY}» — версия раскладки,"
|
||
f" ожидалось целое число, а не {got!r}"
|
||
)
|
||
for name in ("docs", "tasks"):
|
||
section = data.get(name)
|
||
if section is not None and not isinstance(section, dict):
|
||
raise ConfigError(
|
||
f"{CONFIG_NAME}: секция [{name}] — ожидалась таблица настроек,"
|
||
f" а не {section!r}"
|
||
)
|
||
|
||
|
||
def section(cfg: dict, name: str) -> dict:
|
||
got = cfg.get(name, {})
|
||
return got if isinstance(got, dict) else {}
|
||
|
||
|
||
def version(cfg: dict) -> int | None:
|
||
got = cfg.get(VERSION_KEY)
|
||
return got if isinstance(got, int) and not isinstance(got, bool) else None
|
||
|
||
|
||
def legacy_files(root: Path, tasks_dir: Path | None = None) -> list[str]:
|
||
"""Следы прежней раскладки — то, что говорит «проект жил до слияния».
|
||
|
||
Каталог задач передаётся отдельно: до чтения настроек его путь неизвестен, а
|
||
искать `.tasks.json` по всему дереву значит гадать.
|
||
"""
|
||
root = root.resolve()
|
||
found = [rel for rel in LEGACY if (root / rel).is_file()]
|
||
for base in filter(None, (tasks_dir, root / "tasks", root / "docs" / "tasks")):
|
||
# Каталог задач приходит и относительным — таким его печатают в
|
||
# сообщениях; для сравнения с корнем он обязан быть абсолютным.
|
||
path = (base if base.is_absolute() else Path.cwd() / base) / LEGACY_TASKS
|
||
if not path.is_file():
|
||
continue
|
||
path = path.resolve()
|
||
rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path)
|
||
if rel not in found:
|
||
found.append(rel)
|
||
return found
|
||
|
||
|
||
def set_version(root: Path, number: int) -> None:
|
||
"""Двинуть версию, не тронув остального: правится одна строка.
|
||
|
||
Перезапись файла целиком стёрла бы комментарии — то единственное, ради чего
|
||
формат и выбран. Ключа нет вовсе — строка встаёт первой, до всякой секции:
|
||
ключ верхнего уровня, попавший под `[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)
|
||
else:
|
||
text = f"{VERSION_KEY} = {number}\n" + text
|
||
path.write_text(text, encoding="utf-8")
|
||
|
||
|
||
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)
|
||
if start is None:
|
||
added = [f'{k} = "{v}"' for k, v in values.items()]
|
||
if not added:
|
||
return []
|
||
block = ([""] if lines and lines[-1].strip() else []) + [header, *added]
|
||
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))
|
||
body = lines[start + 1:end]
|
||
have = {ln.split("=", 1)[0].strip() for ln in body if "=" in ln and not
|
||
ln.lstrip().startswith("#")}
|
||
added = [k for k in values if k not in have]
|
||
if not added:
|
||
return []
|
||
insert = [f'{k} = "{values[k]}"' for k in added]
|
||
while body and not body[-1].strip():
|
||
body.pop()
|
||
lines[start + 1:end] = [*body, *insert]
|
||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
return added
|
||
|
||
|
||
def skeleton(number: int, docs: dict | None = None, tasks: dict | None = None) -> str:
|
||
"""Свежий файл с комментариями — тем, ради чего взят TOML.
|
||
|
||
Пустая секция пишется всё равно: строка «ключа нет, потому что БД нет»
|
||
читается как решение, а её отсутствие — как недосмотр.
|
||
"""
|
||
docs, tasks = docs or {}, tasks or {}
|
||
out = [
|
||
"# Раскладка av-dev в этом проекте: версия и настройки проверок.",
|
||
"# Файл ведут скиллы плагина, править руками можно — комментарии свои.",
|
||
"",
|
||
f"{VERSION_KEY} = {number}"
|
||
" # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»",
|
||
"",
|
||
"[docs]",
|
||
]
|
||
if docs.get("migrations"):
|
||
out += [
|
||
"# каталог миграций: по нему docs.py сверяет схему с database.md",
|
||
f'migrations = "{docs["migrations"]}"',
|
||
]
|
||
else:
|
||
out += ["# migrations = \"путь/к/миграциям\" — появится, когда появится БД"]
|
||
out += ["", "[tasks]",
|
||
"# каталог задач от корня репозитория; имена частей — умолчания скрипта",
|
||
f'dir = "{tasks.get("dir", "tasks")}"']
|
||
for key in ("items", "backlog", "roadmap"):
|
||
if tasks.get(key):
|
||
out.append(f'{key} = "{tasks[key]}"')
|
||
return "\n".join(out) + "\n"
|