| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- """FTP downloads stop at their deadline, and the deadline fits the file (TO-C1, TO-C3).
- The bytes download used to give up on its caller at the deadline and leave the
- transfer running in its worker thread, and ``with_ftp_retry`` read that as a
- retryable failure, so each retry opened another FTP session alongside the
- first. The printer file download was held at a flat 600 s whatever the size:
- the size-based extension was capped at 300 s in total, below that base.
- """
- import asyncio
- import threading
- from pathlib import Path
- import pytest
- from backend.app.services import bambu_ftp, printer_media
- from backend.app.services.bambu_ftp import DownloadDeadlineExceeded
- class _StuckClient:
- """A printer that keeps a transfer going until it is told to stop."""
- instances: list["_StuckClient"] = []
- def __init__(self, *_args, **_kwargs) -> None:
- self.cancelled = threading.Event()
- self.finished = threading.Event()
- _StuckClient.instances.append(self)
- def connect(self) -> bool:
- return True
- def disconnect(self) -> None:
- pass
- def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
- try:
- while cancel_event is None or not cancel_event.is_set():
- if self.cancelled.wait(0.01):
- break
- self.cancelled.set()
- return None
- finally:
- self.finished.set()
- @pytest.fixture
- def stuck(monkeypatch):
- _StuckClient.instances = []
- monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _StuckClient)
- return _StuckClient.instances
- @pytest.mark.asyncio
- async def test_a_sized_download_past_its_deadline_stops_and_says_so(stuck):
- with pytest.raises(DownloadDeadlineExceeded):
- await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/timelapse/v.mp4", timeout=0.1, expected_size=10)
- # The transfer was stopped before the call returned, not left running.
- assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
- @pytest.mark.asyncio
- async def test_an_unsized_download_past_its_deadline_stops_and_fails(stuck):
- result = await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1)
- assert result is None
- assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
- @pytest.mark.asyncio
- async def test_a_retry_never_starts_a_second_session_beside_the_first(stuck):
- with pytest.raises(DownloadDeadlineExceeded):
- await bambu_ftp.with_ftp_retry(
- bambu_ftp.download_file_bytes_async,
- "10.0.0.5",
- "code",
- "/timelapse/v.mp4",
- timeout=0.1,
- expected_size=10,
- max_retries=3,
- retry_delay=0,
- operation_name="test",
- )
- assert len(stuck) == 1
- @pytest.mark.asyncio
- async def test_a_cancelled_caller_stops_the_transfer(stuck):
- task = asyncio.create_task(bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=30))
- await asyncio.sleep(0.05)
- task.cancel()
- with pytest.raises(asyncio.CancelledError):
- await task
- assert stuck[0].cancelled.is_set() and stuck[0].finished.is_set()
- @pytest.mark.asyncio
- async def test_a_download_that_finishes_returns_its_bytes(monkeypatch):
- class _Client(_StuckClient):
- def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
- return b"video"
- monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Client)
- assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", expected_size=5) == b"video"
- @pytest.mark.asyncio
- async def test_an_unlisted_download_gets_the_time_its_reported_size_buys(monkeypatch):
- """No listing size: the SIZE the printer reports at the start extends the
- deadline, as for download_file_async (a 60 MB 3MF on an A1)."""
- class _Slow(_StuckClient):
- def download_file(self, _remote_path, expected_size=None, cancel_event=None, size_callback=None, **_kwargs):
- size_callback(10 * 1024 * 1024)
- if self.cancelled.wait(0.3) or (cancel_event and cancel_event.is_set()):
- return None
- return b"3mf"
- monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Slow)
- assert await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/model.3mf", timeout=0.1) == b"3mf"
- def _client_with(error: Exception) -> bambu_ftp.BambuFTPClient:
- from unittest.mock import MagicMock
- client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
- client._ftp = MagicMock()
- client._ftp.retrbinary.side_effect = error
- client._ftp.size.side_effect = error
- return client
- def test_a_missing_path_is_told_apart_when_asked():
- import ftplib
- missing = ftplib.error_perm("550 No such file")
- with pytest.raises(bambu_ftp.FileNotOnPrinterError):
- _client_with(missing).download_file("/gone.3mf", report_missing=True)
- with pytest.raises(bambu_ftp.FileNotOnPrinterError):
- _client_with(missing).download_file("/gone.3mf", size_callback=lambda _n: None, report_missing=True)
- # Every other caller still reads it as a failed download.
- assert _client_with(missing).download_file("/gone.3mf") is None
- def test_other_ftp_errors_are_failures_not_missing():
- import ftplib
- assert _client_with(ftplib.error_perm("530 Not logged in")).download_file("/x", report_missing=True) is None
- assert _client_with(OSError("reset")).download_file("/x", report_missing=True) is None
- def test_the_size_extension_can_reach_past_the_print_start_ceiling():
- size = 250 * 1024 * 1024 # a 250 MB /ipcam chunk
- assert bambu_ftp._download_extension(size, 600) == 0.0 # print start keeps its 300 s ceiling
- assert bambu_ftp._download_extension(size, 600, ceiling=1800) == pytest.approx(1200)
- # A preview of a 60 MB 3MF at the default 300 s base, with the file browser's budget.
- assert bambu_ftp._download_extension(60 * 1024 * 1024, 300, ceiling=1800) == pytest.approx(1500)
- @pytest.mark.asyncio
- async def test_a_transfer_that_does_not_stop_at_once_is_still_waited_for(monkeypatch):
- """A read blocked on the socket only notices the cancel when it returns:
- the call must not come back (and a retry must not start) before that."""
- class _Deaf(_StuckClient):
- def download_file(self, _remote_path, expected_size=None, cancel_event=None, **_kwargs):
- try:
- self.cancelled.wait(0.8) # ignores the cancel until the "socket" returns
- return None
- finally:
- self.finished.set()
- monkeypatch.setattr(bambu_ftp, "BambuFTPClient", _Deaf)
- _StuckClient.instances = []
- with pytest.raises(DownloadDeadlineExceeded):
- await bambu_ftp.download_file_bytes_async("10.0.0.5", "code", "/v.mp4", timeout=0.1, expected_size=10)
- assert _StuckClient.instances[0].finished.is_set()
- @pytest.mark.asyncio
- async def test_a_printer_file_download_may_use_the_whole_job_budget(monkeypatch, tmp_path):
- seen = {}
- async def fake_download(*_args, **kwargs):
- seen.update(kwargs)
- return False
- monkeypatch.setattr(printer_media, "download_file_async", fake_download)
- monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
- printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
- with pytest.raises(printer_media.PrinterFileDownloadError):
- await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 250 * 1024 * 1024, bundle_key="k1")
- assert seen["max_total_timeout"] == printer_media.MAX_PRINTER_ZIP_PREPARE_SECONDS
- @pytest.mark.asyncio
- async def test_a_printer_file_past_its_deadline_is_a_timeout(monkeypatch, tmp_path):
- async def fake_download(*_args, **_kwargs):
- raise DownloadDeadlineExceeded("/ipcam/clip.mp4")
- monkeypatch.setattr(printer_media, "download_file_async", fake_download)
- monkeypatch.setattr(printer_media, "_ensure_printer_zip_root", lambda: tmp_path)
- printer = type("P", (), {"id": 1, "ip_address": "10.0.0.5", "access_code": "c", "model": "P1S"})()
- with pytest.raises(TimeoutError):
- await printer_media.build_printer_file(printer, "/ipcam/clip.mp4", 1000, bundle_key="k2")
- assert not (Path(tmp_path) / "k2").exists()
- def test_a_size_refusal_is_left_to_retr():
- """A 550 to SIZE (no such command, a file still being written) does not
- make a file missing that RETR then serves."""
- import ftplib
- from unittest.mock import MagicMock
- client = bambu_ftp.BambuFTPClient("10.0.0.5", "code")
- client._ftp = MagicMock()
- client._ftp.size.side_effect = ftplib.error_perm("550 Could not get file size")
- client._ftp.retrbinary.side_effect = lambda _cmd, write: write(b"3mf-bytes")
- assert client.download_file("/model.3mf", size_callback=lambda _n: None, report_missing=True) == b"3mf-bytes"
|