diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py index 833f7209c..9bd760d50 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py @@ -18,14 +18,13 @@ from anyio import fail_after, run_process, sleep from anyio.streams.file import FileReadStream, FileWriteStream from jumpstarter_driver_network.driver import TcpNetwork, UnixNetwork, VsockNetwork -from jumpstarter_driver_opendal.driver import FlasherInterface from jumpstarter_driver_power.driver import PowerInterface, PowerReading from jumpstarter_driver_pyserial.driver import PySerial from pydantic import BaseModel, ByteSize, Field, TypeAdapter, ValidationError, validate_call from qemu.qmp import QMPClient from qemu.qmp.protocol import ConnectError, Runstate -from jumpstarter.driver import Driver, export +from jumpstarter.driver import Driver, FlasherInterface, export from jumpstarter.streams.encoding import AutoDecompressIterator diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py index c9246e504..1055a8d2c 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py @@ -9,7 +9,6 @@ import pytest import requests -from opendal import Operator from jumpstarter_driver_qemu.driver import Qemu @@ -77,8 +76,7 @@ def test_driver_qemu(tmp_path, ovmf): qemu.flasher.flash(cached_image.resolve()) else: qemu.flasher.flash( - f"pub/fedora/linux/releases/43/Cloud/{arch}/images/Fedora-Cloud-Base-Generic-43-1.6.{arch}.qcow2", - operator=Operator("http", endpoint="https://download.fedoraproject.org"), + f"https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/{arch}/images/Fedora-Cloud-Base-Generic-43-1.6.{arch}.qcow2", ) qemu.power.on() diff --git a/python/packages/jumpstarter-driver-qemu/pyproject.toml b/python/packages/jumpstarter-driver-qemu/pyproject.toml index d62d18c9d..e3c413876 100644 --- a/python/packages/jumpstarter-driver-qemu/pyproject.toml +++ b/python/packages/jumpstarter-driver-qemu/pyproject.toml @@ -14,7 +14,6 @@ dependencies = [ "jumpstarter", "jumpstarter-driver-composite", "jumpstarter-driver-network", - "jumpstarter-driver-opendal", "jumpstarter-driver-power", "jumpstarter-driver-pyserial", "pyyaml>=6.0.2", @@ -38,7 +37,6 @@ source = "vcs" raw-options = { 'root' = '../../../' } [tool.uv.sources] -jumpstarter-driver-opendal = { workspace = true } jumpstarter-driver-composite = { workspace = true } jumpstarter-driver-network = { workspace = true } jumpstarter-driver-pyserial = { workspace = true } diff --git a/python/packages/jumpstarter/jumpstarter/client/__init__.py b/python/packages/jumpstarter/jumpstarter/client/__init__.py index ae9ac7912..62dbf689c 100644 --- a/python/packages/jumpstarter/jumpstarter/client/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/client/__init__.py @@ -1,5 +1,6 @@ from .base import DriverClient from .client import client_from_path +from .flasher import FlasherClient, FlasherClientInterface from .lease import DirectLease, Lease -__all__ = ["DriverClient", "DirectLease", "client_from_path", "Lease"] +__all__ = ["DriverClient", "DirectLease", "FlasherClient", "FlasherClientInterface", "client_from_path", "Lease"] diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher.py b/python/packages/jumpstarter/jumpstarter/client/flasher.py new file mode 100644 index 000000000..7c79b183f --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/client/flasher.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import warnings +from abc import ABCMeta, abstractmethod +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from os import PathLike +from pathlib import Path +from typing import Any, Callable, Mapping, cast + +import click +from anyio import BrokenResourceError, EndOfStream +from anyio.abc import ObjectStream + +from jumpstarter.client import DriverClient +from jumpstarter.client.adapters import blocking +from jumpstarter.client.decorators import driver_click_group +from jumpstarter.common.resources import PresignedRequestResource +from jumpstarter.streams.encoding import Compression +from jumpstarter.streams.progress import ProgressAttribute + +PathBuf = str | PathLike + + +@dataclass(kw_only=True) +class _AsyncIteratorStream(ObjectStream[bytes]): + """Wraps an async iterator as an ObjectStream for resource_async.""" + + iterator: Any + total: int | None = None + + async def receive(self) -> bytes: + try: + return await self.iterator.__anext__() + except StopAsyncIteration: + raise EndOfStream from None + + async def send(self, item: bytes): + raise BrokenResourceError("read-only stream") + + async def send_eof(self): + pass + + async def aclose(self): + await self.iterator.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + if self.total is not None and self.total > 0: + return {ProgressAttribute.total: lambda: float(self.total)} + return {} + + +@dataclass(kw_only=True) +class _FileWriteObjectStream(ObjectStream[bytes]): + """Wraps a file path as a writable ObjectStream for resource_async.""" + + path: Path + _file: Any = field(default=None, init=False) + + async def receive(self) -> bytes: + raise EndOfStream + + async def send(self, item: bytes): + if self._file is None: + import anyio + + self._file = await anyio.open_file(self.path, "wb") + await self._file.write(item) + + async def send_eof(self): + if self._file is not None: + await self._file.aclose() + self._file = None + + async def aclose(self): + if self._file is not None: + await self._file.aclose() + self._file = None + + +def _parse_path(path: PathBuf) -> tuple[Path | None, str | None]: + """Parse a path into either a local Path or an HTTP URL. + + Returns (local_path, None) for local files, or (None, url) for HTTP URLs. + """ + path_str = str(path) + if path_str.startswith(("http://", "https://")): + return None, path_str + return Path(path).resolve(), None + + +@blocking +@asynccontextmanager +async def _local_file_adapter( + *, + client: DriverClient, + path: Path, + mode: str = "rb", + compression: Compression | None = None, +): + """Stream a local file via resource_async, without opendal.""" + import anyio + + if mode == "rb": + # Read mode: stream file content to exporter + file_size = path.stat().st_size + + async def file_reader(): + async with await anyio.open_file(path, "rb") as f: + while True: + chunk = await f.read(65536) + if not chunk: + break + yield chunk + + stream = _AsyncIteratorStream( + iterator=file_reader(), + total=file_size, + ) + + async with client.resource_async(stream, content_encoding=compression) as res: + yield res + else: + # Write mode: receive content from exporter into file + stream = _FileWriteObjectStream(path=path) + async with client.resource_async(stream, content_encoding=compression) as res: + yield res + + +@blocking +@asynccontextmanager +async def _http_url_adapter( + *, + client: DriverClient, + url: str, + mode: str = "rb", +): + """Create a PresignedRequestResource for an HTTP URL. + + The exporter already handles HTTP downloads via aiohttp, + so we just pass the URL as a presigned GET request. + """ + if mode == "rb": + yield PresignedRequestResource( + headers={}, + url=url, + method="GET", + ).model_dump(mode="json") + else: + yield PresignedRequestResource( + headers={}, + url=url, + method="PUT", + ).model_dump(mode="json") + + +class FlasherClientInterface(metaclass=ABCMeta): + @abstractmethod + def flash( + self, + path: PathBuf | dict[str, PathBuf], + *, + target: str | None = None, + compression: Compression | None = None, + ): + """Flash image to DUT""" + ... + + @abstractmethod + def dump( + self, + path: PathBuf, + *, + target: str | None = None, + compression: Compression | None = None, + ): + """Dump image from DUT""" + ... + + def cli(self): + @driver_click_group(self) + def base(): + """Generic flasher interface""" + pass + + @base.command() + @click.argument("file", nargs=-1, required=False) + @click.option( + "--target", + "-t", + "target_specs", + multiple=True, + help="name:file", + ) + @click.option("--compression", type=click.Choice(Compression, case_sensitive=False)) + def flash(file, target_specs, compression): + if target_specs: + mapping: dict[str, str] = {} + for spec in target_specs: + if ":" not in spec: + raise click.ClickException(f"Invalid target spec '{spec}', expected name:file") + name, img = spec.split(":", 1) + mapping[name] = img + self.flash(cast(dict[str, PathBuf], mapping), compression=compression) + return + + if not file: + raise click.ClickException("FILE argument is required unless --target/-t is used") + + self.flash(file[0], target=None, compression=compression) + + @base.command() + @click.argument("file") + @click.option("--target", type=str) + @click.option("--compression", type=click.Choice(Compression, case_sensitive=False)) + def dump(file, target, compression): + """Dump image from DUT to file""" + self.dump(file, target=target, compression=compression) + + return base + + +class FlasherClient(FlasherClientInterface, DriverClient): + def _flash_single( + self, + image: PathBuf, + *, + target: str | None, + compression: Compression | None, + ): + """Flash image to DUT""" + local_path, url = _parse_path(image) + + if url is not None: + if compression is not None: + warnings.warn( + "compression parameter is ignored for HTTP URLs", + stacklevel=2, + ) + # HTTP URL: pass as presigned request for exporter-side download + with _http_url_adapter(client=self, url=url, mode="rb") as handle: + return self.call("flash", handle, target) + else: + # Local file: stream via resource_async + with _local_file_adapter(client=self, path=local_path, mode="rb", compression=compression) as handle: + return self.call("flash", handle, target) + + def flash( + self, + path: PathBuf | dict[str, PathBuf], + *, + target: str | None = None, + compression: Compression | None = None, + ): + if isinstance(path, dict): + if target is not None: + from jumpstarter.common.exceptions import ArgumentError + + raise ArgumentError("'target' parameter is not valid when flashing multiple images") + + results: dict[str, object] = {} + for part, img in path.items(): + results[part] = self._flash_single(img, target=part, compression=compression) + return results + + return self._flash_single(path, target=target, compression=compression) + + def dump( + self, + path: PathBuf, + *, + target: str | None = None, + compression: Compression | None = None, + ): + """Dump image from DUT""" + local_path, url = _parse_path(path) + + if url is not None: + if compression is not None: + warnings.warn( + "compression parameter is ignored for HTTP URLs", + stacklevel=2, + ) + with _http_url_adapter(client=self, url=url, mode="wb") as handle: + return self.call("dump", handle, target) + else: + with _local_file_adapter(client=self, path=local_path, mode="wb", compression=compression) as handle: + return self.call("dump", handle, target) diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher_test.py b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py new file mode 100644 index 000000000..c306b6108 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py @@ -0,0 +1,424 @@ +import warnings + +import pytest + +from jumpstarter.client.flasher import _parse_path + + +class TestParsePath: + """Tests for _parse_path which routes local files vs HTTP URLs.""" + + def test_http_url(self): + local, url = _parse_path("http://example.com/image.qcow2") + assert local is None + assert url == "http://example.com/image.qcow2" + + def test_https_url(self): + local, url = _parse_path("https://download.fedoraproject.org/pub/fedora/image.qcow2") + assert local is None + assert url == "https://download.fedoraproject.org/pub/fedora/image.qcow2" + + def test_local_path_string(self, tmp_path): + test_file = tmp_path / "image.qcow2" + test_file.touch() + local, url = _parse_path(str(test_file)) + assert url is None + assert local == test_file.resolve() + + def test_local_path_object(self, tmp_path): + test_file = tmp_path / "image.qcow2" + test_file.touch() + local, url = _parse_path(test_file) + assert url is None + assert local == test_file.resolve() + + def test_relative_path(self): + local, url = _parse_path("relative/path/image.qcow2") + assert url is None + assert local is not None + assert local.is_absolute() + + def test_url_with_query_params(self): + test_url = "https://example.com/image.qcow2?token=abc&expires=123" + local, url = _parse_path(test_url) + assert local is None + assert url == test_url + + +class TestHttpUrlAdapter: + """Tests for _http_url_adapter which creates PresignedRequestResource for HTTP URLs.""" + + @pytest.mark.anyio + async def test_read_mode_produces_get_request(self): + from jumpstarter.client.flasher import _http_url_adapter + + # _http_url_adapter is decorated with @blocking, but the underlying + # async generator can be tested directly via its __wrapped__ attribute + gen = _http_url_adapter.__wrapped__( + client=None, + url="https://example.com/firmware.bin", + mode="rb", + ) + result = await gen.__aenter__() + + # Should produce a serialized PresignedRequestResource with GET method + assert result["url"] == "https://example.com/firmware.bin" + assert result["method"] == "GET" + assert result["headers"] == {} + + await gen.__aexit__(None, None, None) + + @pytest.mark.anyio + async def test_write_mode_produces_put_request(self): + from jumpstarter.client.flasher import _http_url_adapter + + gen = _http_url_adapter.__wrapped__( + client=None, + url="https://example.com/dump.bin", + mode="wb", + ) + result = await gen.__aenter__() + + assert result["url"] == "https://example.com/dump.bin" + assert result["method"] == "PUT" + assert result["headers"] == {} + + await gen.__aexit__(None, None, None) + + +class TestAsyncIteratorStream: + """Tests for _AsyncIteratorStream receive/send/aclose lifecycle.""" + + @pytest.mark.anyio + async def test_receive_yields_chunks(self): + from anyio import EndOfStream + + from jumpstarter.client.flasher import _AsyncIteratorStream + + async def gen(): + yield b"chunk1" + yield b"chunk2" + + stream = _AsyncIteratorStream(iterator=gen(), total=12) + assert await stream.receive() == b"chunk1" + assert await stream.receive() == b"chunk2" + with pytest.raises(EndOfStream): + await stream.receive() + + @pytest.mark.anyio + async def test_send_raises_broken_resource(self): + from anyio import BrokenResourceError + + from jumpstarter.client.flasher import _AsyncIteratorStream + + async def gen(): + yield b"data" + + stream = _AsyncIteratorStream(iterator=gen()) + with pytest.raises(BrokenResourceError): + await stream.send(b"data") + + @pytest.mark.anyio + async def test_aclose_propagates_to_generator(self): + from jumpstarter.client.flasher import _AsyncIteratorStream + + closed = False + + async def gen(): + nonlocal closed + try: + yield b"data" + yield b"more" + finally: + closed = True + + stream = _AsyncIteratorStream(iterator=gen()) + await stream.receive() + await stream.aclose() + assert closed + + @pytest.mark.anyio + async def test_extra_attributes_with_total(self): + from jumpstarter.client.flasher import _AsyncIteratorStream + from jumpstarter.streams.progress import ProgressAttribute + + async def gen(): + yield b"data" + + stream = _AsyncIteratorStream(iterator=gen(), total=100) + attrs = stream.extra_attributes + assert ProgressAttribute.total in attrs + assert attrs[ProgressAttribute.total]() == 100.0 + + @pytest.mark.anyio + async def test_extra_attributes_without_total(self): + from jumpstarter.client.flasher import _AsyncIteratorStream + + async def gen(): + yield b"data" + + stream = _AsyncIteratorStream(iterator=gen(), total=None) + assert stream.extra_attributes == {} + + @pytest.mark.anyio + async def test_receive_on_empty_iterator(self): + from anyio import EndOfStream + + from jumpstarter.client.flasher import _AsyncIteratorStream + + async def gen(): + return + yield # noqa: RET504 + + stream = _AsyncIteratorStream(iterator=gen()) + with pytest.raises(EndOfStream): + await stream.receive() + + +class TestFileWriteObjectStream: + """Tests for _FileWriteObjectStream send/aclose lifecycle.""" + + @pytest.mark.anyio + async def test_write_and_read_back(self, tmp_path): + from jumpstarter.client.flasher import _FileWriteObjectStream + + out = tmp_path / "output.bin" + stream = _FileWriteObjectStream(path=out) + await stream.send(b"hello ") + await stream.send(b"world") + await stream.send_eof() + assert out.read_bytes() == b"hello world" + + @pytest.mark.anyio + async def test_receive_raises_end_of_stream(self, tmp_path): + from anyio import EndOfStream + + from jumpstarter.client.flasher import _FileWriteObjectStream + + stream = _FileWriteObjectStream(path=tmp_path / "out.bin") + with pytest.raises(EndOfStream): + await stream.receive() + + @pytest.mark.anyio + async def test_aclose_without_open(self, tmp_path): + from jumpstarter.client.flasher import _FileWriteObjectStream + + stream = _FileWriteObjectStream(path=tmp_path / "out.bin") + await stream.aclose() + + @pytest.mark.anyio + async def test_aclose_closes_file(self, tmp_path): + from jumpstarter.client.flasher import _FileWriteObjectStream + + out = tmp_path / "output.bin" + stream = _FileWriteObjectStream(path=out) + await stream.send(b"data") + await stream.aclose() + assert out.read_bytes() == b"data" + assert stream._file is None + + +class TestFlasherClientRouting: + """Tests that FlasherClient routes HTTP URLs vs local paths correctly.""" + + def test_flash_single_routes_http_url(self): + """Verify that an HTTP URL goes through _http_url_adapter, not _local_file_adapter.""" + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + + mock_http = MagicMock() + mock_http.__enter__ = MagicMock(return_value="http_handle") + mock_http.__exit__ = MagicMock(return_value=False) + + mock_local = MagicMock() + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http) as http_patch, + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_local) as local_patch, + patch.object(client, "call", return_value=None) as call_mock, + ): + client._flash_single("https://example.com/image.bin", target=None, compression=None) + + http_patch.assert_called_once_with(client=client, url="https://example.com/image.bin", mode="rb") + local_patch.assert_not_called() + call_mock.assert_called_once_with("flash", "http_handle", None) + + def test_flash_single_routes_local_path(self, tmp_path): + """Verify that a local path goes through _local_file_adapter, not _http_url_adapter.""" + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + test_file = tmp_path / "image.bin" + test_file.touch() + + mock_local = MagicMock() + mock_local.__enter__ = MagicMock(return_value="local_handle") + mock_local.__exit__ = MagicMock(return_value=False) + + mock_http = MagicMock() + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http) as http_patch, + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_local) as local_patch, + patch.object(client, "call", return_value=None) as call_mock, + ): + client._flash_single(str(test_file), target=None, compression=None) + + local_patch.assert_called_once() + http_patch.assert_not_called() + call_mock.assert_called_once_with("flash", "local_handle", None) + + def test_dump_routes_http_url(self): + """Verify that dump with an HTTP URL goes through _http_url_adapter.""" + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + + mock_http = MagicMock() + mock_http.__enter__ = MagicMock(return_value="http_handle") + mock_http.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http) as http_patch, + patch("jumpstarter.client.flasher._local_file_adapter") as local_patch, + patch.object(client, "call", return_value=None) as call_mock, + ): + client.dump("https://example.com/dump.bin", target=None) + + http_patch.assert_called_once_with(client=client, url="https://example.com/dump.bin", mode="wb") + local_patch.assert_not_called() + call_mock.assert_called_once_with("dump", "http_handle", None) + + def test_dump_routes_local_path(self, tmp_path): + """Verify that dump with a local path goes through _local_file_adapter.""" + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + test_file = tmp_path / "dump.bin" + + mock_local = MagicMock() + mock_local.__enter__ = MagicMock(return_value="local_handle") + mock_local.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._http_url_adapter") as http_patch, + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_local) as local_patch, + patch.object(client, "call", return_value=None) as call_mock, + ): + client.dump(str(test_file), target=None) + + local_patch.assert_called_once() + http_patch.assert_not_called() + call_mock.assert_called_once_with("dump", "local_handle", None) + + +class TestFlasherClientMultiTarget: + """Tests for dict-based multi-target flash.""" + + def test_flash_dict_calls_flash_single_per_entry(self): + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + + mock_http = MagicMock() + mock_http.__enter__ = MagicMock(return_value="handle") + mock_http.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http), + patch.object(client, "call", return_value="ok") as call_mock, + ): + results = client.flash( + {"boot": "https://example.com/boot.bin", "root": "https://example.com/root.bin"}, + compression=None, + ) + assert results == {"boot": "ok", "root": "ok"} + assert call_mock.call_count == 2 + + def test_flash_dict_with_target_raises_argument_error(self): + from jumpstarter.client.flasher import FlasherClient + from jumpstarter.common.exceptions import ArgumentError + + client = object.__new__(FlasherClient) + + with pytest.raises(ArgumentError, match="'target' parameter is not valid"): + client.flash({"boot": "/tmp/boot.bin"}, target="some_target") + + +class TestCompressionWarning: + """Tests that compression parameter warns when used with HTTP URLs.""" + + def test_flash_http_with_compression_warns(self): + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + + mock_http = MagicMock() + mock_http.__enter__ = MagicMock(return_value="handle") + mock_http.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http), + patch.object(client, "call", return_value=None), + ): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + client._flash_single("https://example.com/image.bin", target=None, compression="zstd") + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) + + def test_flash_local_with_compression_no_warning(self, tmp_path): + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + test_file = tmp_path / "image.bin" + test_file.touch() + + mock_local = MagicMock() + mock_local.__enter__ = MagicMock(return_value="handle") + mock_local.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_local), + patch.object(client, "call", return_value=None), + ): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + client._flash_single(str(test_file), target=None, compression="zstd") + assert len(w) == 0 + + def test_dump_http_with_compression_warns(self): + from unittest.mock import MagicMock, patch + + from jumpstarter.client.flasher import FlasherClient + + client = object.__new__(FlasherClient) + + mock_http = MagicMock() + mock_http.__enter__ = MagicMock(return_value="handle") + mock_http.__exit__ = MagicMock(return_value=False) + + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http), + patch.object(client, "call", return_value=None), + ): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + client.dump("https://example.com/dump.bin", target=None, compression="zstd") + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) diff --git a/python/packages/jumpstarter/jumpstarter/driver/__init__.py b/python/packages/jumpstarter/jumpstarter/driver/__init__.py index 01c8e388e..ac7400106 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/driver/__init__.py @@ -1,4 +1,5 @@ from .base import Driver from .decorators import export, exportstream +from .flasher import FlasherInterface -__all__ = ["Driver", "export", "exportstream"] +__all__ = ["Driver", "FlasherInterface", "export", "exportstream"] diff --git a/python/packages/jumpstarter/jumpstarter/driver/flasher.py b/python/packages/jumpstarter/jumpstarter/driver/flasher.py new file mode 100644 index 000000000..096314808 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/driver/flasher.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + + +class FlasherInterface(metaclass=ABCMeta): + @classmethod + def client(cls) -> str: + return "jumpstarter.client.flasher.FlasherClient" + + @abstractmethod + def flash(self, source, target: str | None = None): ... + + @abstractmethod + def dump(self, target, partition: str | None = None): ... diff --git a/python/packages/jumpstarter/pyproject.toml b/python/packages/jumpstarter/pyproject.toml index a09ce03f2..9a9275076 100644 --- a/python/packages/jumpstarter/pyproject.toml +++ b/python/packages/jumpstarter/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "rich>=14.0.0", "tenacity>=8.2.0", "backports-zstd>=1.1.0 ; python_full_version < '3.14'", + "click>=8.1.7.2", ] [dependency-groups]