From 75b155935493b06c1f7a744a2eba99f3b0487102 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:41:20 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A5=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=D0=B5:=20=D0=BD=D0=B5=D1=87=D0=B8=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=20=E2=80=94=20?= =?UTF-8?q?=C2=AB=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BD=D0=B5=D1=82=C2=BB?= =?UTF-8?q?=20=D1=81=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC;=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20SDLC=5FHUB=5FHOME=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E=20(#129,=20#49=20M-3,=20#102=20T3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/files.py | 20 +++++++++++++++++--- src/sdlc_hub/core/paths.py | 2 +- tests/test_api.py | 13 +++++++++++++ tests/test_files.py | 28 ++++++++++++++++++++++++++++ tests/test_layout.py | 7 +++++++ 5 files changed, 66 insertions(+), 4 deletions(-) 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"