charmlibs-snap 0.8.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,1335 @@
1
+ # Copyright 2021 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Source code of operator_libs_linux.v2.snap, with minimal exclusions.
16
+
17
+ Snapshot of version 2.14. Charmhub-hosted lib specific metadata has been removed,
18
+ and the docstring has been moved to the package docstring.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import http.client
24
+ import json
25
+ import logging
26
+ import os
27
+ import re
28
+ import socket
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ import typing
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ from datetime import datetime, timedelta, timezone
37
+ from enum import Enum
38
+ from subprocess import CalledProcessError, CompletedProcess
39
+ from typing import (
40
+ Literal,
41
+ Mapping,
42
+ NoReturn,
43
+ TypedDict,
44
+ TypeVar,
45
+ )
46
+
47
+ import opentelemetry.trace
48
+
49
+ if typing.TYPE_CHECKING:
50
+ # avoid typing_extensions import at runtime
51
+ from collections.abc import Callable, Iterable, Sequence
52
+ from typing import TypeAlias
53
+
54
+ from typing_extensions import NotRequired, ParamSpec, Required, Self, Unpack
55
+
56
+ _P = ParamSpec('_P')
57
+ _T = TypeVar('_T')
58
+
59
+ logger = logging.getLogger(__name__)
60
+ tracer = opentelemetry.trace.get_tracer(__name__)
61
+
62
+ # Regex to locate 7-bit C1 ANSI sequences
63
+ ansi_filter = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
64
+
65
+
66
+ def _cache_init(func: Callable[_P, _T]) -> Callable[_P, _T]:
67
+ def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
68
+ if _Cache.cache is None:
69
+ _Cache.cache = SnapCache()
70
+ return func(*args, **kwargs)
71
+
72
+ return inner
73
+
74
+
75
+ # this is used for return types, so it (a) uses concrete types and (b) does not contain None
76
+ # because setting snap config values to null removes the key so a null value can't be returned
77
+ _JSONLeaf: TypeAlias = 'str | int | float | bool'
78
+ JSONType: TypeAlias = 'dict[str, JSONType] | list[JSONType] | _JSONLeaf'
79
+ # we also need a jsonable type for arguments,
80
+ # which (a) uses abstract types and (b) may contain None
81
+ JSONAble: TypeAlias = 'Mapping[str, JSONAble] | Sequence[JSONAble] | _JSONLeaf | None'
82
+
83
+
84
+ class _AsyncChangeDict(TypedDict, total=True):
85
+ """The subset of the json returned by GET changes that we care about internally."""
86
+
87
+ status: str
88
+ data: JSONType
89
+
90
+
91
+ class _SnapDict(TypedDict, total=True):
92
+ """The subset of the json returned by GET snap/find that we care about internally."""
93
+
94
+ name: str
95
+ channel: str
96
+ revision: str
97
+ version: str
98
+ confinement: str
99
+ apps: NotRequired[list[dict[str, JSONType]] | None]
100
+
101
+
102
+ class SnapServiceDict(TypedDict, total=True):
103
+ """Dictionary representation returned by SnapService.as_dict."""
104
+
105
+ daemon: str | None
106
+ daemon_scope: str | None
107
+ enabled: bool
108
+ active: bool
109
+ activators: list[str]
110
+
111
+
112
+ # TypedDicts with hyphenated keys
113
+ _SnapServiceKwargsDict = TypedDict('_SnapServiceKwargsDict', {'daemon-scope': str}, total=False)
114
+ # the kwargs accepted by SnapService
115
+ _SnapServiceAppDict = TypedDict(
116
+ # the data we expect a Snap._apps entry to contain for a daemon
117
+ '_SnapServiceAppDict',
118
+ {
119
+ 'name': 'Required[str]',
120
+ 'daemon': str,
121
+ 'daemon_scope': str,
122
+ 'daemon-scope': str,
123
+ 'enabled': bool,
124
+ 'active': bool,
125
+ 'activators': 'list[str]',
126
+ },
127
+ total=False,
128
+ )
129
+
130
+
131
+ class SnapService:
132
+ """Data wrapper for snap services."""
133
+
134
+ def __init__(
135
+ self,
136
+ daemon: str | None = None,
137
+ daemon_scope: str | None = None,
138
+ enabled: bool = False,
139
+ active: bool = False,
140
+ activators: list[str] | None = None,
141
+ **kwargs: Unpack[_SnapServiceKwargsDict],
142
+ ):
143
+ self.daemon = daemon
144
+ self.daemon_scope = kwargs.get('daemon-scope') or daemon_scope
145
+ self.enabled = enabled
146
+ self.active = active
147
+ self.activators = activators if activators is not None else []
148
+
149
+ def as_dict(self) -> SnapServiceDict:
150
+ """Return instance representation as dict."""
151
+ return {
152
+ 'daemon': self.daemon,
153
+ 'daemon_scope': self.daemon_scope,
154
+ 'enabled': self.enabled,
155
+ 'active': self.active,
156
+ 'activators': self.activators,
157
+ }
158
+
159
+
160
+ class MetaCache(type):
161
+ """MetaCache class used for initialising the snap cache."""
162
+
163
+ @property
164
+ def cache(cls) -> SnapCache:
165
+ """Property for returning the snap cache."""
166
+ return cls._cache
167
+
168
+ @cache.setter
169
+ def cache(cls, cache: SnapCache) -> None:
170
+ """Setter for the snap cache."""
171
+ cls._cache = cache
172
+
173
+ def __getitem__(cls, name: str) -> Snap:
174
+ """Snap cache getter."""
175
+ return cls._cache[name]
176
+
177
+
178
+ class _Cache(metaclass=MetaCache):
179
+ _cache = None
180
+
181
+
182
+ class Error(Exception):
183
+ """Base class of most errors raised by this library."""
184
+
185
+ def __init__(self, message: str = '', *args: object):
186
+ super().__init__(message, *args)
187
+ self.message = message
188
+
189
+ def __repr__(self) -> str:
190
+ """Represent the Error class."""
191
+ return f'<{type(self).__module__}.{type(self).__name__} {self.args}>'
192
+
193
+ @property
194
+ def name(self) -> str:
195
+ """Return a string representation of the model plus class."""
196
+ return f'<{type(self).__module__}.{type(self).__name__}>'
197
+
198
+
199
+ class SnapAPIError(Error):
200
+ """Raised when an HTTP API error occurs talking to the Snapd server."""
201
+
202
+ def __init__(self, body: Mapping[str, JSONAble], code: int, status: str, message: str):
203
+ super().__init__(message) # Makes str(e) return message
204
+ self.body = body
205
+ self.code = code
206
+ self.status = status
207
+ self._message = message
208
+
209
+ def __repr__(self) -> str:
210
+ """Represent the SnapAPIError class."""
211
+ return f'APIError({self.body!r}, {self.code!r}, {self.status!r}, {self._message!r})'
212
+
213
+
214
+ class SnapState(Enum):
215
+ """The state of a snap on the system or in the cache."""
216
+
217
+ Present = 'present'
218
+ Absent = 'absent'
219
+ Latest = 'latest'
220
+ Available = 'available'
221
+
222
+
223
+ class SnapError(Error):
224
+ """Raised when there's an error running snap control commands."""
225
+
226
+ @classmethod
227
+ def _from_called_process_error(cls, msg: str, error: CalledProcessError) -> Self:
228
+ lines = [msg]
229
+ if error.stdout:
230
+ lines.extend(['Stdout:', error.stdout])
231
+ if error.stderr:
232
+ lines.extend(['Stderr:', error.stderr])
233
+ try:
234
+ cmd = ['journalctl', '--unit', 'snapd', '--lines', '20']
235
+ with tracer.start_as_current_span(cmd[0]) as span:
236
+ span.set_attribute('argv', cmd)
237
+ logs = subprocess.check_output(cmd, text=True)
238
+ except Exception as e:
239
+ lines.extend(['Error fetching logs:', str(e)])
240
+ else:
241
+ lines.extend(['Latest logs:', logs])
242
+ return cls('\n'.join(lines))
243
+
244
+
245
+ class SnapNotFoundError(Error):
246
+ """Raised when a requested snap is not known to the system."""
247
+
248
+
249
+ class Snap:
250
+ """Represents a snap package and its properties.
251
+
252
+ `Snap` exposes the following properties about a snap:
253
+ - name: the name of the snap
254
+ - state: a `SnapState` representation of its install status
255
+ - channel: "stable", "candidate", "beta", and "edge" are common
256
+ - revision: a string representing the snap's revision
257
+ - confinement: "classic", "strict", or "devmode"
258
+ - version: a string representing the snap's version, if set by the snap author
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ name: str,
264
+ state: SnapState,
265
+ channel: str,
266
+ revision: str,
267
+ confinement: str,
268
+ apps: list[dict[str, JSONType]] | None = None,
269
+ cohort: str | None = None,
270
+ *,
271
+ version: str | None = None,
272
+ ) -> None:
273
+ self._name = name
274
+ self._state = state
275
+ self._channel = channel
276
+ self._revision = revision
277
+ self._confinement = confinement
278
+ self._cohort = cohort or ''
279
+ self._apps = apps or []
280
+ self._version = version
281
+ self._snap_client = SnapClient()
282
+
283
+ def __eq__(self, other: object) -> bool:
284
+ """Equality for comparison."""
285
+ return isinstance(other, self.__class__) and (
286
+ self._name,
287
+ self._revision,
288
+ ) == (other._name, other._revision)
289
+
290
+ def __hash__(self) -> int:
291
+ """Calculate a hash for this snap."""
292
+ return hash((self._name, self._revision))
293
+
294
+ def __repr__(self) -> str:
295
+ """Represent the object such that it can be reconstructed."""
296
+ return f'<{self.__module__}.{type(self).__name__}: {self.__dict__}>'
297
+
298
+ def __str__(self) -> str:
299
+ """Represent the snap object as a string."""
300
+ return (
301
+ f'<{type(self).__name__}: '
302
+ f'{self._name}-{self._revision}.{self._channel}'
303
+ f' -- {self._state}>'
304
+ )
305
+
306
+ def _snap(self, command: str, optargs: Iterable[str] | None = None) -> str:
307
+ """Perform a snap operation.
308
+
309
+ Args:
310
+ command: the snap command to execute
311
+ optargs: an (optional) list of additional arguments to pass,
312
+ commonly confinement or channel
313
+
314
+ Raises:
315
+ SnapError if there is a problem encountered
316
+ """
317
+ optargs = optargs or []
318
+ args = ['snap', command, self._name, *optargs]
319
+ try:
320
+ with tracer.start_as_current_span(args[0]) as span:
321
+ span.set_attribute('argv', args)
322
+ return subprocess.check_output(args, text=True, stderr=subprocess.PIPE)
323
+ except CalledProcessError as e:
324
+ msg = f'Snap: {self._name!r} -- command {args!r} failed!'
325
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
326
+
327
+ def _snap_daemons(
328
+ self,
329
+ command: list[str],
330
+ services: list[str] | None = None,
331
+ ) -> CompletedProcess[str]:
332
+ """Perform snap app commands.
333
+
334
+ Args:
335
+ command: the snap command to execute
336
+ services: the snap service to execute command on
337
+
338
+ Raises:
339
+ SnapError if there is a problem encountered
340
+ """
341
+ if services:
342
+ # an attempt to keep the command constrained to the snap instance's services
343
+ services = [f'{self._name}.{service}' for service in services]
344
+ else:
345
+ services = [self._name]
346
+
347
+ args = ['snap', *command, *services]
348
+
349
+ try:
350
+ with tracer.start_as_current_span(args[0]) as span:
351
+ span.set_attribute('argv', args)
352
+ return subprocess.run(args, text=True, check=True, capture_output=True)
353
+ except CalledProcessError as e:
354
+ msg = f'Snap: {self._name!r} -- command {args!r} failed!'
355
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
356
+
357
+ @typing.overload
358
+ def get(self, key: Literal[''] | None, *, typed: Literal[False] = False) -> NoReturn: ...
359
+ @typing.overload
360
+ def get(self, key: str, *, typed: Literal[False] = False) -> str: ...
361
+ @typing.overload
362
+ def get(self, key: Literal[''] | None, *, typed: Literal[True]) -> dict[str, JSONType]: ...
363
+ @typing.overload
364
+ def get(self, key: str, *, typed: Literal[True]) -> JSONType: ...
365
+ def get(self, key: str | None, *, typed: bool = False) -> JSONType | str:
366
+ """Fetch snap configuration values.
367
+
368
+ Args:
369
+ key: the key to retrieve. Default to retrieve all values for typed=True.
370
+ typed: set to True to retrieve typed values (set with typed=True).
371
+ Default is to return a string.
372
+ """
373
+ if typed:
374
+ args = ['-d']
375
+ if key:
376
+ args.append(key)
377
+ config = json.loads(self._snap('get', args)) # json.loads -> Any
378
+ if key:
379
+ return config.get(key)
380
+ return config
381
+
382
+ if not key:
383
+ raise TypeError('Key must be provided when typed=False')
384
+
385
+ # return a string
386
+ return self._snap('get', [key]).strip()
387
+
388
+ def set(self, config: Mapping[str, JSONAble], *, typed: bool = False) -> None:
389
+ """Set a snap configuration value.
390
+
391
+ Args:
392
+ config: a dictionary containing keys and values specifying the config to set.
393
+ typed: set to True to convert all values in the config into typed values while
394
+ configuring the snap (set with typed=True). Default is not to convert.
395
+ """
396
+ if not typed:
397
+ config = {k: str(v) for k, v in config.items()}
398
+ self._snap_client._put_snap_conf(self._name, config)
399
+
400
+ def unset(self, key: str) -> str:
401
+ """Unset a snap configuration value.
402
+
403
+ Args:
404
+ key: the key to unset
405
+ """
406
+ return self._snap('unset', [key])
407
+
408
+ def start(self, services: list[str] | None = None, enable: bool = False) -> None:
409
+ """Start a snap's services.
410
+
411
+ Args:
412
+ services (list): (optional) list of individual snap services to start (otherwise all)
413
+ enable (bool): (optional) flag to enable snap services on start. Default `false`
414
+ """
415
+ args = ['start', '--enable'] if enable else ['start']
416
+ self._snap_daemons(args, services)
417
+
418
+ def stop(self, services: list[str] | None = None, disable: bool = False) -> None:
419
+ """Stop a snap's services.
420
+
421
+ Args:
422
+ services (list): (optional) list of individual snap services to stop (otherwise all)
423
+ disable (bool): (optional) flag to disable snap services on stop. Default `False`
424
+ """
425
+ args = ['stop', '--disable'] if disable else ['stop']
426
+ self._snap_daemons(args, services)
427
+
428
+ def logs(self, services: list[str] | None = None, num_lines: int | Literal['all'] = 10) -> str:
429
+ """Fetch a snap services' logs.
430
+
431
+ Args:
432
+ services: individual snap services to show logs from. Includes all by default.
433
+ num_lines: number of log lines to return, or 'all'. Returns 10 by default.
434
+ """
435
+ args = ['logs', f'-n={num_lines}'] if num_lines else ['logs']
436
+ return self._snap_daemons(args, services).stdout
437
+
438
+ def connect(self, plug: str, service: str | None = None, slot: str | None = None) -> None:
439
+ """Connect a plug to a slot.
440
+
441
+ Args:
442
+ plug (str): the plug to connect
443
+ service (str): (optional) the snap service name to plug into
444
+ slot (str): (optional) the snap service slot to plug in to
445
+
446
+ Raises:
447
+ SnapError if there is a problem encountered
448
+ """
449
+ command = ['connect', f'{self._name}:{plug}']
450
+
451
+ if service and slot:
452
+ command.append(f'{service}:{slot}')
453
+ elif slot:
454
+ command.append(slot)
455
+
456
+ args = ['snap', *command]
457
+ try:
458
+ with tracer.start_as_current_span(args[0]) as span:
459
+ span.set_attribute('argv', args)
460
+ subprocess.run(args, text=True, check=True, capture_output=True)
461
+ except CalledProcessError as e:
462
+ msg = f'Snap: {self._name!r} -- command {args!r} failed!'
463
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
464
+
465
+ def hold(self, duration: timedelta | None = None) -> None:
466
+ """Add a refresh hold to a snap.
467
+
468
+ Args:
469
+ duration: duration for the hold, or None (the default) to hold this snap indefinitely.
470
+ """
471
+ hold_str = 'forever'
472
+ if duration is not None:
473
+ seconds = round(duration.total_seconds())
474
+ hold_str = f'{seconds}s'
475
+ self._snap('refresh', [f'--hold={hold_str}'])
476
+
477
+ def unhold(self) -> None:
478
+ """Remove the refresh hold of a snap."""
479
+ self._snap('refresh', ['--unhold'])
480
+
481
+ def alias(self, application: str, alias: str | None = None) -> None:
482
+ """Create an alias for a given application.
483
+
484
+ Args:
485
+ application: application to get an alias.
486
+ alias: (optional) name of the alias; if not provided, the application name is used.
487
+ """
488
+ if alias is None:
489
+ alias = application
490
+ args = ['snap', 'alias', f'{self.name}.{application}', alias]
491
+ try:
492
+ with tracer.start_as_current_span(args[0]) as span:
493
+ span.set_attribute('argv', args)
494
+ subprocess.run(args, text=True, check=True, capture_output=True)
495
+ except CalledProcessError as e:
496
+ msg = f'Snap: {self._name!r} -- command {args!r} failed!'
497
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
498
+
499
+ def restart(self, services: list[str] | None = None, reload: bool = False) -> None:
500
+ """Restarts a snap's services.
501
+
502
+ Args:
503
+ services (list): (optional) list of individual snap services to restart.
504
+ (otherwise all)
505
+ reload (bool): (optional) flag to use the service reload command, if available.
506
+ Default `False`
507
+ """
508
+ args = ['restart', '--reload'] if reload else ['restart']
509
+ self._snap_daemons(args, services)
510
+
511
+ def _install(
512
+ self,
513
+ channel: str = '',
514
+ cohort: str = '',
515
+ revision: str = '',
516
+ ) -> None:
517
+ """Add a snap to the system.
518
+
519
+ Args:
520
+ channel: the channel to install from
521
+ cohort: optional, the key of a cohort that this snap belongs to
522
+ revision: optional, the revision of the snap to install
523
+ """
524
+ cohort = cohort or self._cohort
525
+
526
+ args: list[str] = []
527
+ if self.confinement == 'classic':
528
+ args.append('--classic')
529
+ if self.confinement == 'devmode':
530
+ args.append('--devmode')
531
+ if channel:
532
+ args.append(f'--channel="{channel}"')
533
+ if revision:
534
+ args.append(f'--revision="{revision}"')
535
+ if cohort:
536
+ args.append(f'--cohort="{cohort}"')
537
+
538
+ self._snap('install', args)
539
+
540
+ def _refresh(
541
+ self,
542
+ channel: str = '',
543
+ cohort: str = '',
544
+ revision: str = '',
545
+ devmode: bool = False,
546
+ leave_cohort: bool = False,
547
+ ) -> None:
548
+ """Refresh a snap.
549
+
550
+ Args:
551
+ channel: the channel to install from
552
+ cohort: optionally, specify a cohort.
553
+ revision: optionally, specify the revision of the snap to refresh
554
+ devmode: optionally, specify devmode confinement
555
+ leave_cohort: leave the current cohort.
556
+ """
557
+ args: list[str] = []
558
+ if channel:
559
+ args.append(f'--channel="{channel}"')
560
+
561
+ if revision:
562
+ args.append(f'--revision="{revision}"')
563
+
564
+ if self.confinement == 'classic':
565
+ args.append('--classic')
566
+
567
+ if devmode:
568
+ args.append('--devmode')
569
+
570
+ if not cohort:
571
+ cohort = self._cohort
572
+
573
+ if leave_cohort:
574
+ self._cohort = ''
575
+ args.append('--leave-cohort')
576
+ elif cohort:
577
+ args.append(f'--cohort="{cohort}"')
578
+
579
+ self._snap('refresh', args)
580
+
581
+ def _remove(self) -> str:
582
+ """Remove a snap from the system."""
583
+ return self._snap('remove')
584
+
585
+ @property
586
+ def name(self) -> str:
587
+ """Returns the name of the snap."""
588
+ return self._name
589
+
590
+ def ensure(
591
+ self,
592
+ state: SnapState,
593
+ classic: bool = False,
594
+ devmode: bool = False,
595
+ channel: str | None = None,
596
+ cohort: str | None = None,
597
+ revision: str | int | None = None,
598
+ ):
599
+ """Ensure that a snap is in a given state.
600
+
601
+ Args:
602
+ state: a `SnapState` to reconcile to.
603
+ classic: an (Optional) boolean indicating whether classic confinement should be used
604
+ devmode: an (Optional) boolean indicating whether devmode confinement should be used
605
+ channel: the channel to install from
606
+ cohort: optional. Specify the key of a snap cohort.
607
+ revision: optional. the revision of the snap to install/refresh
608
+
609
+ While both channel and revision could be specified, the underlying snap install/refresh
610
+ command will determine which one takes precedence (revision at this time)
611
+
612
+ Raises:
613
+ SnapError if an error is encountered
614
+ """
615
+ channel = channel or ''
616
+ cohort = cohort or ''
617
+ revision = str(revision) if revision is not None else ''
618
+
619
+ if classic and devmode:
620
+ raise ValueError('Cannot set both classic and devmode confinement')
621
+
622
+ if classic or self._confinement == 'classic':
623
+ self._confinement = 'classic'
624
+ elif devmode or self._confinement == 'devmode':
625
+ self._confinement = 'devmode'
626
+ else:
627
+ self._confinement = ''
628
+
629
+ if state not in (SnapState.Present, SnapState.Latest):
630
+ # We are attempting to remove this snap.
631
+ if self._state in (SnapState.Present, SnapState.Latest):
632
+ # The snap is installed, so we run _remove.
633
+ self._remove()
634
+ else:
635
+ # The snap is not installed -- no need to do anything.
636
+ pass
637
+ else:
638
+ # We are installing or refreshing a snap.
639
+ if self._state not in (SnapState.Present, SnapState.Latest):
640
+ # The snap is not installed, so we install it.
641
+ logger.info(
642
+ 'Installing snap %s, revision %s, tracking %s', self._name, revision, channel
643
+ )
644
+ self._install(channel, cohort, revision)
645
+ logger.info('The snap installation completed successfully')
646
+ elif revision != self._revision:
647
+ # The snap is installed, but we are changing it (e.g., switching channels).
648
+ logger.info(
649
+ 'Refreshing snap %s, revision %s, tracking %s', self._name, revision, channel
650
+ )
651
+ self._refresh(channel=channel, cohort=cohort, revision=revision, devmode=devmode)
652
+ logger.info('The snap refresh completed successfully')
653
+ else:
654
+ logger.info('Refresh of snap %s was unnecessary', self._name)
655
+
656
+ self._update_snap_apps()
657
+ self._state = state
658
+
659
+ def _update_snap_apps(self) -> None:
660
+ """Update a snap's apps after snap changes state."""
661
+ try:
662
+ self._apps = self._snap_client.get_installed_snap_apps(self._name)
663
+ except SnapAPIError:
664
+ logger.debug('Unable to retrieve snap apps for %s', self._name)
665
+ self._apps = []
666
+
667
+ @property
668
+ def present(self) -> bool:
669
+ """Report whether or not a snap is present."""
670
+ return self._state in (SnapState.Present, SnapState.Latest)
671
+
672
+ @property
673
+ def latest(self) -> bool:
674
+ """Report whether the snap is the most recent version."""
675
+ return self._state is SnapState.Latest
676
+
677
+ @property
678
+ def state(self) -> SnapState:
679
+ """Report the current snap state."""
680
+ return self._state
681
+
682
+ @state.setter
683
+ def state(self, state: SnapState) -> None:
684
+ """Set the snap state to a given value.
685
+
686
+ Args:
687
+ state: a `SnapState` to reconcile the snap to.
688
+
689
+ Raises:
690
+ SnapError if an error is encountered
691
+ """
692
+ if self._state is not state:
693
+ self.ensure(state)
694
+ self._state = state
695
+
696
+ @property
697
+ def revision(self) -> str:
698
+ """Returns the revision for a snap."""
699
+ return self._revision
700
+
701
+ @property
702
+ def channel(self) -> str:
703
+ """Returns the channel for a snap."""
704
+ return self._channel
705
+
706
+ @property
707
+ def confinement(self) -> str:
708
+ """Returns the confinement for a snap."""
709
+ return self._confinement
710
+
711
+ @property
712
+ def apps(self) -> list[dict[str, JSONType]]:
713
+ """Returns (if any) the installed apps of the snap."""
714
+ self._update_snap_apps()
715
+ return self._apps
716
+
717
+ @property
718
+ def services(self) -> dict[str, SnapServiceDict]:
719
+ """Returns (if any) the installed services of the snap."""
720
+ self._update_snap_apps()
721
+ services: dict[str, SnapServiceDict] = {}
722
+ for app in self._apps:
723
+ if 'daemon' in app:
724
+ app = typing.cast('_SnapServiceAppDict', app)
725
+ services[app['name']] = SnapService(**app).as_dict()
726
+
727
+ return services
728
+
729
+ @property
730
+ def held(self) -> bool:
731
+ """Report whether the snap has a hold."""
732
+ info = self._snap('info')
733
+ return 'hold:' in info
734
+
735
+ @property
736
+ def version(self) -> str | None:
737
+ """Returns the version for a snap."""
738
+ return self._version
739
+
740
+
741
+ class _UnixSocketConnection(http.client.HTTPConnection):
742
+ """Implementation of HTTPConnection that connects to a named Unix socket."""
743
+
744
+ def __init__(self, host: str, timeout: float | None = None, socket_path: str | None = None):
745
+ if timeout is None:
746
+ super().__init__(host)
747
+ else:
748
+ super().__init__(host, timeout=timeout)
749
+ self.socket_path = socket_path
750
+
751
+ def connect(self):
752
+ """Override connect to use Unix socket (instead of TCP socket)."""
753
+ if not hasattr(socket, 'AF_UNIX'):
754
+ raise NotImplementedError(f'Unix sockets not supported on {sys.platform}')
755
+ assert self.socket_path is not None # else TypeError on self.socket.connect
756
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
757
+ self.sock.connect(self.socket_path)
758
+ if self.timeout is not None:
759
+ self.sock.settimeout(self.timeout)
760
+
761
+
762
+ class _UnixSocketHandler(urllib.request.AbstractHTTPHandler):
763
+ """Implementation of HTTPHandler that uses a named Unix socket."""
764
+
765
+ def __init__(self, socket_path: str):
766
+ super().__init__()
767
+ self.socket_path = socket_path
768
+
769
+ def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
770
+ """Override http_open to use a Unix socket connection (instead of TCP)."""
771
+ return self.do_open(
772
+ typing.cast('urllib.request._HTTPConnectionProtocol', _UnixSocketConnection),
773
+ req,
774
+ socket_path=self.socket_path,
775
+ )
776
+
777
+
778
+ class SnapClient:
779
+ """Snapd API client to talk to HTTP over UNIX sockets.
780
+
781
+ In order to avoid shelling out and/or involving sudo in calling the snapd API,
782
+ use a wrapper based on the Pebble Client, trimmed down to only the utility methods
783
+ needed for talking to snapd.
784
+ """
785
+
786
+ def __init__(
787
+ self,
788
+ socket_path: str = '/run/snapd.socket',
789
+ opener: urllib.request.OpenerDirector | None = None,
790
+ base_url: str = 'http://localhost/v2/',
791
+ timeout: float = 30.0,
792
+ ):
793
+ """Initialize a client instance.
794
+
795
+ Args:
796
+ socket_path: a path to the socket on the filesystem. Defaults to /run/snap/snapd.socket
797
+ opener: specifies an opener for unix socket, if unspecified a default is used
798
+ base_url: base URL for making requests to the snap client. Must be an HTTP(S) URL.
799
+ Defaults to http://localhost/v2/
800
+ timeout: timeout in seconds to use when making requests to the API. Default is 30.0s.
801
+ """
802
+ if opener is None:
803
+ opener = self._get_default_opener(socket_path)
804
+ self.opener = opener
805
+ # Address ruff's suspicious-url-open-usage (S310)
806
+ if not base_url.startswith(('http:', 'https:')):
807
+ raise ValueError("base_url must start with 'http:' or 'https:'")
808
+ self.base_url = base_url
809
+ self.timeout = timeout
810
+
811
+ @classmethod
812
+ def _get_default_opener(cls, socket_path: str) -> urllib.request.OpenerDirector:
813
+ """Build the default opener to use for requests (HTTP over Unix socket)."""
814
+ opener = urllib.request.OpenerDirector()
815
+ opener.add_handler(_UnixSocketHandler(socket_path))
816
+ opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
817
+ opener.add_handler(urllib.request.HTTPRedirectHandler())
818
+ opener.add_handler(urllib.request.HTTPErrorProcessor())
819
+ return opener
820
+
821
+ def _request(
822
+ self,
823
+ method: str,
824
+ path: str,
825
+ query: Mapping[str, str] | None = None,
826
+ body: Mapping[str, JSONAble] | None = None,
827
+ ) -> JSONType | None:
828
+ """Make a JSON request to the Snapd server with the given HTTP method and path.
829
+
830
+ If query dict is provided, it is encoded and appended as a query string
831
+ to the URL. If body dict is provided, it is serialied as JSON and used
832
+ as the HTTP body (with Content-Type: "application/json"). The resulting
833
+ body is decoded from JSON.
834
+ """
835
+ headers = {'Accept': 'application/json'}
836
+ data = None
837
+ if body is not None:
838
+ data = json.dumps(body).encode('utf-8')
839
+ headers['Content-Type'] = 'application/json'
840
+
841
+ response = self._request_raw(method, path, query, headers, data)
842
+ response = json.loads(response.read().decode()) # json.loads -> Any
843
+ if response['type'] == 'async':
844
+ return self._wait(response['change']) # may be `None` due to `get`
845
+ return response['result']
846
+
847
+ def _wait(self, change_id: str, timeout: float = 300) -> JSONType | None:
848
+ """Wait for an async change to complete.
849
+
850
+ The poll time is 100 milliseconds, the same as in snap clients.
851
+ """
852
+ deadline = time.time() + timeout
853
+ while True:
854
+ if time.time() > deadline:
855
+ raise TimeoutError(f'timeout waiting for snap change {change_id}')
856
+ response = self._request('GET', f'changes/{change_id}')
857
+ response = typing.cast('_AsyncChangeDict', response)
858
+ status = response['status']
859
+ if status == 'Done':
860
+ return response.get('data')
861
+ if status == 'Doing' or status == 'Do':
862
+ time.sleep(0.1)
863
+ continue
864
+ if status == 'Wait':
865
+ logger.warning("snap change %s succeeded with status 'Wait'", change_id)
866
+ return response.get('data')
867
+ raise SnapError(
868
+ f'snap change {response.get("kind")!r} id {change_id} failed with status {status}'
869
+ )
870
+
871
+ def _request_raw(
872
+ self,
873
+ method: str,
874
+ path: str,
875
+ query: Mapping[str, str] | None = None,
876
+ headers: dict[str, str] | None = None,
877
+ data: bytes | None = None,
878
+ ) -> http.client.HTTPResponse:
879
+ """Make a request to the Snapd server; return the raw HTTPResponse object."""
880
+ url = self.base_url + path
881
+ if query:
882
+ url = url + '?' + urllib.parse.urlencode(query)
883
+
884
+ if headers is None:
885
+ headers = {}
886
+ request = urllib.request.Request(url, method=method, data=data, headers=headers) # noqa: S310
887
+
888
+ try:
889
+ response = self.opener.open(request, timeout=self.timeout)
890
+ except urllib.error.HTTPError as e:
891
+ code = e.code
892
+ status = e.reason
893
+ message = ''
894
+ body: dict[str, JSONType]
895
+ try:
896
+ body = json.loads(e.read().decode())['result'] # json.loads -> Any
897
+ except (OSError, ValueError, KeyError) as e2:
898
+ # Will only happen on read error or if Pebble sends invalid JSON.
899
+ body = {}
900
+ message = f'{type(e2).__name__} - {e2}'
901
+ raise SnapAPIError(body, code, status, message) from e
902
+ except urllib.error.URLError as e:
903
+ raise SnapAPIError({}, 500, 'Not found', str(e.reason)) from e
904
+ return response
905
+
906
+ def get_installed_snaps(self) -> list[dict[str, JSONType]]:
907
+ """Get information about currently installed snaps."""
908
+ with tracer.start_as_current_span('get_installed_snaps'):
909
+ return self._request('GET', 'snaps') # type: ignore
910
+
911
+ def get_snap_information(self, name: str) -> dict[str, JSONType]:
912
+ """Query the snap server for information about single snap."""
913
+ with tracer.start_as_current_span('get_snap_information') as span:
914
+ span.set_attribute('name', name)
915
+ return self._request('GET', 'find', {'name': name})[0] # type: ignore
916
+
917
+ def get_installed_snap_apps(self, name: str) -> list[dict[str, JSONType]]:
918
+ """Query the snap server for apps belonging to a named, currently installed snap."""
919
+ with tracer.start_as_current_span('get_installed_snap_apps') as span:
920
+ span.set_attribute('name', name)
921
+ return self._request('GET', 'apps', {'names': name, 'select': 'service'}) # type: ignore
922
+
923
+ def _put_snap_conf(self, name: str, conf: Mapping[str, JSONAble]) -> None:
924
+ """Set the configuration details for an installed snap."""
925
+ self._request('PUT', f'snaps/{name}/conf', body=conf)
926
+
927
+
928
+ class SnapCache(Mapping[str, Snap]):
929
+ """An abstraction to represent installed/available packages.
930
+
931
+ When instantiated, `SnapCache` iterates through the list of installed
932
+ snaps using the `snapd` HTTP API, and a list of available snaps by reading
933
+ the filesystem to populate the cache. Information about available snaps is lazily-loaded
934
+ from the `snapd` API when requested.
935
+ """
936
+
937
+ def __init__(self):
938
+ if not self.snapd_installed:
939
+ raise SnapError('snapd is not installed or not in /usr/bin') from None
940
+ self._snap_client = SnapClient()
941
+ self._snap_map: dict[str, Snap | None] = {}
942
+ if self.snapd_installed:
943
+ self._load_available_snaps()
944
+ self._load_installed_snaps()
945
+
946
+ def __contains__(self, key: object) -> bool:
947
+ """Check if a given snap is in the cache."""
948
+ return key in self._snap_map
949
+
950
+ def __len__(self) -> int:
951
+ """Report number of items in the snap cache."""
952
+ return len(self._snap_map)
953
+
954
+ def __iter__(self) -> Iterable[Snap | None]: # pyright: ignore[reportIncompatibleMethodOverride]
955
+ """Provide iterator for the snap cache."""
956
+ return iter(self._snap_map.values())
957
+
958
+ def __getitem__(self, snap_name: str) -> Snap:
959
+ """Return either the installed version or latest version for a given snap."""
960
+ snap = self._snap_map.get(snap_name)
961
+ if snap is not None:
962
+ return snap
963
+ # The snapd cache file may not have existed when _snap_map was
964
+ # populated. This is normal.
965
+ try:
966
+ snap = self._snap_map[snap_name] = self._load_info(snap_name)
967
+ except SnapAPIError as e:
968
+ raise SnapNotFoundError(f"Snap '{snap_name}' not found!") from e
969
+ return snap
970
+
971
+ @property
972
+ def snapd_installed(self) -> bool:
973
+ """Check whether snapd has been installed on the system."""
974
+ return os.path.isfile('/usr/bin/snap')
975
+
976
+ def _load_available_snaps(self) -> None:
977
+ """Load the list of available snaps from disk.
978
+
979
+ Leave them empty and lazily load later if asked for.
980
+ """
981
+ if not os.path.isfile('/var/cache/snapd/names'):
982
+ # The snap catalog may not be populated yet; this is normal.
983
+ # snapd updates the cache infrequently and the cache file may not
984
+ # currently exist.
985
+ return
986
+
987
+ with open('/var/cache/snapd/names') as f:
988
+ for line in f:
989
+ if line.strip():
990
+ self._snap_map[line.strip()] = None
991
+
992
+ def _load_installed_snaps(self) -> None:
993
+ """Load the installed snaps into the dict."""
994
+ installed = self._snap_client.get_installed_snaps()
995
+
996
+ for i in installed:
997
+ i = typing.cast('_SnapDict', i)
998
+ snap = Snap(
999
+ name=i['name'],
1000
+ state=SnapState.Latest,
1001
+ channel=i['channel'],
1002
+ revision=i['revision'],
1003
+ confinement=i['confinement'],
1004
+ apps=i.get('apps'),
1005
+ version=i.get('version'),
1006
+ )
1007
+ self._snap_map[snap.name] = snap
1008
+
1009
+ def _load_info(self, name: str) -> Snap:
1010
+ """Load info for snaps which are not installed if requested.
1011
+
1012
+ Args:
1013
+ name: a string representing the name of the snap
1014
+ """
1015
+ info = self._snap_client.get_snap_information(name)
1016
+ info = typing.cast('_SnapDict', info)
1017
+
1018
+ return Snap(
1019
+ name=info['name'],
1020
+ state=SnapState.Available,
1021
+ channel=info['channel'],
1022
+ revision=info['revision'],
1023
+ confinement=info['confinement'],
1024
+ apps=None,
1025
+ version=info.get('version'),
1026
+ )
1027
+
1028
+
1029
+ @typing.overload
1030
+ def add( # return a single Snap if snap name is given as a string
1031
+ snap_names: str,
1032
+ state: str | SnapState = SnapState.Latest,
1033
+ channel: str | None = None,
1034
+ classic: bool = False,
1035
+ devmode: bool = False,
1036
+ cohort: str | None = None,
1037
+ revision: str | int | None = None,
1038
+ ) -> Snap: ...
1039
+ @typing.overload
1040
+ def add( # may return a single Snap or a list depending if one or more snap names were given
1041
+ snap_names: list[str],
1042
+ state: str | SnapState = SnapState.Latest,
1043
+ channel: str | None = None,
1044
+ classic: bool = False,
1045
+ devmode: bool = False,
1046
+ cohort: str | None = None,
1047
+ revision: str | int | None = None,
1048
+ ) -> Snap | list[Snap]: ...
1049
+ @_cache_init
1050
+ def add(
1051
+ snap_names: str | list[str],
1052
+ state: str | SnapState = SnapState.Latest,
1053
+ channel: str | None = None,
1054
+ classic: bool = False,
1055
+ devmode: bool = False,
1056
+ cohort: str | None = None,
1057
+ revision: str | int | None = None,
1058
+ ) -> Snap | list[Snap]:
1059
+ """Add a snap to the system.
1060
+
1061
+ Args:
1062
+ snap_names: the name or names of the snaps to install
1063
+ state: a string or `SnapState` representation of the desired state, one of
1064
+ [`Present` or `Latest`]
1065
+ channel: an (Optional) channel as a string. Defaults to 'latest'
1066
+ classic: an (Optional) boolean specifying whether it should be added with classic
1067
+ confinement. Default `False`
1068
+ devmode: an (Optional) boolean specifying whether it should be added with devmode
1069
+ confinement. Default `False`
1070
+ cohort: an (Optional) string specifying the snap cohort to use
1071
+ revision: an (Optional) string specifying the snap revision to use
1072
+
1073
+ Raises:
1074
+ SnapError if some snaps failed to install or were not found.
1075
+ """
1076
+ if not channel and not revision:
1077
+ channel = 'latest'
1078
+
1079
+ snap_names = [snap_names] if isinstance(snap_names, str) else snap_names
1080
+ if not snap_names:
1081
+ raise TypeError('Expected at least one snap to add, received zero!')
1082
+
1083
+ if isinstance(state, str):
1084
+ state = SnapState(state)
1085
+
1086
+ return _wrap_snap_operations(
1087
+ snap_names=snap_names,
1088
+ state=state,
1089
+ channel=channel or '',
1090
+ classic=classic,
1091
+ devmode=devmode,
1092
+ cohort=cohort or '',
1093
+ revision=str(revision) if revision is not None else '',
1094
+ )
1095
+
1096
+
1097
+ @typing.overload
1098
+ def remove(snap_names: str) -> Snap: ...
1099
+ # return a single Snap if snap name is given as a string
1100
+ @typing.overload
1101
+ def remove(snap_names: list[str]) -> Snap | list[Snap]: ...
1102
+ # may return a single Snap or a list depending if one or more snap names were given
1103
+ @_cache_init
1104
+ def remove(snap_names: str | list[str]) -> Snap | list[Snap]:
1105
+ """Remove specified snap(s) from the system.
1106
+
1107
+ Args:
1108
+ snap_names: the name or names of the snaps to install
1109
+
1110
+ Raises:
1111
+ SnapError if some snaps failed to install.
1112
+ """
1113
+ snap_names = [snap_names] if isinstance(snap_names, str) else snap_names
1114
+ if not snap_names:
1115
+ raise TypeError('Expected at least one snap to add, received zero!')
1116
+ return _wrap_snap_operations(
1117
+ snap_names=snap_names,
1118
+ state=SnapState.Absent,
1119
+ channel='',
1120
+ classic=False,
1121
+ devmode=False,
1122
+ )
1123
+
1124
+
1125
+ @typing.overload
1126
+ def ensure( # return a single Snap if snap name is given as a string
1127
+ snap_names: str,
1128
+ state: str,
1129
+ channel: str | None = None,
1130
+ classic: bool = False,
1131
+ devmode: bool = False,
1132
+ cohort: str | None = None,
1133
+ revision: str | int | None = None,
1134
+ ) -> Snap: ...
1135
+ @typing.overload
1136
+ def ensure( # may return a single Snap or a list depending if one or more snap names were given
1137
+ snap_names: list[str],
1138
+ state: str,
1139
+ channel: str | None = None,
1140
+ classic: bool = False,
1141
+ devmode: bool = False,
1142
+ cohort: str | None = None,
1143
+ revision: str | int | None = None,
1144
+ ) -> Snap | list[Snap]: ...
1145
+ @_cache_init
1146
+ def ensure(
1147
+ snap_names: str | list[str],
1148
+ state: str,
1149
+ channel: str | None = None,
1150
+ classic: bool = False,
1151
+ devmode: bool = False,
1152
+ cohort: str | None = None,
1153
+ revision: str | int | None = None,
1154
+ ) -> Snap | list[Snap]:
1155
+ """Ensure specified snaps are in a given state on the system.
1156
+
1157
+ Args:
1158
+ snap_names: the name(s) of the snaps to operate on
1159
+ state: a string representation of the desired state, from `SnapState`
1160
+ channel: an (Optional) channel as a string. Defaults to 'latest'
1161
+ classic: an (Optional) boolean specifying whether it should be added with classic
1162
+ confinement. Default `False`
1163
+ devmode: an (Optional) boolean specifying whether it should be added with devmode
1164
+ confinement. Default `False`
1165
+ cohort: an (Optional) string specifying the snap cohort to use
1166
+ revision: an (Optional) string specifying the snap revision to use
1167
+
1168
+ When both channel and revision are specified, the underlying snap install/refresh
1169
+ command will determine the precedence (revision at the time of adding this)
1170
+
1171
+ Raises:
1172
+ SnapError if the snap is not in the cache.
1173
+ """
1174
+ if not revision and not channel:
1175
+ channel = 'latest'
1176
+
1177
+ if state in ('present', 'latest') or revision:
1178
+ return add(
1179
+ snap_names=snap_names,
1180
+ state=SnapState(state),
1181
+ channel=channel,
1182
+ classic=classic,
1183
+ devmode=devmode,
1184
+ cohort=cohort,
1185
+ revision=str(revision) if revision is not None else '',
1186
+ )
1187
+ else:
1188
+ return remove(snap_names)
1189
+
1190
+
1191
+ def _wrap_snap_operations(
1192
+ snap_names: list[str],
1193
+ state: SnapState,
1194
+ channel: str,
1195
+ classic: bool,
1196
+ devmode: bool,
1197
+ cohort: str = '',
1198
+ revision: str = '',
1199
+ ) -> Snap | list[Snap]:
1200
+ """Wrap common operations for bare commands."""
1201
+ snaps: list[Snap] = []
1202
+ errors: list[str] = []
1203
+
1204
+ op = 'remove' if state is SnapState.Absent else 'install or refresh'
1205
+
1206
+ for s in snap_names:
1207
+ try:
1208
+ snap = _Cache[s]
1209
+ if state is SnapState.Absent:
1210
+ snap.ensure(state=SnapState.Absent)
1211
+ else:
1212
+ snap.ensure(
1213
+ state=state,
1214
+ classic=classic,
1215
+ devmode=devmode,
1216
+ channel=channel,
1217
+ cohort=cohort,
1218
+ revision=revision,
1219
+ )
1220
+ snaps.append(snap)
1221
+ except SnapError as e: # noqa: PERF203
1222
+ logger.warning('Failed to %s snap %s: %s!', op, s, e.message)
1223
+ errors.append(s)
1224
+ except SnapNotFoundError:
1225
+ logger.warning("Snap '%s' not found in cache!", s)
1226
+ errors.append(s)
1227
+
1228
+ if errors:
1229
+ raise SnapError(f'Failed to install or refresh snap(s): {", ".join(errors)}')
1230
+
1231
+ return snaps if len(snaps) > 1 else snaps[0]
1232
+
1233
+
1234
+ def install_local(
1235
+ filename: str,
1236
+ classic: bool = False,
1237
+ devmode: bool = False,
1238
+ dangerous: bool = False,
1239
+ ) -> Snap:
1240
+ """Perform a snap operation.
1241
+
1242
+ Args:
1243
+ filename: the path to a local .snap file to install
1244
+ classic: whether to use classic confinement
1245
+ devmode: whether to use devmode confinement
1246
+ dangerous: whether --dangerous should be passed to install snaps without a signature
1247
+
1248
+ Raises:
1249
+ SnapError if there is a problem encountered
1250
+ """
1251
+ args = [
1252
+ 'snap',
1253
+ 'install',
1254
+ filename,
1255
+ ]
1256
+ if classic:
1257
+ args.append('--classic')
1258
+ if devmode:
1259
+ args.append('--devmode')
1260
+ if dangerous:
1261
+ args.append('--dangerous')
1262
+ try:
1263
+ with tracer.start_as_current_span(args[0]) as span:
1264
+ span.set_attribute('argv', args)
1265
+ result = subprocess.check_output(
1266
+ args,
1267
+ text=True,
1268
+ stderr=subprocess.PIPE,
1269
+ ).splitlines()[-1]
1270
+ snap_name, _ = result.split(' ', 1)
1271
+ snap_name = ansi_filter.sub('', snap_name)
1272
+
1273
+ c = SnapCache()
1274
+
1275
+ try:
1276
+ return c[snap_name]
1277
+ except SnapAPIError as e:
1278
+ logger.error(
1279
+ 'Could not find snap %s when querying Snapd socket: %s',
1280
+ snap_name,
1281
+ e.body,
1282
+ )
1283
+ raise SnapError(f'Failed to find snap {snap_name} in Snap cache') from e
1284
+ except CalledProcessError as e:
1285
+ msg = f'Cound not install snap {filename}!'
1286
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
1287
+
1288
+
1289
+ def _system_set(config_item: str, value: str) -> None:
1290
+ """Set system snapd config values.
1291
+
1292
+ Args:
1293
+ config_item: name of snap system setting. E.g. 'refresh.hold'
1294
+ value: value to assign
1295
+ """
1296
+ args = ['snap', 'set', 'system', f'{config_item}={value}']
1297
+ try:
1298
+ with tracer.start_as_current_span(args[0]) as span:
1299
+ span.set_attribute('argv', args)
1300
+ subprocess.run(args, text=True, check=True, capture_output=True)
1301
+ except CalledProcessError as e:
1302
+ msg = f"Failed setting system config '{config_item}' to '{value}'"
1303
+ raise SnapError._from_called_process_error(msg=msg, error=e) from e
1304
+
1305
+
1306
+ def hold_refresh(days: int = 90, forever: bool = False) -> None:
1307
+ """Set the system-wide snap refresh hold.
1308
+
1309
+ Args:
1310
+ days: number of days to hold system refreshes for. Maximum 90. Set to zero to remove hold.
1311
+ forever: if True, will set a hold forever.
1312
+ """
1313
+ if not isinstance(forever, bool): # pyright: ignore[reportUnnecessaryIsInstance]
1314
+ raise TypeError('forever must be a bool')
1315
+ if not isinstance(days, int): # pyright: ignore[reportUnnecessaryIsInstance]
1316
+ raise TypeError('days must be an int')
1317
+ if forever:
1318
+ _system_set('refresh.hold', 'forever')
1319
+ logger.info('Set system-wide snap refresh hold to: forever')
1320
+ elif days == 0:
1321
+ _system_set('refresh.hold', '')
1322
+ logger.info('Removed system-wide snap refresh hold')
1323
+ else:
1324
+ # Currently the snap daemon can only hold for a maximum of 90 days
1325
+ if not 1 <= days <= 90:
1326
+ raise ValueError('days must be between 1 and 90')
1327
+ # Add the number of days to current time
1328
+ target_date = datetime.now(timezone.utc).astimezone() + timedelta(days=days)
1329
+ # Format for the correct datetime format
1330
+ hold_date = target_date.strftime('%Y-%m-%dT%H:%M:%S%z')
1331
+ # Python dumps the offset in format '+0100', we need '+01:00'
1332
+ hold_date = f'{hold_date[:-2]}:{hold_date[-2:]}'
1333
+ # Actually set the hold date
1334
+ _system_set('refresh.hold', hold_date)
1335
+ logger.info('Set system-wide snap refresh hold to: %s', hold_date)