diff --git a/src/sdlc_hub/core/files.py b/src/sdlc_hub/core/files.py index a6447fc..baf37d7 100644 --- a/src/sdlc_hub/core/files.py +++ b/src/sdlc_hub/core/files.py @@ -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: diff --git a/src/sdlc_hub/core/paths.py b/src/sdlc_hub/core/paths.py index 59fb68f..b8abc7d 100644 --- a/src/sdlc_hub/core/paths.py +++ b/src/sdlc_hub/core/paths.py @@ -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: diff --git a/tests/test_api.py b/tests/test_api.py index 0338d96..db38a52 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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": []}, diff --git a/tests/test_files.py b/tests/test_files.py index 07f536f..6282161 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -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 == "" diff --git a/tests/test_layout.py b/tests/test_layout.py index 79131d5..944f161 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -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"