"""SP37 follow-up #5: SftpClient.stat() method. The SftpClient wrapper previously exposed ``write_file``, ``list_inbound``, ``list_inbound_names``, ``download_inbound``, and ``read_file`` — but NO ``stat()``. The canonical ``cyclone.submission.submit_file`` helper needs to compare the local file size against the remote file size for idempotency; the lack of ``stat()`` forced the helper to bypass the wrapper and open paramiko directly (see ``submission/core.py:_default_sftp_factory``). This file adds a ``stat()`` method to the wrapper so the helper can use ``SftpClient(block=b)`` instead of bypassing the wrapper. Tests cover the stub implementation only (paramiko real-mode tests would need a real SFTP server). """ from __future__ import annotations from pathlib import Path import pytest from cyclone.clearhouse import SftpClient, SftpStat from cyclone.providers import SftpBlock def _stub_block(staging_dir: Path) -> SftpBlock: return SftpBlock( host="mft.example.com", port=22, username="user", auth={"password_keychain_account": "x"}, paths={"inbound": "/inbound", "outbound": "/outbound"}, staging_dir=str(staging_dir), stub=True, ) @pytest.fixture def staging(tmp_path: Path) -> Path: """A staging dir pre-populated with one file. Mirrors what the SP9 stub layout looks like when an operator has dropped a real inbound file for testing.""" inbound = tmp_path / "inbound" inbound.mkdir(parents=True) (inbound / "test.txt").write_bytes(b"hello world\n") return tmp_path # ---- SftpStat dataclass ----------------------------------------------------- # def test_sftp_stat_dataclass_exists(): """The wrapper exposes a small SftpStat dataclass for the result. Mirrors the paramiko ``SFTPAttributes`` shape (at least ``size``) but keeps the public surface narrow so callers can use it without depending on paramiko. """ assert SftpStat is not None stat = SftpStat(size=42, modified_at=None) assert stat.size == 42 assert stat.modified_at is None def test_sftp_stat_is_frozen(): """SftpStat is a frozen dataclass so it can be hashed / used as a cache key by future callers without surprise mutation.""" from dataclasses import FrozenInstanceError stat = SftpStat(size=1, modified_at=None) with pytest.raises(FrozenInstanceError): stat.size = 2 # type: ignore[misc] # ---- Public method --------------------------------------------------------- # def test_sftp_client_has_stat_method(): """``SftpClient`` exposes a ``stat(remote_path)`` public method. Guards the API surface — a future contributor who renames or removes ``stat()`` (forcing the helper to bypass the wrapper again) would break this test, prompting them to update the helper instead. """ assert hasattr(SftpClient, "stat") assert callable(SftpClient.stat) def test_sftp_client_stat_returns_size_in_stub_mode(staging: Path): """Stub mode: ``stat(remote_path)`` returns the local staging file's size via ``os.stat_result.st_size``. Mirrors what a real paramiko ``SFTPAttributes.st_size`` would return for a remote file. """ client = SftpClient(block=_stub_block(staging)) stat = client.stat("/inbound/test.txt") assert stat.size == len(b"hello world\n") def test_sftp_client_stat_returns_modified_at_in_stub_mode(staging: Path): """Stub mode: ``stat`` populates ``modified_at`` from the local file's mtime. Mirrors paramiko's behavior for parity.""" client = SftpClient(block=_stub_block(staging)) stat = client.stat("/inbound/test.txt") assert stat.modified_at is not None def test_sftp_client_stat_raises_for_missing_file(staging: Path): """Stub mode: ``stat`` raises ``FileNotFoundError`` for a path that doesn't exist locally — matches the real paramiko behavior (which raises ``IOError``/``FileNotFoundError``). The helper's SKIPPED-outcome short-circuit depends on this exception type. """ client = SftpClient(block=_stub_block(staging)) with pytest.raises(FileNotFoundError): client.stat("/inbound/missing.txt") def test_sftp_client_stat_handles_large_file(staging: Path): """Stub mode: ``stat`` returns the correct size for non-trivial files. Sanity check: the size path doesn't truncate or accidentally cast (e.g. to int8) on larger files. """ payload = b"x" * (1024 * 1024) # 1 MiB (staging / "inbound" / "big.bin").write_bytes(payload) client = SftpClient(block=_stub_block(staging)) stat = client.stat("/inbound/big.bin") assert stat.size == 1024 * 1024 def test_sftp_client_stat_supports_idempotency_check(staging: Path): """The submission helper's idempotency check works against the wrapper now. Replays the SKIPPED-outcome check from ``cyclone.submission.submit_file`` (lines 178-182) using the wrapper directly. Before this follow-up, the helper had to open paramiko directly because the wrapper lacked ``stat()``. """ payload = b"identical contents" (staging / "outbound").mkdir(parents=True, exist_ok=True) (staging / "outbound" / "claim.x12").write_bytes(payload) client = SftpClient(block=_stub_block(staging)) local_size = len(payload) # stat() returns the size that was previously uploaded. remote_stat = client.stat("/outbound/claim.x12") assert remote_stat.size == local_size # idempotency check passes # → SKIPPED outcome in the submission helper