Хранилище: нечитаемый файл — «файла нет» с предупреждением; пустой 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:
parent
f1dae5a811
commit
75b1559354
@ -3,8 +3,10 @@
|
|||||||
каталог передаётся параметром (Hub.files_dir, решение 10)."""
|
каталог передаётся параметром (Hub.files_dir, решение 10)."""
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat as stat_mod
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,6 +14,8 @@ from pathlib import Path
|
|||||||
from . import registry
|
from . import registry
|
||||||
from .paths import secure_dir
|
from .paths import secure_dir
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
|
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
|
||||||
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
|
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
|
||||||
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
||||||
@ -41,22 +45,32 @@ def exists(root, sha) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def size(root, sha) -> int | None:
|
def size(root, sha) -> int | None:
|
||||||
"""Размер файла в байтах; None — файла нет или sha негодный."""
|
"""Размер файла в байтах; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
|
||||||
if not is_sha(sha):
|
if not is_sha(sha):
|
||||||
return None
|
return None
|
||||||
|
target = path(root, sha)
|
||||||
try:
|
try:
|
||||||
return path(root, sha).stat().st_size
|
st = target.stat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
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:
|
def read(root, sha) -> bytes | None:
|
||||||
|
"""Содержимое файла; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
|
||||||
if not is_sha(sha):
|
if not is_sha(sha):
|
||||||
return None
|
return None
|
||||||
|
target = path(root, sha)
|
||||||
try:
|
try:
|
||||||
return path(root, sha).read_bytes()
|
return target.read_bytes()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("файл хранилища %s не читается: %s", target, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _intact(target, sha) -> bool:
|
def _intact(target, sha) -> bool:
|
||||||
|
|||||||
@ -7,7 +7,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def home() -> Path:
|
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:
|
def legacy_home() -> Path:
|
||||||
|
|||||||
@ -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"}
|
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):
|
def test_issues_hide_closed(hub):
|
||||||
c = hub["client"]
|
c = hub["client"]
|
||||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
|
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
|
||||||
|
|||||||
@ -189,3 +189,31 @@ def test_sweep_downloads(tmp_path):
|
|||||||
assert files.sweep_downloads(root) == ["31337"]
|
assert files.sweep_downloads(root) == ["31337"]
|
||||||
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
|
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
|
||||||
assert files.sweep_downloads(tmp_path / "нет") == []
|
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 == ""
|
||||||
|
|||||||
@ -34,3 +34,10 @@ def test_env_home(monkeypatch, tmp_path):
|
|||||||
from sdlc_hub.core import paths
|
from sdlc_hub.core import paths
|
||||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
|
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
|
||||||
assert paths.home() == 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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user