fix(release): публикация с fsync и откатом через agent.py.prev, проверка манифеста (M-1, M-3, M-5, M-6, M-7 #25) (#32)
Сбой записи — «не удалось записать публикацию: …», код 1, прежняя версия остаётся; манифест годен при целой version ≥ 1 и sha256 из 64 hex; предупреждение о негодном — одно на ошибку; не-UTF-8 agent.py — 404 (решения 1–6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ea37d54946
commit
f977d9bd34
@ -15,7 +15,7 @@
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
|
||||
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
||||
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` |
|
||||
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
|
||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||
| `mcp_server` | инструменты MCP по stdio | `client` |
|
||||
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
||||
|
||||
@ -111,6 +111,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
||||
`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь,
|
||||
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
|
||||
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.
|
||||
Если записать публикацию не удалось (диск полон, нет прав), команда печатает «не удалось записать публикацию: …» и
|
||||
выходит с кодом 1; опубликованной остаётся прежняя версия, временных файлов в `release/` нет. Манифест, который хаб
|
||||
не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в
|
||||
логе хаба на каждую новую ошибку.
|
||||
|
||||
## HTTP API
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
|
||||
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5."""
|
||||
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7."""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@ -15,6 +16,11 @@ log = logging.getLogger(__name__)
|
||||
MAX_SIZE = 1024 * 1024
|
||||
VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M)
|
||||
|
||||
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
|
||||
_warned_lock = threading.Lock()
|
||||
|
||||
|
||||
class PublishError(Exception):
|
||||
pass
|
||||
@ -28,13 +34,27 @@ def parse_version(text: str) -> int:
|
||||
|
||||
|
||||
def _write_atomic(path: Path, data: bytes) -> None:
|
||||
"""Файл целиком или никак: .<имя>.tmp в том же каталоге, write + flush + fsync, переименование (решение 1).
|
||||
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
|
||||
tmp = path.with_name("." + path.name + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def publish(release_dir, source) -> dict:
|
||||
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной)."""
|
||||
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
|
||||
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
|
||||
опубликованной остаётся прежняя версия, наружу — только PublishError."""
|
||||
src = Path(source)
|
||||
try:
|
||||
data = src.read_bytes()
|
||||
@ -51,25 +71,79 @@ def publish(release_dir, source) -> dict:
|
||||
prev = current(rd)
|
||||
if prev and version <= prev["version"]:
|
||||
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
||||
secure_dir(rd)
|
||||
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
|
||||
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
||||
_write_atomic(rd / "agent.py", data)
|
||||
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
||||
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
|
||||
moved = False
|
||||
try:
|
||||
secure_dir(rd)
|
||||
if agent.exists():
|
||||
os.replace(agent, prev_file)
|
||||
moved = True
|
||||
_write_atomic(agent, data)
|
||||
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
||||
except OSError as e:
|
||||
_undo(agent, prev_file, moved)
|
||||
raise PublishError(f"не удалось записать публикацию: {e}") from e
|
||||
try:
|
||||
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
|
||||
except OSError as e:
|
||||
log.warning("release: %s не удалён: %s", prev_file, e)
|
||||
return manifest
|
||||
|
||||
|
||||
def _undo(agent: Path, prev_file: Path, moved: bool) -> None:
|
||||
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
||||
убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением."""
|
||||
try:
|
||||
if moved:
|
||||
os.replace(prev_file, agent)
|
||||
else:
|
||||
agent.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)
|
||||
|
||||
|
||||
def _manifest_problem(m) -> str | None:
|
||||
"""Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9)."""
|
||||
if not isinstance(m, dict):
|
||||
return "не объект"
|
||||
version = m.get("version")
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||
return f"version — не целое ≥ 1: {version!r}"
|
||||
sha = m.get("sha256")
|
||||
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
|
||||
return "sha256 — не 64 символа [0-9a-f]"
|
||||
return None
|
||||
|
||||
|
||||
def _note_manifest(problem: str | None, path: Path) -> None:
|
||||
"""Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест
|
||||
сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков."""
|
||||
global _warned
|
||||
text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет"
|
||||
with _warned_lock:
|
||||
if text is not None and text != _warned:
|
||||
log.warning("%s", text)
|
||||
_warned = text
|
||||
|
||||
|
||||
def current(release_dir) -> dict | None:
|
||||
"""Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять)."""
|
||||
"""Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
|
||||
path = Path(release_dir) / "manifest.json"
|
||||
try:
|
||||
m = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")}
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
_note_manifest(None, path)
|
||||
return None
|
||||
except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает
|
||||
log.warning("release: манифест %s не читается (%s) — публикации нет", path, e)
|
||||
except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
|
||||
_note_manifest(f"не читается ({e})", path)
|
||||
return None
|
||||
problem = _manifest_problem(m)
|
||||
_note_manifest(problem, path)
|
||||
if problem:
|
||||
return None
|
||||
return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")}
|
||||
|
||||
|
||||
def load(release_dir) -> dict | None:
|
||||
@ -85,7 +159,12 @@ def load(release_dir) -> dict | None:
|
||||
if hashlib.sha256(data).hexdigest() != m["sha256"]:
|
||||
log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет")
|
||||
return None
|
||||
return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")}
|
||||
try:
|
||||
content = data.decode("utf-8")
|
||||
except UnicodeDecodeError: # решение 6: 404, а не 500
|
||||
log.error("release: agent.py не в UTF-8 — публикации нет")
|
||||
return None
|
||||
return {"version": m["version"], "sha256": m["sha256"], "content": content}
|
||||
|
||||
|
||||
def hint(rel, agent, update_state, launcher_version) -> str | None:
|
||||
|
||||
@ -453,3 +453,34 @@ def test_claim_survives_unreadable_release(hub):
|
||||
assert b["id"]
|
||||
finally:
|
||||
os.chmod(rd, 0o700)
|
||||
|
||||
|
||||
# --- hub-hygiene-d ---------------------------------------------------------------------------------------------
|
||||
|
||||
def test_invalid_manifest_one_warning_for_many_claims(hub, monkeypatch, caplog):
|
||||
"""M-3/M-7 #25, спека «Негодный манифест»: version = 6.9 — публикации нет; 10 claim и GET /agent — одно предупреждение."""
|
||||
monkeypatch.setattr(release, "_warned", None)
|
||||
rd = hub["hub"].release_dir
|
||||
rd.mkdir()
|
||||
(rd / "manifest.json").write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
|
||||
for _ in range(10):
|
||||
assert hub["agent"].post("/agent/claim", {"agent_version": 5})["agent_release"] is None
|
||||
assert hub["client"].get("/agent")["release"] is None
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].get("/agent/release")
|
||||
assert e.value.status == 404 and e.value.error == "no_release"
|
||||
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
|
||||
|
||||
|
||||
def test_agent_release_non_utf8_is_404(hub, tmp_path):
|
||||
"""M-5 #25: agent.py не в UTF-8 при совпавшем sha256 — 404 no_release, а не 500."""
|
||||
import hashlib
|
||||
_publish(hub, tmp_path, 6)
|
||||
rd = hub["hub"].release_dir
|
||||
data = b"AGENT_VERSION = 6\n\xff\n"
|
||||
(rd / "agent.py").write_bytes(data)
|
||||
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||
(rd / "manifest.json").write_text(json.dumps({**m, "sha256": hashlib.sha256(data).hexdigest()}), encoding="utf-8")
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].get("/agent/release")
|
||||
assert e.value.status == 404 and e.value.error == "no_release"
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from jira_hub import cli, db, paths, settings
|
||||
from jira_hub import cli, db, paths, release, settings
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||
|
||||
@ -35,3 +36,27 @@ def test_agent_publish(tmp_path, monkeypatch, capsys):
|
||||
assert (paths.release_dir() / "manifest.json").exists()
|
||||
assert cli.main(["agent", "publish", str(src)]) == 1
|
||||
assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
|
||||
"""Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25)."""
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
src = tmp_path / "agent.py"
|
||||
src.write_text("AGENT_VERSION = 8\n", encoding="utf-8")
|
||||
assert cli.main(["agent", "publish", str(src)]) == 0
|
||||
capsys.readouterr()
|
||||
real = os.replace
|
||||
|
||||
def fake(s, d, *a, **kw):
|
||||
if Path(d).name == "manifest.json":
|
||||
raise OSError(28, "No space left on device")
|
||||
return real(s, d, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(os, "replace", fake)
|
||||
src.write_text("AGENT_VERSION = 9\n", encoding="utf-8")
|
||||
assert cli.main(["agent", "publish", str(src)]) == 1
|
||||
err = capsys.readouterr().err
|
||||
assert err.startswith("не удалось записать публикацию: ") and "Traceback" not in err
|
||||
monkeypatch.setattr(os, "replace", real)
|
||||
assert release.load(paths.release_dir())["version"] == 8
|
||||
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"]
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@ -101,3 +103,147 @@ def test_hint():
|
||||
def test_hint_none_when_no_agent():
|
||||
rel = {"version": 6, "sha256": "s", "published_at": "t"}
|
||||
assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None
|
||||
|
||||
|
||||
# --- hub-hygiene-d: публикация при сбое записи, проверка манифеста (решения 1–6) ---------------------------------
|
||||
|
||||
def _fail_replace_into(monkeypatch, *names):
|
||||
"""os.replace в файл с одним из имён names падает «диск полон»; остальные переименования работают."""
|
||||
real = os.replace
|
||||
|
||||
def fake(src, dst, *a, **kw):
|
||||
if Path(dst).name in names:
|
||||
raise OSError(28, "No space left on device")
|
||||
return real(src, dst, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(release.os, "replace", fake)
|
||||
|
||||
|
||||
def _listing(rd):
|
||||
return {p.name: p.read_bytes() for p in rd.iterdir()}
|
||||
|
||||
|
||||
def test_publish_success_leaves_no_prev(tmp_path):
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 5))
|
||||
release.publish(rd, agent_file(tmp_path, 6))
|
||||
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||
assert release.current(rd)["version"] == 6
|
||||
|
||||
|
||||
def test_manifest_write_failure_keeps_previous(tmp_path, monkeypatch):
|
||||
"""M-1 #25, спека «Диск полон при записи манифеста»: прежние v8 agent.py и манифест, ни .tmp, ни agent.py.prev."""
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 8))
|
||||
before = _listing(rd)
|
||||
_fail_replace_into(monkeypatch, "manifest.json")
|
||||
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: .*No space left on device"):
|
||||
release.publish(rd, agent_file(tmp_path, 9, body="print('v9')\n"))
|
||||
monkeypatch.undo()
|
||||
assert _listing(rd) == before
|
||||
assert release.load(rd)["version"] == 8
|
||||
|
||||
|
||||
def test_agent_write_failure_keeps_previous(tmp_path, monkeypatch):
|
||||
"""M-1, M-6 #25: сбой fsync при записи agent.py — временный файл удалён, прежний agent.py на месте."""
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 8))
|
||||
before = _listing(rd)
|
||||
|
||||
def no_space(fd):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(release.os, "fsync", no_space)
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 9))
|
||||
monkeypatch.undo()
|
||||
assert _listing(rd) == before
|
||||
assert release.load(rd)["version"] == 8
|
||||
|
||||
|
||||
def test_first_publish_failure_leaves_nothing(tmp_path, monkeypatch):
|
||||
"""Review Focus 3: прежней публикации нет — после сбоя release/ пуст, а не с agent.py без манифеста."""
|
||||
rd = tmp_path / "release"
|
||||
_fail_replace_into(monkeypatch, "manifest.json")
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 1))
|
||||
monkeypatch.undo()
|
||||
assert list(rd.iterdir()) == [] and release.current(rd) is None
|
||||
|
||||
|
||||
def test_stale_prev_removed_by_next_publish(tmp_path):
|
||||
"""Review Focus 1: agent.py.prev, оставленный упавшим процессом, убирает следующая успешная публикация."""
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 6))
|
||||
(rd / "agent.py").rename(rd / "agent.py.prev") # процесс упал между переименованием и записью
|
||||
assert release.load(rd) is None
|
||||
release.publish(rd, agent_file(tmp_path, 7))
|
||||
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||
assert release.load(rd)["version"] == 7
|
||||
|
||||
|
||||
def test_restore_failure_is_logged_not_raised(tmp_path, monkeypatch, caplog):
|
||||
"""Review Focus 2: и возврат agent.py.prev не удался — PublishError с текстом, причина возврата в логе."""
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 8))
|
||||
_fail_replace_into(monkeypatch, "manifest.json")
|
||||
real = release.os.replace
|
||||
|
||||
def fake(src, dst, *a, **kw):
|
||||
if Path(src).name == "agent.py.prev":
|
||||
raise OSError(13, "Permission denied")
|
||||
return real(src, dst, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(release.os, "replace", fake)
|
||||
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||
release.publish(rd, agent_file(tmp_path, 9))
|
||||
assert "прежняя публикация не восстановлена" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest", [
|
||||
{"version": 6.9, "sha256": "a" * 64}, {"version": True, "sha256": "a" * 64}, {"version": "7", "sha256": "a" * 64},
|
||||
{"version": 0, "sha256": "a" * 64}, {"version": 6, "sha256": "a" * 63}, {"version": 6, "sha256": "A" * 64},
|
||||
{"version": 6}, [6, "a" * 64], None,
|
||||
])
|
||||
def test_invalid_manifest_means_no_release(tmp_path, manifest):
|
||||
"""M-7 #25, решение 4: version — целое (не bool) ≥ 1, sha256 — 64 символа [0-9a-f], корень — объект."""
|
||||
rd = tmp_path / "release"
|
||||
rd.mkdir()
|
||||
(rd / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
assert release.current(rd) is None and release.load(rd) is None
|
||||
|
||||
|
||||
def test_invalid_manifest_warns_once_per_new_error(tmp_path, monkeypatch, caplog):
|
||||
"""M-3 #25, решение 5: одно предупреждение на каждый новый текст ошибки; годный манифест сбрасывает память."""
|
||||
monkeypatch.setattr(release, "_warned", None)
|
||||
rd = tmp_path / "release"
|
||||
rd.mkdir()
|
||||
manifest = rd / "manifest.json"
|
||||
manifest.write_text('{"version": 6.9, "sha256": "' + "a" * 64 + '"}', encoding="utf-8")
|
||||
for _ in range(100):
|
||||
assert release.current(rd) is None
|
||||
warnings = lambda: [r for r in caplog.records if "манифест" in r.getMessage()]
|
||||
assert len(warnings()) == 1 and "6.9" in warnings()[0].getMessage()
|
||||
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8") # другая ошибка — ещё одно
|
||||
release.current(rd)
|
||||
release.current(rd)
|
||||
assert len(warnings()) == 2
|
||||
manifest.write_text('{"version": 6, "sha256": "' + "a" * 64 + '"}', encoding="utf-8") # годный сбрасывает память
|
||||
assert release.current(rd)["version"] == 6
|
||||
manifest.write_text('{"version": 6, "sha256": "short"}', encoding="utf-8")
|
||||
release.current(rd)
|
||||
assert len(warnings()) == 3
|
||||
|
||||
|
||||
def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog):
|
||||
"""M-5 #25, решение 6: sha256 сошёлся, но файл не в UTF-8 — публикации нет, ошибка в логе."""
|
||||
rd = tmp_path / "release"
|
||||
release.publish(rd, agent_file(tmp_path, 6))
|
||||
data = b"AGENT_VERSION = 6\n\xff\xfe\n"
|
||||
(rd / "agent.py").write_bytes(data)
|
||||
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||
m["sha256"] = hashlib.sha256(data).hexdigest()
|
||||
(rd / "manifest.json").write_text(json.dumps(m), encoding="utf-8")
|
||||
assert release.current(rd)["version"] == 6
|
||||
assert release.load(rd) is None
|
||||
assert "agent.py не в UTF-8" in caplog.text
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user