feat(sp13): paramiko-backed SftpClient wire-up

Replace SftpClient stub write_file/list_inbound/read_file
implementations with real paramiko SSHClient + SFTPClient
calls. The public API (SftpClient.write_file, list_inbound,
read_file, get_secret) is unchanged from SP9 — same signature,
same return types — so the API layer needs no changes.

Real-mode behavior:
* _connect() returns a context manager yielding (ssh, sftp);
  closes both on exit. Lazy-imports paramiko so the stub-only
  test path doesn't need the dependency.
* Auth resolves from SftpBlock.auth: password_keychain_account
  (MFT model) or key_file + optional key_passphrase_keychain_account.
  Missing Keychain entries fail loud (RuntimeError) rather than
  silently attempting empty-password auth.
* write_file: opens sftp.open(remote, 'wb') and writes bytes;
  mkdirs the parent dir (idempotent — MFT pre-creates FromHPE/ToHPE).
* list_inbound: listdir_attr + per-file download into local
  staging cache; skips directory entries (0o040000 mask).
* read_file: download via shutil.copyfileobj into BytesIO.

Stub mode is unchanged. AutoAddPolicy for first-time MFT host
fingerprint; operator should pin the key for production.

Adds tests/test_sftp_paramiko.py: 9 tests covering
* stub still works
* real-mode connect builds correct paramiko call from
  password_keychain_account, raises on missing Keychain,
  raises on missing auth config, raises on STUB_SECRET
* write_file opens 'wb' on the right path and writes bytes
* list_inbound translates attrs into InboundFile records and
  caches files locally; skips dirs

Removes 2 obsolete tests in test_sftp_stub.py that expected
SP13-mode to raise NotImplementedError.

pyproject.toml: new optional 'sftp' extra (paramiko>=3.4,<6).
This commit is contained in:
Tyler
2026-06-21 00:00:13 -06:00
parent 1225013fb0
commit a11e051f82
4 changed files with 461 additions and 38 deletions
-14
View File
@@ -72,20 +72,6 @@ def test_stub_get_secret_returns_stub_when_keychain_empty(sftp_block):
assert secret == "<stub-secret>"
def test_real_mode_write_raises_not_implemented(sftp_block):
block = sftp_block.model_copy(update={"stub": False})
client = SftpClient(block)
with pytest.raises(NotImplementedError, match="SP13"):
client.write_file("/x.x12", b"y")
def test_real_mode_list_inbound_raises_not_implemented(sftp_block):
block = sftp_block.model_copy(update={"stub": False})
client = SftpClient(block)
with pytest.raises(NotImplementedError, match="SP13"):
client.list_inbound()
def test_stub_read_file_raises(sftp_block):
client = SftpClient(sftp_block)
with pytest.raises(RuntimeError, match="Stub SFTP cannot read"):