SFM/app/pipeline/file_manager.py
2026-06-22 07:25:27 +03:00

387 lines
13 KiB
Python

from __future__ import annotations
import tempfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from .config import (
build_date_parts,
build_error_dir,
build_out_dir,
build_processed_dir,
get_parent_dir,
join_path,
validate_file_name,
)
@dataclass
class RunContext:
run_dir: str
out_day_dir: str
error_dir: str
processed_dir: str
run_number: int
date_key: str
@dataclass
class RemoteFile:
path: str
name: str
@dataclass
class InputFileScanResult:
valid_files: list[RemoteFile]
invalid_files: list[RemoteFile]
class SMBClientConnection:
def __init__(
self, smb_base_path: str, user_params: dict[str, str] | None = None
) -> None:
self.smb_base_path = _normalize_unc_path(smb_base_path)
self.user_name = str((user_params or {}).get("user_name") or "")
self.password = str((user_params or {}).get("password") or "")
self._registered = False
def __enter__(self) -> SMBClientConnection:
self._ensure_registered()
return self
def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
return False
def path_exists(self, path: str) -> bool:
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
try:
smbclient.stat(full_path)
return True
except Exception: # noqa: BLE001
return False
def listdir(self, path: str) -> list[str]:
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
with smbclient.scandir(full_path) as entries:
return [entry.name for entry in entries]
def is_file(self, path: str) -> bool:
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
try:
with smbclient.open_file(full_path, mode="rb"):
return True
except Exception: # noqa: BLE001
return False
def mkdir(self, path: str, parent: bool = False) -> None:
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
if parent:
smbclient.makedirs(full_path, exist_ok=True)
return
smbclient.mkdir(full_path)
def download(
self,
remote_file_path: str | None = None,
local_file_path: str | None = None,
path_from: str | None = None,
path_to: str | None = None,
replace: bool = False,
) -> None:
remote = remote_file_path or path_from
local = local_file_path or path_to
if not remote or not local:
raise RuntimeError("Некорректные параметры download().")
target = Path(local)
if target.exists() and not replace:
raise FileExistsError(str(target))
target.parent.mkdir(parents=True, exist_ok=True)
with self.open(remote, mode="rb") as remote_file:
target.write_bytes(remote_file.read())
def upload(
self,
remote_file_path: str | None = None,
local_file_path: str | None = None,
path_from: str | None = None,
path_to: str | None = None,
replace: bool = False,
) -> None:
remote = remote_file_path or path_to
local = local_file_path or path_from
if not remote or not local:
raise RuntimeError("Некорректные параметры upload().")
local_path = Path(local)
if not local_path.exists():
raise FileNotFoundError(str(local_path))
full_remote = self._resolve_path(remote)
if self.path_exists(remote) and not replace:
raise FileExistsError(full_remote)
self.mkdir(get_parent_dir(remote), parent=True)
smbclient = _import_smbclient()
with smbclient.open_file(full_remote, mode="wb") as remote_file:
remote_file.write(local_path.read_bytes())
def copy(self, path_from: str, path_to: str, replace: bool = False) -> None:
if self.path_exists(path_to) and not replace:
raise FileExistsError(path_to)
with self.open(path_from, mode="rb") as source_file:
data = source_file.read()
self.mkdir(get_parent_dir(path_to), parent=True)
with self.open(path_to, mode="wb") as target_file:
target_file.write(data)
def delete(self, path: str) -> None:
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
smbclient.remove(full_path)
def open(self, path: str, mode: str = "rb"):
smbclient = _import_smbclient()
full_path = self._resolve_path(path)
return smbclient.open_file(full_path, mode=mode)
def _ensure_registered(self) -> None:
if self._registered:
return
if not self.user_name:
raise RuntimeError("Введите логин SAMBA.")
smbclient = _import_smbclient()
server = _extract_server(self.smb_base_path)
try:
smbclient.register_session(
server=server,
username=self.user_name,
password=self.password,
)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"Ошибка подключения к SMB: {exc}") from exc
self._registered = True
def _resolve_path(self, path: str) -> str:
if path.startswith("//") or path.startswith("\\\\"):
return _normalize_unc_path(path)
return _normalize_unc_path(join_path(self.smb_base_path, path))
def connect_samba(
smb_base_path: str, user_params: dict[str, str] | None = None
) -> SMBClientConnection:
return SMBClientConnection(smb_base_path=smb_base_path, user_params=user_params)
def check_input_dir_access(
input_dir: Path | str, samba_conn
) -> tuple[bool, str | None]:
return _check_samba_dir_access(str(input_dir), samba_conn, "Папка")
def check_network_dir_access(
network_dir: str | Path, samba_conn
) -> tuple[bool, str | None]:
path = Path(network_dir)
if not str(path).startswith("\\\\"):
return False, f"Путь не является сетевым UNC-путем: {path}"
return _check_samba_dir_access(str(path), samba_conn, "Сетевая папка")
def create_run_context(
input_dir: str, samba_conn, now: datetime | None = None
) -> RunContext:
base_dir = get_parent_dir(input_dir)
out_day_dir = build_out_dir(base_dir, now)
error_dir = build_error_dir(base_dir)
processed_dir = build_processed_dir(base_dir)
ensure_dir_exists(out_day_dir, samba_conn)
ensure_dir_exists(error_dir, samba_conn)
ensure_dir_exists(processed_dir, samba_conn)
run_number = next_run_number(out_day_dir, samba_conn)
run_dir = join_path(out_day_dir, str(run_number))
ensure_dir_exists(run_dir, samba_conn)
_, _, date_key = build_date_parts(now)
return RunContext(
run_dir=run_dir,
out_day_dir=out_day_dir,
error_dir=error_dir,
processed_dir=processed_dir,
run_number=run_number,
date_key=date_key,
)
def next_run_number(out_day_dir: str, samba_conn) -> int:
with samba_conn:
if not samba_conn.path_exists(out_day_dir):
return 1
entries = samba_conn.listdir(out_day_dir)
numbers: list[int] = []
for entry in entries:
if str(entry).isdigit():
numbers.append(int(entry))
return max(numbers, default=0) + 1
def scan_input_files(input_dir: str, samba_conn) -> InputFileScanResult:
valid_files: list[RemoteFile] = []
invalid_files: list[RemoteFile] = []
for file_name in _list_samba_files(input_dir, samba_conn):
remote_path = join_path(input_dir, file_name)
if validate_file_name(file_name):
valid_files.append(RemoteFile(path=remote_path, name=file_name))
else:
invalid_files.append(RemoteFile(path=remote_path, name=file_name))
return InputFileScanResult(valid_files=valid_files, invalid_files=invalid_files)
def move_file_to_error(
file_path: str, file_name: str, error_dir: str, samba_conn
) -> str:
target = _next_available_samba_target(error_dir, file_name, samba_conn)
_samba_move_file(file_path, target, samba_conn)
return target
def move_file_to_processed(
file_path: str, file_name: str, processed_dir: str, samba_conn
) -> str:
target = _next_available_samba_target(processed_dir, file_name, samba_conn)
_samba_move_file(file_path, target, samba_conn)
return target
def ensure_dir_exists(path: str, samba_conn) -> None:
with samba_conn:
if not samba_conn.path_exists(path):
samba_conn.mkdir(path, parent=True)
def read_remote_file_bytes(remote_path: str, samba_conn) -> bytes:
if hasattr(samba_conn, "download"):
with tempfile.TemporaryDirectory() as temp_dir:
local_path = Path(temp_dir) / Path(remote_path).name
with samba_conn:
try:
samba_conn.download(
remote_file_path=remote_path,
local_file_path=str(local_path),
replace=True,
)
except TypeError:
samba_conn.download(path_from=remote_path, path_to=str(local_path))
return local_path.read_bytes()
if hasattr(samba_conn, "open"):
with samba_conn:
with samba_conn.open(remote_path, "rb") as remote_file:
return remote_file.read()
raise RuntimeError("Коннектор SAMBA не поддерживает чтение файла (download/open).")
def upload_local_file(local_path: Path, remote_path: str, samba_conn) -> None:
remote_dir = get_parent_dir(remote_path)
ensure_dir_exists(remote_dir, samba_conn)
if hasattr(samba_conn, "upload"):
with samba_conn:
try:
samba_conn.upload(
remote_file_path=remote_path,
local_file_path=str(local_path),
replace=True,
)
except TypeError:
samba_conn.upload(path_from=str(local_path), path_to=remote_path)
return
if hasattr(samba_conn, "copy"):
with samba_conn:
samba_conn.copy(
path_from=str(local_path), path_to=remote_path, replace=True
)
return
raise RuntimeError("Коннектор SAMBA не поддерживает запись файла (upload/copy).")
def _check_samba_dir_access(
target_dir: str, samba_conn, source_name: str
) -> tuple[bool, str | None]:
try:
with samba_conn:
if not samba_conn.path_exists(target_dir):
return False, f"{source_name} не найдена: {target_dir}"
_ = samba_conn.listdir(target_dir)
return True, None
except Exception as exc: # noqa: BLE001
return False, f"Ошибка доступа к папке: {target_dir}. {exc}"
def _list_samba_files(input_dir: str, samba_conn) -> list[str]:
items: list[str] = []
with samba_conn:
for name in sorted(samba_conn.listdir(input_dir)):
full_path = join_path(input_dir, name)
if not samba_conn.is_file(full_path):
continue
items.append(name)
return items
def _next_available_samba_target(dir_path: str, file_name: str, samba_conn) -> str:
with samba_conn:
if not samba_conn.path_exists(dir_path):
samba_conn.mkdir(dir_path, parent=True)
base_target = join_path(dir_path, file_name)
if not samba_conn.path_exists(base_target):
return base_target
base = Path(file_name)
index = 1
while True:
candidate_name = f"{base.stem}_{index}{base.suffix}"
candidate = join_path(dir_path, candidate_name)
if not samba_conn.path_exists(candidate):
return candidate
index += 1
def _samba_move_file(path_from: str, path_to: str, samba_conn) -> None:
with samba_conn:
samba_conn.copy(path_from=path_from, path_to=path_to, replace=False)
samba_conn.delete(path_from)
def _extract_server(smb_base_path: str) -> str:
server = urlparse(smb_base_path).netloc
if not server:
raise RuntimeError(f"Некорректный SMB путь: {smb_base_path}")
return server
def _normalize_unc_path(path: str) -> str:
normalized = path.replace("\\", "/").strip()
while normalized.startswith("///"):
normalized = normalized[1:]
if not normalized.startswith("//"):
normalized = "//" + normalized.lstrip("/")
return normalized
def _import_smbclient():
try:
import smbclient # type: ignore[import-not-found]
except ImportError as exc: # noqa: BLE001
raise RuntimeError("Библиотека smbprotocol/smbclient не установлена.") from exc
return smbclient