snap-python 0.1.7__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ import logging
2
+ from typing import Any, Literal
3
+
4
+ from snap_python.schemas.changes import ChangesResponse
5
+ from snap_python.schemas.common import AsyncResponse
6
+ from snap_python.schemas.interfaces import (
7
+ ConnectionsResponse,
8
+ InterfaceActionPlug,
9
+ InterfaceActionSlot,
10
+ InterfacesResponse,
11
+ )
12
+ from snap_python.utils import AbstractSnapsClient, wait_for_change
13
+
14
+ logger = logging.getLogger("snap_python.components.interfaces")
15
+
16
+ #: Values ``GET /v2/interfaces`` accepts for ``select``. Sending no ``select``
17
+ #: at all selects the legacy object response shape.
18
+ InterfaceSelect = Literal["all", "connected"]
19
+
20
+ #: Values ``GET /v2/connections`` accepts for ``select``.
21
+ ConnectionsSelect = Literal["", "all"]
22
+
23
+
24
+ class InterfacesEndpoints:
25
+ """Endpoints for interfaces and their connections.
26
+
27
+ :param client: Client used to talk to snapd.
28
+ """
29
+
30
+ def __init__(self, client: AbstractSnapsClient) -> None:
31
+ self._client = client
32
+ self.common_endpoint = "interfaces"
33
+
34
+ async def list_interfaces(
35
+ self,
36
+ select: InterfaceSelect | None = None,
37
+ plugs: bool = False,
38
+ slots: bool = False,
39
+ doc: bool = False,
40
+ names: list[str] | None = None,
41
+ ) -> InterfacesResponse:
42
+ """
43
+ List interfaces known to snapd.
44
+
45
+ snapd picks the response shape from ``select``: without it the legacy
46
+ handler answers with a ``{"plugs": [...], "slots": [...]}`` object,
47
+ with any non-empty value the modern handler answers with an array.
48
+ :class:`InterfacesResponse` accepts both;
49
+ :attr:`InterfacesResponse.legacy` tells them apart and
50
+ :attr:`InterfacesResponse.interfaces` normalises them.
51
+
52
+ The ``plugs``/``slots``/``doc`` flags are only honoured by the modern
53
+ handler, and only when exactly ``true``; they are not sent unless
54
+ requested.
55
+
56
+ :param select: ``"all"`` or ``"connected"`` (the latter keeps only
57
+ interfaces with at least one live connection). None (default)
58
+ requests the legacy response shape.
59
+ :type select: str, optional
60
+ :param plugs: Include the ``plugs`` arrays. Only for the modern shape.
61
+ :type plugs: bool
62
+ :param slots: Include the ``slots`` arrays. Only for the modern shape.
63
+ :type slots: bool
64
+ :param doc: Include ``doc-url``, defaulting to
65
+ ``https://snapcraft.io/docs/<name>-interface``. Only for the modern
66
+ shape.
67
+ :type doc: bool
68
+ :param names: Restrict the result to these interface names. None
69
+ (default) returns every interface. Names are joined with commas and
70
+ snapd does not trim whitespace.
71
+ :type names: list[str], optional
72
+
73
+ :returns: The response object containing the interfaces.
74
+ :rtype: InterfacesResponse
75
+
76
+ :raises SnapdAPIError: If snapd rejects the request, for example for an
77
+ unknown ``select`` value.
78
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
79
+ """
80
+ params: dict[str, str] = {}
81
+ if select is not None:
82
+ params["select"] = select
83
+ if plugs:
84
+ params["plugs"] = "true"
85
+ if slots:
86
+ params["slots"] = "true"
87
+ if doc:
88
+ params["doc"] = "true"
89
+ if names:
90
+ params["names"] = ",".join(names)
91
+
92
+ response = await self._client.request(
93
+ "GET", self.common_endpoint, params=params
94
+ )
95
+ return InterfacesResponse.model_validate_json(response.content)
96
+
97
+ async def connections(
98
+ self,
99
+ snap: str | None = None,
100
+ select: ConnectionsSelect | None = None,
101
+ interface: str | None = None,
102
+ ) -> ConnectionsResponse:
103
+ """
104
+ Retrieve plug/slot connections.
105
+
106
+ :param snap: Only return connections involving this snap. snapd also
107
+ keeps the counterpart plug/slot of another snap and everything
108
+ connected to it. None (default) returns every snap. An unknown snap
109
+ is a 404 ``snap-not-found``.
110
+ :type snap: str, optional
111
+ :param select: ``"all"`` also returns unconnected plugs/slots and
112
+ undesired connections; ``""`` (daemon default when the parameter is
113
+ omitted) returns only established connections. None (default) omits
114
+ the parameter.
115
+ :type select: str, optional
116
+ :param interface: Only return connections, plugs and slots of this
117
+ interface. An unknown interface yields an empty result.
118
+ :type interface: str, optional
119
+
120
+ :returns: The response object containing connections, plugs and slots.
121
+ :rtype: ConnectionsResponse
122
+
123
+ :raises SnapdAPIError: If snapd rejects the request, for example for an
124
+ unknown ``select`` value or an unknown snap (404).
125
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
126
+ """
127
+ params: dict[str, str] = {}
128
+ if snap is not None:
129
+ params["snap"] = snap
130
+ if select:
131
+ params["select"] = select
132
+ if interface is not None:
133
+ params["interface"] = interface
134
+
135
+ response = await self._client.request("GET", "connections", params=params)
136
+ return ConnectionsResponse.model_validate_json(response.content)
137
+
138
+ async def _change_interface(
139
+ self,
140
+ action: Literal["connect", "disconnect"],
141
+ plug_snap: str,
142
+ plug_name: str,
143
+ slot_snap: str | None,
144
+ slot_name: str | None,
145
+ forget: bool,
146
+ wait: bool,
147
+ timeout: float | None,
148
+ poll_interval: float,
149
+ ) -> AsyncResponse | ChangesResponse:
150
+ """POST one plug/slot pair to ``/v2/interfaces``.
151
+
152
+ snapd rejects more than one plug or slot per request with 501
153
+ ``many-to-many operations are not implemented``, so the API always
154
+ sends exactly one of each. ``forget`` is only ever set by
155
+ :meth:`disconnect`.
156
+ """
157
+ body: dict[str, Any] = {
158
+ "action": action,
159
+ "plugs": [InterfaceActionPlug(snap=plug_snap, plug=plug_name).model_dump()],
160
+ "slots": [
161
+ InterfaceActionSlot(
162
+ snap=slot_snap or "", slot=slot_name or ""
163
+ ).model_dump()
164
+ ],
165
+ }
166
+ if forget:
167
+ body["forget"] = True
168
+
169
+ response = await self._client.request("POST", self.common_endpoint, json=body)
170
+ async_response = AsyncResponse.model_validate_json(response.content)
171
+ if not wait:
172
+ return async_response
173
+ return await wait_for_change(
174
+ self._client,
175
+ async_response.change,
176
+ timeout=timeout,
177
+ poll_interval=poll_interval,
178
+ )
179
+
180
+ async def connect(
181
+ self,
182
+ plug_snap: str,
183
+ plug_name: str,
184
+ slot_snap: str | None = None,
185
+ slot_name: str | None = None,
186
+ wait: bool = True,
187
+ timeout: float | None = None,
188
+ poll_interval: float = 0.5,
189
+ ) -> AsyncResponse | ChangesResponse:
190
+ """
191
+ Connect one plug to one slot.
192
+
193
+ An empty ``slot_snap`` lets snapd fall back to ``snapd``, ``core`` or
194
+ ``ubuntu-core``; an empty ``slot_name`` lets it pick the single slot of
195
+ the plug's interface. Connecting an already connected pair still
196
+ returns an async response, but with a change that is already done.
197
+
198
+ :param plug_snap: Snap providing the plug.
199
+ :type plug_snap: str
200
+ :param plug_name: Name of the plug.
201
+ :type plug_name: str
202
+ :param slot_snap: Snap providing the slot, or None to let snapd choose.
203
+ :type slot_snap: str, optional
204
+ :param slot_name: Name of the slot, or None to let snapd choose.
205
+ :type slot_name: str, optional
206
+ :param wait: Wait for the connect change to finish before returning.
207
+ :type wait: bool
208
+ :param timeout: Maximum seconds to wait for the change when ``wait`` is
209
+ True; None waits forever.
210
+ :type timeout: float | None
211
+ :param poll_interval: Seconds between two polls while waiting.
212
+ :type poll_interval: float
213
+
214
+ :returns: The async response, or the finished change when waiting.
215
+ :rtype: AsyncResponse | ChangesResponse
216
+
217
+ :raises SnapdAPIError: If snapd refuses the connection, or the change
218
+ fails.
219
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
220
+ """
221
+ return await self._change_interface(
222
+ "connect",
223
+ plug_snap,
224
+ plug_name,
225
+ slot_snap,
226
+ slot_name,
227
+ forget=False,
228
+ wait=wait,
229
+ timeout=timeout,
230
+ poll_interval=poll_interval,
231
+ )
232
+
233
+ async def disconnect(
234
+ self,
235
+ plug_snap: str,
236
+ plug_name: str,
237
+ slot_snap: str | None = None,
238
+ slot_name: str | None = None,
239
+ forget: bool = False,
240
+ wait: bool = True,
241
+ timeout: float | None = None,
242
+ poll_interval: float = 0.5,
243
+ ) -> AsyncResponse | ChangesResponse:
244
+ """
245
+ Disconnect one plug from one slot.
246
+
247
+ :param plug_snap: Snap providing the plug.
248
+ :type plug_snap: str
249
+ :param plug_name: Name of the plug.
250
+ :type plug_name: str
251
+ :param slot_snap: Snap providing the slot, or None to let snapd choose.
252
+ :type slot_snap: str, optional
253
+ :param slot_name: Name of the slot, or None to let snapd choose.
254
+ :type slot_name: str, optional
255
+ :param forget: Drop the connection state instead of recording it as
256
+ undesired, so a later refresh does not reconnect the pair.
257
+ :type forget: bool
258
+ :param wait: Wait for the disconnect change to finish before returning.
259
+ :type wait: bool
260
+ :param timeout: Maximum seconds to wait for the change when ``wait`` is
261
+ True; None waits forever.
262
+ :type timeout: float | None
263
+ :param poll_interval: Seconds between two polls while waiting.
264
+ :type poll_interval: float
265
+
266
+ :returns: The async response, or the finished change when waiting.
267
+ :rtype: AsyncResponse | ChangesResponse
268
+
269
+ :raises SnapdAPIError: If snapd refuses the disconnection, for example
270
+ because the pair is not connected, or the change fails.
271
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
272
+ """
273
+ return await self._change_interface(
274
+ "disconnect",
275
+ plug_snap,
276
+ plug_name,
277
+ slot_snap,
278
+ slot_name,
279
+ forget=forget,
280
+ wait=wait,
281
+ timeout=timeout,
282
+ poll_interval=poll_interval,
283
+ )
@@ -0,0 +1,162 @@
1
+ import logging
2
+ from typing import AsyncGenerator
3
+
4
+ from snap_python.schemas.logs import LogEntry
5
+ from snap_python.utils import AbstractSnapsClient
6
+
7
+ logger = logging.getLogger("snap_python.components.logs")
8
+
9
+ #: RFC 7464 record separator that prefixes every JSON-seq record.
10
+ RECORD_SEPARATOR = "\x1e"
11
+
12
+
13
+ def _parse_record(line: bytes) -> LogEntry | None:
14
+ """Parse one JSON-seq line, returning None for anything unusable.
15
+
16
+ Records look like ``<RS>{"timestamp": ...}\\n``. Truncated, empty or
17
+ non-JSON records are skipped, exactly like snapd's own client. That also
18
+ covers the raw ``\\x1E{"error": ...}`` record snapd writes when the journal
19
+ read fails mid-stream: it has no real RS byte, so it is never JSON-seq.
20
+ """
21
+ text = line.decode("utf-8", errors="replace")
22
+ index = text.find(RECORD_SEPARATOR)
23
+ if index < 0:
24
+ return None
25
+ payload = text[index + 1 :].strip()
26
+ if not payload:
27
+ return None
28
+ try:
29
+ return LogEntry.model_validate_json(payload)
30
+ except ValueError:
31
+ logger.debug("Skipping malformed log record: %r", text)
32
+ return None
33
+
34
+
35
+ class LogsEndpoints:
36
+ """Endpoint for ``/v2/logs``, a JSON-seq journal stream.
37
+
38
+ ``GET /v2/logs`` does not answer with the usual JSON envelope: it streams
39
+ RFC 7464 records, each a record separator byte (``0x1E``), one JSON object
40
+ and a newline. Errors raised before the stream starts are ordinary snapd
41
+ error envelopes.
42
+
43
+ Records are split on LF only. Neither :meth:`str.splitlines` nor httpx's
44
+ ``aiter_lines`` may be used here: both treat the RS byte itself as a line
45
+ boundary, which would shred every record.
46
+
47
+ :param client: Client used to talk to snapd.
48
+ """
49
+
50
+ def __init__(self, client: AbstractSnapsClient) -> None:
51
+ self._client = client
52
+ self.common_endpoint = "logs"
53
+
54
+ @staticmethod
55
+ def _params(
56
+ names: list[str] | None, n: int | None, follow: bool = False
57
+ ) -> dict[str, str]:
58
+ """Build the query parameters of ``GET /v2/logs``."""
59
+ params: dict[str, str] = {}
60
+ if names:
61
+ params["names"] = ",".join(names)
62
+ if n is not None:
63
+ params["n"] = str(n)
64
+ if follow:
65
+ params["follow"] = "true"
66
+ return params
67
+
68
+ async def get_logs(
69
+ self,
70
+ names: list[str] | None = None,
71
+ n: int | None = None,
72
+ ) -> list[LogEntry]:
73
+ """
74
+ Retrieve a bounded batch of log entries.
75
+
76
+ :param names: Services to read logs from, as ``"<snap>"`` or
77
+ ``"<snap>.<app>"`` entries. Only services have logs. None (default)
78
+ reads every service snapd can find.
79
+ :type names: list[str], optional
80
+ :param n: Number of lines snapd returns initially. ``-1`` means every
81
+ line; None (default) omits the parameter, so snapd uses its own
82
+ default of 10.
83
+ :type n: int, optional
84
+
85
+ :returns: The parsed log entries, in stream order.
86
+ :rtype: list[LogEntry]
87
+
88
+ :raises SnapdAPIError: If snapd answers with an error envelope, for
89
+ example 404 when a name is not a service snap.
90
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error.
91
+ """
92
+ response = await self._client.request(
93
+ "GET", self.common_endpoint, params=self._params(names, n)
94
+ )
95
+ entries = (_parse_record(line) for line in response.content.split(b"\n"))
96
+ return [entry for entry in entries if entry is not None]
97
+
98
+ async def stream_logs(
99
+ self,
100
+ names: list[str] | None = None,
101
+ n: int | None = None,
102
+ ) -> AsyncGenerator[LogEntry, None]:
103
+ """
104
+ Follow the log stream, yielding entries as snapd writes them.
105
+
106
+ ``follow=true`` never terminates on its own, so this is an async
107
+ generator: break out of the ``async for`` to close the stream. Unlike
108
+ :meth:`get_logs` it needs the underlying ``httpx`` client, because
109
+ :meth:`snap_python.client.SnapClient.request` buffers the whole body.
110
+ Malformed records are skipped rather than raised on.
111
+
112
+ :param names: Services to read logs from, as ``"<snap>"`` or
113
+ ``"<snap>.app"`` entries. None (default) reads every service.
114
+ :type names: list[str], optional
115
+ :param n: Number of lines snapd replays before following. ``-1`` means
116
+ every line; None (default) keeps snapd's default of 10.
117
+ :type n: int, optional
118
+
119
+ :yields: One parsed entry per record, in stream order.
120
+ :rtype: LogEntry
121
+
122
+ :raises SnapdAPIError: If snapd answers with an error envelope instead
123
+ of a stream, for example 404 for an unknown service.
124
+ :raises httpx.HTTPStatusError: If the request results in an HTTP error
125
+ that is not a snapd envelope.
126
+ :raises NotImplementedError: If the client does not expose the raw
127
+ ``httpx`` client and its URL builder.
128
+ """
129
+ snapd_client = getattr(self._client, "snapd_client", None)
130
+ build_url = getattr(self._client, "endpoint_url", None)
131
+ if snapd_client is None or build_url is None:
132
+ raise NotImplementedError(
133
+ "log streaming needs a client exposing 'snapd_client' and "
134
+ f"'endpoint_url()'; {type(self._client).__name__} is not usable"
135
+ )
136
+
137
+ params = self._params(names, n, follow=True)
138
+ async with snapd_client.stream(
139
+ "GET", build_url(self.common_endpoint), params=params
140
+ ) as response:
141
+ if response.is_error:
142
+ await response.aread()
143
+ # Imported lazily: snap_python.client imports this module.
144
+ from snap_python.client import _snapd_error_from_response
145
+
146
+ snapd_error = _snapd_error_from_response(response)
147
+ if snapd_error is not None:
148
+ raise snapd_error
149
+ response.raise_for_status()
150
+
151
+ buffer = b""
152
+ async for chunk in response.aiter_bytes():
153
+ buffer += chunk
154
+ while b"\n" in buffer:
155
+ line, buffer = buffer.split(b"\n", 1)
156
+ entry = _parse_record(line)
157
+ if entry is not None:
158
+ yield entry
159
+ if buffer:
160
+ entry = _parse_record(buffer)
161
+ if entry is not None:
162
+ yield entry