diff --git a/aiohasupervisor/backups.py b/aiohasupervisor/backups.py index 2737368..1ec563b 100644 --- a/aiohasupervisor/backups.py +++ b/aiohasupervisor/backups.py @@ -1,5 +1,10 @@ """Backups client for supervisor.""" +from collections.abc import AsyncIterator + +from aiohttp import MultipartWriter +from multidict import MultiDict + from .client import _SupervisorComponentClient from .const import ResponseType from .models.backups import ( @@ -15,6 +20,8 @@ NewBackup, PartialBackupOptions, PartialRestoreOptions, + UploadBackupOptions, + UploadedBackup, ) @@ -102,4 +109,29 @@ async def partial_restore( ) return BackupJob.from_dict(result.data) - # Omitted for now - Upload and download backup + async def upload_backup( + self, stream: AsyncIterator[bytes], options: UploadBackupOptions | None = None + ) -> str: + """Upload backup by stream and return slug.""" + params = MultiDict() + if options and options.location: + for location in options.location: + params.add("location", location or "") + + with MultipartWriter("form-data") as mp: + mp.append(stream) + result = await self._client.post( + "backups/new/upload", + params=params, + data=mp, + response_type=ResponseType.JSON, + ) + + return UploadedBackup.from_dict(result.data).slug + + async def download_backup(self, backup: str) -> AsyncIterator[bytes]: + """Download backup and return stream.""" + result = await self._client.get( + f"backups/{backup}/download", response_type=ResponseType.STREAM + ) + return result.data diff --git a/aiohasupervisor/client.py b/aiohasupervisor/client.py index 7c9c396..66ca37c 100644 --- a/aiohasupervisor/client.py +++ b/aiohasupervisor/client.py @@ -12,6 +12,7 @@ ClientSession, ClientTimeout, ) +from multidict import MultiDict from yarl import URL from .const import DEFAULT_TIMEOUT, ResponseType @@ -27,6 +28,7 @@ SupervisorTimeoutError, ) from .models.base import Response, ResultType +from .utils.aiohttp import ChunkAsyncStreamIterator VERSION = metadata.version(__package__) @@ -53,12 +55,33 @@ class _SupervisorClient: session: ClientSession | None = None _close_session: bool = field(default=False, init=False) + async def _raise_on_status(self, response: ClientResponse) -> None: + """Raise appropriate exception on status.""" + if response.status >= HTTPStatus.BAD_REQUEST.value: + exc_type: type[SupervisorError] = SupervisorError + match response.status: + case HTTPStatus.BAD_REQUEST: + exc_type = SupervisorBadRequestError + case HTTPStatus.UNAUTHORIZED: + exc_type = SupervisorAuthenticationError + case HTTPStatus.FORBIDDEN: + exc_type = SupervisorForbiddenError + case HTTPStatus.NOT_FOUND: + exc_type = SupervisorNotFoundError + case HTTPStatus.SERVICE_UNAVAILABLE: + exc_type = SupervisorServiceUnavailableError + + if is_json(response): + result = Response.from_json(await response.text()) + raise exc_type(result.message, result.job_id) + raise exc_type() + async def _request( self, method: HTTPMethod, uri: str, *, - params: dict[str, str] | None, + params: dict[str, str] | MultiDict[str] | None, response_type: ResponseType, json: dict[str, Any] | None = None, data: Any = None, @@ -94,7 +117,7 @@ async def _request( self._close_session = True try: - async with self.session.request( + response = await self.session.request( method.value, url, timeout=timeout, @@ -102,34 +125,20 @@ async def _request( params=params, json=json, data=data, - ) as response: - if response.status >= HTTPStatus.BAD_REQUEST.value: - exc_type: type[SupervisorError] = SupervisorError - match response.status: - case HTTPStatus.BAD_REQUEST: - exc_type = SupervisorBadRequestError - case HTTPStatus.UNAUTHORIZED: - exc_type = SupervisorAuthenticationError - case HTTPStatus.FORBIDDEN: - exc_type = SupervisorForbiddenError - case HTTPStatus.NOT_FOUND: - exc_type = SupervisorNotFoundError - case HTTPStatus.SERVICE_UNAVAILABLE: - exc_type = SupervisorServiceUnavailableError - - if is_json(response): - result = Response.from_json(await response.text()) - raise exc_type(result.message, result.job_id) - raise exc_type() - - match response_type: - case ResponseType.JSON: - is_json(response, raise_on_fail=True) - return Response.from_json(await response.text()) - case ResponseType.TEXT: - return Response(ResultType.OK, await response.text()) - case _: - return Response(ResultType.OK) + ) + await self._raise_on_status(response) + match response_type: + case ResponseType.JSON: + is_json(response, raise_on_fail=True) + return Response.from_json(await response.text()) + case ResponseType.TEXT: + return Response(ResultType.OK, await response.text()) + case ResponseType.STREAM: + return Response( + ResultType.OK, ChunkAsyncStreamIterator(response.content) + ) + case _: + return Response(ResultType.OK) except (UnicodeDecodeError, ClientResponseError) as err: raise SupervisorResponseError( @@ -146,7 +155,7 @@ async def get( self, uri: str, *, - params: dict[str, str] | None = None, + params: dict[str, str] | MultiDict[str] | None = None, response_type: ResponseType = ResponseType.JSON, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, ) -> Response: @@ -163,7 +172,7 @@ async def post( self, uri: str, *, - params: dict[str, str] | None = None, + params: dict[str, str] | MultiDict[str] | None = None, response_type: ResponseType = ResponseType.NONE, json: dict[str, Any] | None = None, data: Any = None, @@ -184,7 +193,7 @@ async def put( self, uri: str, *, - params: dict[str, str] | None = None, + params: dict[str, str] | MultiDict[str] | None = None, json: dict[str, Any] | None = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, ) -> Response: @@ -202,7 +211,7 @@ async def delete( self, uri: str, *, - params: dict[str, str] | None = None, + params: dict[str, str] | MultiDict[str] | None = None, timeout: ClientTimeout | None = DEFAULT_TIMEOUT, ) -> Response: """Handle a DELETE request to Supervisor.""" diff --git a/aiohasupervisor/const.py b/aiohasupervisor/const.py index a5450ae..c85b6fc 100644 --- a/aiohasupervisor/const.py +++ b/aiohasupervisor/const.py @@ -13,4 +13,5 @@ class ResponseType(StrEnum): NONE = "none" JSON = "json" + STREAM = "stream" TEXT = "text" diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index 8a9187e..af68157 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -40,6 +40,7 @@ NewBackup, PartialBackupOptions, PartialRestoreOptions, + UploadBackupOptions, ) from aiohasupervisor.models.discovery import ( Discovery, @@ -215,6 +216,7 @@ "NewBackup", "PartialBackupOptions", "PartialRestoreOptions", + "UploadBackupOptions", "Discovery", "DiscoveryConfig", "AccessPoint", diff --git a/aiohasupervisor/models/backups.py b/aiohasupervisor/models/backups.py index 4a81f53..143c8f4 100644 --- a/aiohasupervisor/models/backups.py +++ b/aiohasupervisor/models/backups.py @@ -47,7 +47,9 @@ class BackupBaseFields(ABC): date: datetime type: BackupType size: float + size_bytes: int location: str | None + locations: set[str | None] protected: bool compressed: bool @@ -73,12 +75,13 @@ class BackupAddon(ResponseData): class BackupComplete(BackupBaseFields, ResponseData): """BackupComplete model.""" - supervisor_version: str | None - homeassistant: str + supervisor_version: str + homeassistant: str | None addons: list[BackupAddon] repositories: list[str] folders: list[Folder] homeassistant_exclude_database: bool | None + extra: dict | None @dataclass(frozen=True, slots=True) @@ -132,9 +135,10 @@ class FullBackupOptions(Request): name: str | None = None password: str | None = None compressed: bool | None = None - location: str | None = None + location: set[str | None] | str | None = None homeassistant_exclude_database: bool | None = None background: bool | None = None + extra: dict | None = None @dataclass(frozen=True, slots=True) @@ -167,3 +171,17 @@ class FullRestoreOptions(Request): @dataclass(frozen=True, slots=True) class PartialRestoreOptions(FullRestoreOptions, PartialBackupRestoreOptions): """PartialRestoreOptions model.""" + + +@dataclass(frozen=True, slots=True) +class UploadBackupOptions(Request): + """UploadBackupOptions model.""" + + location: set[str | None] = None + + +@dataclass(frozen=True, slots=True) +class UploadedBackup(ResponseData): + """UploadedBackup model.""" + + slug: str diff --git a/aiohasupervisor/models/mounts.py b/aiohasupervisor/models/mounts.py index 0cab3e9..f3a474b 100644 --- a/aiohasupervisor/models/mounts.py +++ b/aiohasupervisor/models/mounts.py @@ -79,6 +79,7 @@ class MountResponse(ABC): name: str read_only: bool state: MountState | None + user_path: PurePath | None @dataclass(frozen=True) diff --git a/aiohasupervisor/utils/__init__.py b/aiohasupervisor/utils/__init__.py new file mode 100644 index 0000000..1344618 --- /dev/null +++ b/aiohasupervisor/utils/__init__.py @@ -0,0 +1 @@ +"""Utilities used internally in library.""" diff --git a/aiohasupervisor/utils/aiohttp.py b/aiohasupervisor/utils/aiohttp.py new file mode 100644 index 0000000..7cfbfb4 --- /dev/null +++ b/aiohasupervisor/utils/aiohttp.py @@ -0,0 +1,31 @@ +"""Utilities for interacting with aiohttp.""" + +from typing import Self + +from aiohttp import StreamReader + + +class ChunkAsyncStreamIterator: + """Async iterator for chunked streams. + + Based on aiohttp.streams.ChunkTupleAsyncStreamIterator, but yields + bytes instead of tuple[bytes, bool]. + Borrowed from home-assistant/core. + """ + + __slots__ = ("_stream",) + + def __init__(self, stream: StreamReader) -> None: + """Initialize.""" + self._stream = stream + + def __aiter__(self) -> Self: + """Iterate.""" + return self + + async def __anext__(self) -> bytes: + """Yield next chunk.""" + rv = await self._stream.readchunk() + if rv == (b"", False): + raise StopAsyncIteration + return rv[0] diff --git a/tests/__init__.py b/tests/__init__.py index a690757..82e40d9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,7 +3,12 @@ from pathlib import Path +def get_fixture_path(filename: str) -> Path: + """Get fixture path.""" + return Path(__package__) / "fixtures" / filename + + def load_fixture(filename: str) -> str: """Load a fixture.""" - fixture = Path(__package__) / "fixtures" / filename + fixture = get_fixture_path(filename) return fixture.read_text(encoding="utf-8") diff --git a/tests/fixtures/backup_info.json b/tests/fixtures/backup_info.json index 7811e8e..b73cc64 100644 --- a/tests/fixtures/backup_info.json +++ b/tests/fixtures/backup_info.json @@ -6,11 +6,13 @@ "name": "addon_core_mosquitto_6.4.0", "date": "2024-05-31T00:00:00.000000+00:00", "size": 0.01, + "size_bytes": 10123, "compressed": true, "protected": false, "supervisor_version": "2024.05.0", "homeassistant": null, "location": null, + "locations": [null], "addons": [ { "slug": "core_mosquitto", @@ -27,6 +29,7 @@ "https://github.com/hassio-addons/repository" ], "folders": [], - "homeassistant_exclude_database": null + "homeassistant_exclude_database": null, + "extra": null } } diff --git a/tests/fixtures/backup_info_no_homeassistant.json b/tests/fixtures/backup_info_no_homeassistant.json index 11076de..801d693 100644 --- a/tests/fixtures/backup_info_no_homeassistant.json +++ b/tests/fixtures/backup_info_no_homeassistant.json @@ -6,11 +6,13 @@ "name": "Studio Code Server", "date": "2023-08-10T19:37:01.084215+00:00", "size": 0.12, + "size_bytes": 120123, "compressed": true, "protected": false, "supervisor_version": "2023.08.2.dev1002", "homeassistant": null, "location": "Test", + "locations": ["Test"], "addons": [ { "slug": "a0d7b954_vscode", @@ -27,6 +29,7 @@ "https://github.com/esphome/home-assistant-addon" ], "folders": [], - "homeassistant_exclude_database": null + "homeassistant_exclude_database": null, + "extra": null } } diff --git a/tests/fixtures/backup_info_with_extra.json b/tests/fixtures/backup_info_with_extra.json new file mode 100644 index 0000000..5de752b --- /dev/null +++ b/tests/fixtures/backup_info_with_extra.json @@ -0,0 +1,38 @@ +{ + "result": "ok", + "data": { + "slug": "69558789", + "type": "partial", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T00:00:00.000000+00:00", + "size": 0.01, + "size_bytes": 10123, + "compressed": true, + "protected": false, + "supervisor_version": "2024.05.0", + "homeassistant": null, + "location": null, + "locations": [null], + "addons": [ + { + "slug": "core_mosquitto", + "name": "Mosquitto broker", + "version": "6.4.0", + "size": 0.0 + } + ], + "repositories": [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository" + ], + "folders": [], + "homeassistant_exclude_database": null, + "extra": { + "user": "test", + "scheduled": true + } + } +} diff --git a/tests/fixtures/backup_info_with_locations.json b/tests/fixtures/backup_info_with_locations.json new file mode 100644 index 0000000..eab5855 --- /dev/null +++ b/tests/fixtures/backup_info_with_locations.json @@ -0,0 +1,35 @@ +{ + "result": "ok", + "data": { + "slug": "69558789", + "type": "partial", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T00:00:00.000000+00:00", + "size": 0.01, + "size_bytes": 10123, + "compressed": true, + "protected": false, + "supervisor_version": "2024.05.0", + "homeassistant": null, + "location": null, + "locations": [null, "Test"], + "addons": [ + { + "slug": "core_mosquitto", + "name": "Mosquitto broker", + "version": "6.4.0", + "size": 0.0 + } + ], + "repositories": [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository" + ], + "folders": [], + "homeassistant_exclude_database": null, + "extra": null + } +} diff --git a/tests/fixtures/backup_uploaded.json b/tests/fixtures/backup_uploaded.json new file mode 100644 index 0000000..f612fa6 --- /dev/null +++ b/tests/fixtures/backup_uploaded.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "slug": "7fed74c8" } +} diff --git a/tests/fixtures/backups_info.json b/tests/fixtures/backups_info.json index ee9bbba..3965a8b 100644 --- a/tests/fixtures/backups_info.json +++ b/tests/fixtures/backups_info.json @@ -8,7 +8,9 @@ "date": "2024-04-06T07:05:40.000000+00:00", "type": "full", "size": 828.81, + "size_bytes": 828810000, "location": null, + "locations": [null], "protected": false, "compressed": true, "content": { @@ -32,7 +34,9 @@ "date": "2024-05-31T20:48:03.838030+00:00", "type": "partial", "size": 0.01, + "size_bytes": 10123, "location": null, + "locations": [null], "protected": false, "compressed": true, "content": { diff --git a/tests/fixtures/backups_list.json b/tests/fixtures/backups_list.json index 4792494..fa3b04f 100644 --- a/tests/fixtures/backups_list.json +++ b/tests/fixtures/backups_list.json @@ -8,7 +8,9 @@ "date": "2024-04-06T07:05:40.000000+00:00", "type": "full", "size": 828.81, + "size_bytes": 828810000, "location": null, + "locations": [null], "protected": false, "compressed": true, "content": { @@ -32,7 +34,9 @@ "date": "2024-05-31T20:48:03.838030+00:00", "type": "partial", "size": 0.01, + "size_bytes": 10123, "location": null, + "locations": [null], "protected": false, "compressed": true, "content": { diff --git a/tests/fixtures/mounts_info.json b/tests/fixtures/mounts_info.json index 27dde64..7123e66 100644 --- a/tests/fixtures/mounts_info.json +++ b/tests/fixtures/mounts_info.json @@ -11,7 +11,8 @@ "usage": "backup", "read_only": false, "version": null, - "state": "active" + "state": "active", + "user_path": null }, { "share": "share", @@ -22,16 +23,18 @@ "read_only": true, "version": "2.0", "port": 12345, - "state": "active" + "state": "active", + "user_path": "/share/Test2" }, { "server": "test3.local", - "name": "Test2", + "name": "Test3", "type": "nfs", "usage": "media", "read_only": false, "path": "media", - "state": "active" + "state": "active", + "user_path": "/media/Test3" } ] } diff --git a/tests/test_backups.py b/tests/test_backups.py index 2eb8a91..eab63a4 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -1,5 +1,7 @@ """Test backups supervisor client.""" +import asyncio +from collections.abc import AsyncIterator from datetime import UTC, datetime from typing import Any @@ -15,6 +17,7 @@ FullBackupOptions, PartialBackupOptions, PartialRestoreOptions, + UploadBackupOptions, ) from . import load_fixture @@ -130,7 +133,7 @@ async def test_partial_restore_options() -> None: def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # noqa: ARG001 """Return response based on whether backup was in background or not.""" - if kwargs["json"] and kwargs["json"]["background"]: + if kwargs["json"] and kwargs["json"].get("background"): fixture = "backup_background.json" else: fixture = "backup_foreground.json" @@ -142,6 +145,19 @@ def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # no [ (FullBackupOptions(name="Test", background=True), None), (FullBackupOptions(name="Test", background=False), "9ecf0028"), + (FullBackupOptions(name="Test", background=False, location=None), "9ecf0028"), + (FullBackupOptions(name="Test", background=False, location="test"), "9ecf0028"), + ( + FullBackupOptions(name="Test", background=False, location={None, "test"}), + "9ecf0028", + ), + ( + FullBackupOptions( + name="Test", background=False, extra={"user": "test", "scheduled": True} + ), + "9ecf0028", + ), + (FullBackupOptions(name="Test", background=False, extra=None), "9ecf0028"), (None, "9ecf0028"), ], ) @@ -162,13 +178,59 @@ async def test_backups_full_backup( @pytest.mark.parametrize( - ("background", "slug"), - [(True, None), (False, "9ecf0028")], + ("options", "slug"), + [ + (PartialBackupOptions(name="Test", background=True, addons={"core_ssh"}), None), + ( + PartialBackupOptions(name="Test", background=False, addons={"core_ssh"}), + "9ecf0028", + ), + ( + PartialBackupOptions( + name="Test", background=False, location=None, addons={"core_ssh"} + ), + "9ecf0028", + ), + ( + PartialBackupOptions( + name="Test", background=False, location="test", addons={"core_ssh"} + ), + "9ecf0028", + ), + ( + PartialBackupOptions( + name="Test", + background=False, + location={None, "test"}, + addons={"core_ssh"}, + ), + "9ecf0028", + ), + ( + PartialBackupOptions( + name="Test", + background=False, + addons={"core_ssh"}, + extra={"user": "test", "scheduled": True}, + ), + "9ecf0028", + ), + ( + PartialBackupOptions( + name="Test", background=False, addons={"core_ssh"}, extra=None + ), + "9ecf0028", + ), + ( + PartialBackupOptions(name="Test", background=None, addons={"core_ssh"}), + "9ecf0028", + ), + ], ) async def test_backups_partial_backup( responses: aioresponses, supervisor_client: SupervisorClient, - background: bool, # noqa: FBT001 + options: PartialBackupOptions, slug: str | None, ) -> None: """Test backups full backup API.""" @@ -176,9 +238,7 @@ async def test_backups_partial_backup( f"{SUPERVISOR_URL}/backups/new/partial", callback=backup_callback, ) - result = await supervisor_client.backups.partial_backup( - PartialBackupOptions(name="test", background=background, addons={"core_ssh"}) - ) + result = await supervisor_client.backups.partial_backup(options) assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" assert result.slug == slug @@ -197,6 +257,7 @@ async def test_backup_info( assert result.type == "partial" assert result.date == datetime(2024, 5, 31, 0, 0, 0, 0, UTC) assert result.size == 0.01 + assert result.size_bytes == 10123 assert result.compressed is True assert result.addons[0].slug == "core_mosquitto" assert result.addons[0].name == "Mosquitto broker" @@ -211,6 +272,9 @@ async def test_backup_info( ] assert result.folders == [] assert result.homeassistant_exclude_database is None + assert result.extra is None + assert result.location is None + assert result.locations == {None} async def test_backup_info_no_homeassistant( @@ -228,6 +292,37 @@ async def test_backup_info_no_homeassistant( assert result.homeassistant is None +async def test_backup_info_with_extra( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backup info API with extras set by client.""" + responses.get( + f"{SUPERVISOR_URL}/backups/d13dedd0/info", + status=200, + body=load_fixture("backup_info_with_extra.json"), + ) + result = await supervisor_client.backups.backup_info("d13dedd0") + assert result.slug == "69558789" + assert result.type == "partial" + assert result.extra == {"user": "test", "scheduled": True} + + +async def test_backup_info_with_multiple_locations( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backup info API with multiple locations.""" + responses.get( + f"{SUPERVISOR_URL}/backups/d13dedd0/info", + status=200, + body=load_fixture("backup_info_with_locations.json"), + ) + result = await supervisor_client.backups.backup_info("d13dedd0") + assert result.slug == "69558789" + assert result.type == "partial" + assert result.location is None + assert result.locations == {None, "Test"} + + async def test_remove_backup( responses: aioresponses, supervisor_client: SupervisorClient ) -> None: @@ -265,3 +360,52 @@ async def test_partial_restore( "abc123", PartialRestoreOptions(addons={"core_ssh"}) ) assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + + +async def test_upload_backup( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test upload backup API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/upload", + status=200, + body=load_fixture("backup_uploaded.json"), + ) + data = asyncio.StreamReader(loop=asyncio.get_running_loop()) + data.feed_data(b"backup test") + data.feed_eof() + + result = await supervisor_client.backups.upload_backup(data) + assert result == "7fed74c8" + + +async def test_upload_backup_to_locations( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test upload backup API with multiple locations.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/upload?location=&location=test", + status=200, + body=load_fixture("backup_uploaded.json"), + ) + data = asyncio.StreamReader(loop=asyncio.get_running_loop()) + data.feed_data(b"backup test") + data.feed_eof() + + result = await supervisor_client.backups.upload_backup( + data, UploadBackupOptions(location={None, "test"}) + ) + assert result == "7fed74c8" + + +async def test_download_backup( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test download backup API.""" + responses.get( + f"{SUPERVISOR_URL}/backups/7fed74c8/download", status=200, body=b"backup test" + ) + result = await supervisor_client.backups.download_backup("7fed74c8") + assert isinstance(result, AsyncIterator) + async for chunk in result: + assert chunk == b"backup test" diff --git a/tests/test_mounts.py b/tests/test_mounts.py index 196d9a8..7f9947a 100644 --- a/tests/test_mounts.py +++ b/tests/test_mounts.py @@ -37,15 +37,18 @@ async def test_mounts_info( assert info.mounts[0].read_only is False assert info.mounts[0].version is None assert info.mounts[0].state == "active" + assert info.mounts[0].user_path is None assert info.mounts[1].usage == "share" assert info.mounts[1].read_only is True assert info.mounts[1].version == "2.0" assert info.mounts[1].port == 12345 + assert info.mounts[1].user_path == PurePath("/share/Test2") assert info.mounts[2].type == "nfs" assert info.mounts[2].usage == "media" assert info.mounts[2].path.as_posix() == "media" + assert info.mounts[2].user_path == PurePath("/media/Test3") @pytest.mark.parametrize("mount_name", ["test", None])