from __future__ import annotations import tempfile from dataclasses import dataclass from datetime import datetime from pathlib import Path from urllib.parse import urlparse from uuid import uuid4 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) # In some smbclient/smbprotocol versions scandir() returns a generator, # not a context manager, so we iterate directly. entries = smbclient.scandir(full_path) 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) if _looks_like_unc_without_prefix(path, self.smb_base_path): return _normalize_unc_path("//" + path.lstrip("/\\")) 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 check_dir_write_access( target_dir: str, samba_conn, source_name: str ) -> tuple[bool, str | None]: probe_prefix = f".sfm_write_probe_{uuid4().hex}" probe_src = join_path(target_dir, f"{probe_prefix}.tmp") probe_dst = join_path(target_dir, f"{probe_prefix}_renamed.tmp") try: with samba_conn: if not samba_conn.path_exists(target_dir): return False, f"{source_name} не найдена: {target_dir}" _write_probe_file(probe_src, samba_conn) if hasattr(samba_conn, "copy") and hasattr(samba_conn, "delete"): samba_conn.copy(path_from=probe_src, path_to=probe_dst, replace=False) samba_conn.delete(probe_src) samba_conn.delete(probe_dst) elif hasattr(samba_conn, "delete"): samba_conn.delete(probe_src) return True, None except Exception as exc: # noqa: BLE001 # Best-effort cleanup for noisy probes. with samba_conn: for probe_path in (probe_src, probe_dst): try: if samba_conn.path_exists(probe_path) and hasattr( samba_conn, "delete" ): samba_conn.delete(probe_path) except Exception: # noqa: BLE001 pass return ( False, f"Недостаточно прав на запись/переименование/удаление в папке: {target_dir}. {exc}", ) def create_run_context( input_dir: str, samba_conn, now: datetime | None = None ) -> RunContext: # Артефакты формируем внутри указанного пользователем пути обработки. base_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): if not file_name.lower().endswith(".xml"): continue 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: try: with samba_conn: samba_conn.copy(path_from=path_from, path_to=path_to, replace=False) except Exception as exc: # noqa: BLE001 raise RuntimeError( "SMB move failed at copy stage: " f"source='{path_from}', target='{path_to}', " f"{_format_smb_exception(exc)}" ) from exc try: with samba_conn: samba_conn.delete(path_from) except Exception as exc: # noqa: BLE001 target_exists = _safe_path_exists(path_to, samba_conn) raise RuntimeError( "SMB move failed at delete stage after successful copy: " f"source='{path_from}', target='{path_to}', " f"target_exists={target_exists}, {_format_smb_exception(exc)}" ) from exc def _safe_path_exists(path: str, samba_conn) -> bool: try: with samba_conn: return samba_conn.path_exists(path) except Exception: # noqa: BLE001 return False def _format_smb_exception(exc: Exception) -> str: details = [f"error_type={type(exc).__name__}", f"error={exc}"] for attr_name in ("ntstatus", "status", "winerror", "errno"): value = getattr(exc, attr_name, None) if value is not None: details.append(f"{attr_name}={value}") return ", ".join(details) def _write_probe_file(remote_path: str, samba_conn) -> None: if hasattr(samba_conn, "open"): with samba_conn.open(remote_path, mode="wb") as remote_file: remote_file.write(b"sfm-probe") return if hasattr(samba_conn, "upload"): with tempfile.TemporaryDirectory() as temp_dir: local_probe = Path(temp_dir) / "probe.tmp" local_probe.write_bytes(b"sfm-probe") try: samba_conn.upload( remote_file_path=remote_path, local_file_path=str(local_probe), replace=False, ) except TypeError: samba_conn.upload(path_from=str(local_probe), path_to=remote_path) return raise RuntimeError("Коннектор SAMBA не поддерживает проверку записи (open/upload).") 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 def _looks_like_unc_without_prefix(path: str, smb_base_path: str) -> bool: normalized_path = path.replace("/", "\\").lstrip("\\") if not normalized_path: return False server = _extract_server(smb_base_path).lower() return normalized_path.lower().startswith(f"{server}\\")