Хранилище: нечитаемый файл — «файла нет» с предупреждением; пустой SDLC_HUB_HOME — по умолчанию (#129, #49 M-3, #102 T3.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:41:20 +04:00
parent f1dae5a811
commit 75b1559354
5 changed files with 66 additions and 4 deletions

View File

@ -3,8 +3,10 @@
каталог передаётся параметром (Hub.files_dir, решение 10)."""
import hashlib
import os
import logging
import re
import shutil
import stat as stat_mod
import tempfile
import time
from pathlib import Path
@ -12,6 +14,8 @@ from pathlib import Path
from . import registry
from .paths import secure_dir
log = logging.getLogger(__name__)
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
SHA_RE = re.compile(r"[0-9a-f]{64}")
@ -41,22 +45,32 @@ def exists(root, sha) -> bool:
def size(root, sha) -> int | None:
"""Размер файла в байтах; None — файла нет или sha негодный."""
"""Размер файла в байтах; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
if not is_sha(sha):
return None
target = path(root, sha)
try:
return path(root, sha).stat().st_size
st = target.stat()
except FileNotFoundError:
return None
except OSError as e:
log.warning("файл хранилища %s не читается: %s", target, e)
return None
return st.st_size if stat_mod.S_ISREG(st.st_mode) else None
def read(root, sha) -> bytes | None:
"""Содержимое файла; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
if not is_sha(sha):
return None
target = path(root, sha)
try:
return path(root, sha).read_bytes()
return target.read_bytes()
except FileNotFoundError:
return None
except OSError as e:
log.warning("файл хранилища %s не читается: %s", target, e)
return None
def _intact(target, sha) -> bool:

View File

@ -7,7 +7,7 @@ log = logging.getLogger(__name__)
def home() -> Path:
return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub")))
return Path(os.environ.get("SDLC_HUB_HOME") or str(Path.home() / ".local" / "share" / "sdlc-hub"))
def legacy_home() -> Path:

View File

@ -807,6 +807,19 @@ def test_attachment_flow_over_api(hub):
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}
def test_claim_with_directory_instead_of_file_has_size_none(hub):
"""Файл вложения заменён каталогом: claim отвечает 200, size — None (#129, #49 M-3)."""
c, a = hub["client"], hub["agent"]
sha = c.post("/files", {"content": _b64(b"abc")})["file"]
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
"key": "A-1", "file": sha, "filename": "a.xlsx"}}]})
target = hub["hub"].files_dir / sha
target.unlink()
target.mkdir()
cmd = a.post("/agent/claim", {})["command"]
assert cmd["params"]["size"] is None
def test_issues_hide_closed(hub):
c = hub["client"]
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},

View File

@ -189,3 +189,31 @@ def test_sweep_downloads(tmp_path):
assert files.sweep_downloads(root) == ["31337"]
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
assert files.sweep_downloads(tmp_path / "нет") == []
@pytest.mark.parametrize("func,attr", [(files.size, "stat"), (files.read, "read_bytes")])
def test_unreadable_file_is_none_with_warning(root, monkeypatch, caplog, func, attr):
from pathlib import Path
sha = files.put(root, b"abc")[0]
def denied(self, *args, **kwargs):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(Path, attr, denied)
with caplog.at_level("WARNING"):
assert func(root, sha) is None
assert sha in caplog.text and "не читается" in caplog.text
@pytest.mark.parametrize("func", [files.size, files.read])
def test_directory_instead_of_file_is_none(root, caplog, func):
(root / ABC).mkdir(parents=True)
with caplog.at_level("WARNING"):
assert func(root, ABC) is None
@pytest.mark.parametrize("func", [files.size, files.read])
def test_missing_file_is_none_without_log(root, caplog, func):
root.mkdir()
with caplog.at_level("WARNING"):
assert func(root, ABC) is None
assert caplog.text == ""

View File

@ -34,3 +34,10 @@ def test_env_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert paths.home() == tmp_path / "x"
def test_empty_env_home_is_default(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.setenv("SDLC_HUB_HOME", "")
monkeypatch.setenv("HOME", str(tmp_path))
assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub"