Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6f21289
feat: restore snap conf module
james-garner-canonical Jul 1, 2026
18d31d5
test: value is now a string
james-garner-canonical Jul 1, 2026
6733ac6
test: handle value properly as str
james-garner-canonical Jul 1, 2026
1a15928
chore: correct copyright years
james-garner-canonical Jul 1, 2026
1da87f9
test: test deeply nested key
james-garner-canonical Jul 6, 2026
d3dc3c5
test: combination of dotted and top-level keys
james-garner-canonical Jul 6, 2026
3127e26
docs: update documentation for snap.get result
james-garner-canonical Jul 6, 2026
83d76ba
docs: clarify when OptionNotFoundError is raised
james-garner-canonical Jul 6, 2026
3767de2
test: tighten noop assertion
james-garner-canonical Jul 6, 2026
db8a4c6
fix: remove erroneous comment about unset
james-garner-canonical Jul 6, 2026
9246122
docs: clarify error condition
james-garner-canonical Jul 6, 2026
213385e
docs: add design note comments
james-garner-canonical Jul 6, 2026
88f4548
feat: handle and document snap not installed behaviour in conf
james-garner-canonical Jul 7, 2026
f2787ab
docs: clarify ChangeError behaviour
james-garner-canonical Jul 7, 2026
e33c012
docs: correct comment
james-garner-canonical Jul 7, 2026
583597f
test: increase functional test coverage of system snap conf
james-garner-canonical Jul 7, 2026
0343fda
test: consolidate snap conf tests
james-garner-canonical Jul 8, 2026
0163d10
test: expand snap conf test coverage
james-garner-canonical Jul 9, 2026
425e549
docs: correct function names
james-garner-canonical Jul 16, 2026
12e44ff
feat: switch from *keys to keys, nail down edge cases
james-garner-canonical Jul 16, 2026
6ad5731
test: update unit and functional tests accordingly
james-garner-canonical Jul 16, 2026
2062476
docs: correct comment
james-garner-canonical Jul 16, 2026
ce0733e
chore: merge main
james-garner-canonical Jul 16, 2026
1945c70
test: use a lighter weight snap
james-garner-canonical Jul 16, 2026
8742196
test: cover deeply nested non-existent key
james-garner-canonical Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion snap/src/charmlibs/snap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

- Automatic refreshes with :func:`hold` and :func:`unhold`.
- Services with :func:`start`, :func:`stop`, and :func:`restart`.
- Config with :func:`config_get`, :func:`config_set`, and :func:`config_unset`.
- Config with :func:`get`, :func:`set`, and :func:`unset`.
- Connections between snaps with :func:`connect` and :func:`disconnect`.
- Application aliases with :func:`alias` and :func:`unalias`.

Expand Down Expand Up @@ -68,6 +68,11 @@
start,
stop,
)
from ._snapd_conf import (
get,
set, # noqa: A004 (shadowing a Python builtin)
Comment thread
tonyandrewmeyer marked this conversation as resolved.
unset,
)
from ._snapd_logs import (
LogEntry,
logs,
Expand Down Expand Up @@ -101,14 +106,17 @@
'TimeoutError',
'ensure',
'ensure_revision',
'get',
'hold',
'info',
'install',
'logs',
'refresh',
'remove',
'restart',
'set',
'start',
'stop',
'unhold',
'unset',
]
6 changes: 5 additions & 1 deletion snap/src/charmlibs/snap/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ class AppNotFoundError(APIError):


class NotFoundError(APIError):
"""Raised via the API when a snap is not found in the store."""
"""Raised via the API when a snap is not found.

Depending on the operation, this means not found in the store (for example install),
or not installed on the system (for example configuration operations).
"""


class NotInstalledError(APIError):
Expand Down
164 changes: 164 additions & 0 deletions snap/src/charmlibs/snap/_snapd_conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Snap operations implemented as direct calls to the snapd REST API."""

from __future__ import annotations

import logging
import typing

from . import _client, _errors

if typing.TYPE_CHECKING:
from collections.abc import Iterable
from typing import Any

logger = logging.getLogger(__name__)


# /v2/snaps/{snap}/conf


# Getting one config value looks like get(s, [k])[k]. In future we could add a get_one(s) helper.
# Get with keys=None returns the entire config, following the CLI (get_all is unnecessary).
def get(snap: str, keys: Iterable[str] | None = None) -> dict[str, Any]:
"""Get snap configuration.

Args:
snap: The name of the snap to read configuration from.
keys: Configuration keys to read. Nested options may be accessed with dotted notation,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we maybe already had this conversation, but I don't remember the conclusion or know how to go find it, so will possibly repeat.

I'm unsure about snap.get('mysnap', 'config.engine', 'config.user', 'cache'). I think if it was a class or partial, like mysnap.get or mysnap_get with an arbitrary number of config names I'd like it more. Maybe this will never gain more arguments, but if it does, then knowing at which point the regular args stop and config names start gets even worse. There's also a mismatch with set, which takes a dict rather than kwargs (to handle dots, I assume).

I lean towards a dict here too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we've talked about this before. I definitely see the ugliness you're highlighting with snap.get('mysnap', 'config.engine', 'config.user', 'cache'). For very long lists, I think it would be most common for them to be stored in a variable somewhere, so I think there's a natural limit where this becomes snap.get('mysnap', *mysnap_keys) based on each user's preferences.

Maybe this will never gain more arguments, but if it does, then knowing at which point the regular args stop and config names start gets even worse.

The function will never be able to grow any more positional arguments without breaking backwards compatibility, so any additional arguments would be keyword only. It would be snap.get('mysnap', 'config.engine', 'config.user', 'cache', foo=bar), which I think is easy to tell where the keys stop.

I lean towards a dict here too.

I don't think a dict makes sense since the leaf values don't mean anything, so there's inevitably some asymmetry with snap.set. It's also nice to avoid having to manually flatten the dict before passing the keys to the API to handle nested (dotted) keys expressed as {'config': {'engine': None, 'user': None}}.

It could be a Sequence[str] or Iterable[str], called like snap.get('mysnap', ['config.engine', 'config.user', 'cache']). I think this looks quite nice, but it introduces three warts:

  1. A str is an Iterable[str] and a Sequence[str] too, so the type checker won't reject snap.get('mysnap', 'config.engine'). So we need to do something with that at runtime, even if it's just raising a TypeError or ValueError.
  2. Calling with a single argument is a tiny bit uglier with this nested rather than flat design: snap.get('mysnap', ['config.engine']).
  3. snap.get('mysnap', []) means "get me all the keys", which feels kind of weird.

If we disregard 2, then we could avoid 1 by requiring some concrete types, like list[str] or tuple[str], but that doesn't feel super Pythonic.

We could resolve both at once by explicitly allowing str, and special casing like keys = [keys] if isinstance(keys, str) else keys. There are a few points that pushed me towards *keys instead:

  • simplicity of implementation (no special case)
  • honesty of argument name: keys being a slight lie as an argument name since it can be a single flat key (not even a sequence of one key)
  • clarity at call site when getting all keys: snap.get('mysnap', *[]) is also valid "get me all the keys" with the *keys approach, but it's more obviously "no arguments" than "one empty list argument".

Let me know what you think.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh also, this ties into the unset safety: we require at least one key by using key: str, /, *keys: str. If we switched to a sequence here, we'd want to do that for consistency in unset. So the 'at least one key' safety would move from being statically and runtime enforced by the signature itself, to a runtime only check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant list (or sequence, really) not dict, sorry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function will never be able to grow any more positional arguments without breaking backwards compatibility,

I assume this is because snap config names can be any valid Python argument name (or even others). Honestly, this makes it feel even ickier that it's the way it is.

  1. A str is an Iterable[str] and a Sequence[str] too, so the type checker won't reject snap.get('mysnap', 'config.engine'). So we need to do something with that at runtime, even if it's just raising a TypeError or ValueError.

Fair, although this is super common and we already deal with this exact problem in other places.

  1. Calling with a single argument is a tiny bit uglier with this nested rather than flat design: snap.get('mysnap', ['config.engine']).

I'll give you a bit uglier, but I would argue a tiny bit clearer.

  1. snap.get('mysnap', []) means "get me all the keys", which feels kind of weird.

This could easily be an error. I agree if it means "all the keys" that's bad.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. snap.get('mysnap', []) means "get me all the keys", which feels kind of weird.

This could easily be an error. I agree if it means "all the keys" that's bad.

In that case, the non-*args version needs a different way of expressing "all the keys".

If we want to retain snap.get('mysnap') having that meaning, then it would have to be the default value. I'd lean towards allowing users to express "all the keys" in a way compatible with snap.get(my_snap, my_keys), so the default shouldn't be a private sentinel but instead something like None. So snap.get('mysnap', None). This at least has the virtue of being a bit more explicit (and hopefully less error-prone) than [] but I still don't love it.

Alternatively, perhaps we should align with unset and have the library shield the user from this change in semantics based on argument count altogether, and have a separate get_all, requiring get to have at least one config name specified. I believe that's best done with the arg, *args pattern.

@tonyandrewmeyer tonyandrewmeyer Jul 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's because of how *args works in Python.

Argh, sorry I was reading this as **keys. I do know how *args works 😄.

Returning to the original point, this means it's always easy to know which arguments are the config keys: all positional arguments other than the first, which (as with all the other functions) is the snap name.

I still feel the explicit iterable is the clearer choice here. It's still having one of the arguments not match the rest that most puts me off. If it was snap_get(*keys) or similar then I think it would be ok (although I'm still not sold on that).

I'm not sure "all but the first" is something that is easy to know without seeing the docs or function signature. Just like knowing the order of arguments with positional in many cases is not knowable without that, which is generally an indicator of bad shape.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chatted with Ben and we settled on the following design and semantics:

def get(snap: str, keys: Iterable[str] | None = None) -> dict[str, Any]: ...
# get(mysnap, 'some-string'): TypeError
# get(mysnap) -> full config
# get(mysnap, keys=None) -> full config
# get(mysnap, []) -> {}

I'm a little on the fence about get(mysnap, []) -> {}. The most straightforward implementation, a naive early return, would bypass absent snap errors, which is undesirable, so we have a few options I'd like your opinion on:

  1. Add snap installed check to the early return. Straightforward enough, but complicated a bit by the system snap case (see changes since your last review).
  2. Fetch the full config, passing through the usual error handling paths, and then convert it immediately before return if the original keys argument was falsey-but-not-None.
  3. Condense the installed checks into a single check that we always run, rather than trying to keep the happy path as a single API request.
  4. Reconsider whether we want to second guess the API by making get for a non-installed snap an explicit error, and just follow the API's behaviour of returning {}.

For symmetry, unset should therefore probably have keys: Iterable[str] as well. Not passing any keys doesn't really make any sense, which suggests it should be required and not accept None:

def unset(snap: str, keys: Iterable[str]) -> dict[str, Any]: ...
# unset(mysnap, 'some-string'): TypeError
# unset(mysnap): TypeError

However, we still have to think about the case of the empty iterable. Note that naively unset(snap, []) performs the exact same snapd API operation that set(snap, {}) does -- a PUT with a {} body, so worth figuring out what that means too. The snapd API treats it as a harmless no-op (it's still a request, so it fails on an absent snap, but it doesn't change any config on the happy path, and it also doesn't error if the snap doesn't have a config hook). My instinct is that we should just let the API handle this. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I 'm a little on the fence about get(mysnap, []) -> {}.

Fair. Did you discuss it being an error, like your proposed unset?

  1. Add snap installed check to the early return. Straightforward enough, but complicated a bit by the system snap case (see changes since your last review).

I feel like this would potentially end up messy if there end up being other snap checks needed. But perhaps an issue for another day.

  1. Fetch the full config, passing through the usual error handling paths, and then convert it immediately before return if the original keys argument was falsey-but-not-None.

Convert meaning discard everything and return an empty dict?

  1. Condense the installed checks into a single check that we always run, rather than trying to keep the happy path as a single API request.

I think we could hold off on this and do it later as an optimisation if needed, without changing the API shape, right?

  1. Reconsider whether we want to second guess the API by making get for a non-installed snap an explicit error, and just follow the API's behaviour of returning {}.

Interesting. For the Juju case I assume charms would almost always be doing an ensure in install, unless it was some third-party snap the charm knew would be installed? But it does seem more logical for it to be an error to avoid a caller needing to know.

For symmetry, unset should therefore probably have keys: Iterable[str] as well. Not passing any keys doesn't really make any sense, which suggests it should be required and not accept None:

def unset(snap: str, keys: Iterable[str]) -> dict[str, Any]: ...
# unset(mysnap, 'some-string'): TypeError
# unset(mysnap): TypeError

This looks good to me.

However, we still have to think about the case of the empty iterable. Note that naively unset(snap, []) performs the exact same snapd API operation that set(snap, {}) does -- a PUT with a {} body, so worth figuring out what that means too. The snapd API treats it as a harmless no-op (it's still a request, so it fails on an absent snap, but it doesn't change any config on the happy path, and it also doesn't error if the snap doesn't have a config hook). My instinct is that we should just let the API handle this. WDYT?

Agreed, particularly if get with an empty iterable gives an empty dict.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your comments here. I stuck with [] -> {} and tried to tidy up the error handling.

for example ``['server.port']``. If ``None``, the full config is returned as a
nested dict. If an empty iterable, an empty dict is returned if the snap is installed.
Must not be a bare string.

Returns:
A dict mapping each requested key to its configured value. If all keys are requested
(keys=None), the entire config is returned as a nested dict (empty if the snap has no
configuration). If no keys are requested (keys=[]), an empty dict is returned if the snap
is installed. Each dotted key queried is returned as a top-level entry.

Raises:
TypeError: if ``keys`` is a string (must be a non-string iterable of strings, or ``None``).
NotFoundError: if the snap is not installed. Never raised for ``system`` or ``core``,
whose configuration is served whether or not the core snap is installed.
OptionNotFoundError: if a requested key has no value stored in the snap's configuration.
Snap configuration is schemaless, so snapd does not distinguish between a key the
snap doesn't recognise, a key that was never set, and a key that was unset. Any
defaults a snap applies internally are invisible here unless its configure hook has
stored them with ``snapctl set``.

::

# Full config.
get('foo') # {'server': {'port': 8080}, 'client': {'timeout': 30}}
get('foo', keys=None) # {'server': {'port': 8080}, 'client': {'timeout': 30}}
# Querying specific keys.
get('foo', ['client']) # {'client': {'timeout': 30}}
get('foo', ['client.timeout']) # {'client.timeout': 30}
get('foo', ['server', 'server.port']) # {'server': {'port': 8080}, 'server.port': 8080}
# Querying no keys.
get('foo', keys=[]) # {}
# Invalid keys argument.
get('foo', keys='client') # TypeError
"""
if isinstance(keys, str):
raise TypeError('keys must be an iterable of strings, or None (not a string)')
if keys is not None:
keys = list(keys)
if keys == []:
# NOTE: snapd returns the full configuration if no keys are specified.
# We pass this behaviour through for keys=None, but for keys=[] we return
# an empty dict, since the caller explicitly requested no keys.
_raise_if_snap_not_installed_or_system(snap)
return {}
params = {'keys': ','.join(keys)} if keys is not None else None
try:
config = _client.get(f'/v2/snaps/{snap}/conf', query=params)
except _errors.OptionNotFoundError:
# NOTE: snapd reports option-not-found both for a missing key and for a missing snap.
# The CLI returns 'error: snap "foo" has no "bar" configuration' in both cases.
# For symmetry with PUT (set/unset), we raise NotFoundError here for a missing snap.
# Following PUT, NotFoundError isn't raised for system/core (installed or not).
_raise_if_snap_not_installed_or_system(snap)
raise
if keys is None and not config:
# NOTE: snapd returns {} for an installed snap with no config and for a missing snap.
# The CLI returns 'error: snap "foo" has no configuration' for a missing snap.
# For symmetry with PUT (set/unset), we raise NotFoundError here for a missing snap.
# Following PUT, NotFoundError isn't raised for system/core (installed or not).
_raise_if_snap_not_installed_or_system(snap)
assert isinstance(config, dict)
return typing.cast('dict[str, Any]', config)


def _raise_if_snap_not_installed_or_system(snap: str) -> None:
"""Raise NotFoundError if the snap is not installed, unless it's system/core."""
# NOTE: snapd's conf endpoints treat 'system' as an alias for 'core'.
# System configuration is served whether or not the core snap is installed.
# /v2/snaps/system always 404s (it's a hardcoded alias, not a real snap).
# /v2/snaps/core 404s when the core snap is absent (typical when no other snaps depend on it).
# For the purposes of this module, we can skip snap installed checks for both names.
if snap not in ('system', 'core'):
_client.get(f'/v2/snaps/{snap}')


def unset(snap: str, keys: Iterable[str]) -> None:
"""Unset snap configuration keys.

Unsetting a key that is not currently set is a no-op and does not raise.

Args:
snap: The name of the snap to unset configuration on.
keys: Configuration keys to unset. Nested options may be addressed with dotted
notation, for example ``['server.port']``. Must not be a bare string.
An empty iterable is passed to snapd, where it is treated as a no-op.

Raises:
TypeError: if ``keys`` is a string (must be a non-string iterable of strings).
NotFoundError: if the snap is not installed.
ChangeError: if the snap's configure hook fails. This includes unsetting any
configuration on a snap that does not define a configure hook. A failed change
is rolled back: no key from the request is unset.
"""
if isinstance(keys, str):
raise TypeError('keys must be an iterable of strings (not a string)')
# NOTE: snap-not-found is returned for a missing snap, but not for system or core,
# even if the core snap isn't installed -- configuration changes are still applied.
# NOTE: Unset with no keys is a no-op (like set with an empty dict). We let snapd handle it.
_client.put(f'/v2/snaps/{snap}/conf', body=dict.fromkeys(keys))


# Defined last to minimise the chance of meaningfully shadowing the built-in set type.
def set(snap: str, config: dict[str, Any]) -> None: # noqa: A001 (shadowing a Python builtin)
"""Set snap configuration.

Args:
snap: The name of the snap to configure.
config: A mapping of configuration keys to values. Values may be any JSON-serialisable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if people would want to be able to pass in either pre-JSON'd content or a serialiser. For things like datetimes (maybe those are not often config values?). But I guess that would be easy enough to add in the future.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing a serializer argument would be easy enough (though we'd have to wire it through several layers of client code). Passing a pre-JSON'd string is something I hadn't considered. Currently users can just serialize specific values as strings if needed (e.g. datetimes). I'm hopeful that regular Python values are sufficient for most cases, but this might warrant some investigation.

type, including nested dicts and lists. Setting a key to ``None`` unsets it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we have this conversation before too. We have this elsewhere in charming, too. I'm not a big fan, even though it's just exposing the snapd behaviour. I would rather people explicitly unset. I guess it's better to leak the underlying behaviour than to prevent people using it by searching for Nones and raising.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we've talked deeply about this. I think None removing is fairly well-known by our users, and the symmetry with Python's get on the result side seems reasonable. It's probably also convenient if you want to build up a config and set it all in one go (without a separate unset call), though maybe that's a pattern you'd argue for discouraging?

I lean towards letting the behaviour leak through like this for simplicity of implementation and stick with the philosophy of staying close to the API. As you say, searching for None and raising doesn't seem like a good approach.

Nested options may be addressed with dotted keys, for example ``server.port``.
An empty mapping is accepted as a no-op.

Raises:
NotFoundError: if the snap is not installed.
ChangeError: if the snap's configure hook fails. This includes setting any
configuration on a snap that does not define a configure hook, and configuration
rejected by a validating configure hook. A failed change is rolled back: no key
from the request is applied.
"""
# NOTE: snap-not-found is returned for a missing snap, but not for system or core,
# even if the core snap isn't installed -- configuration changes are still applied.
# NOTE: Set with an empty dict is a no-op. We let snapd handle it.
_client.put(f'/v2/snaps/{snap}/conf', body=config)
1 change: 1 addition & 0 deletions snap/tests/functional/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ SNAPS_DIR="tests/functional/snaps"
mksquashfs "$SNAPS_DIR/test-snap-1.0" "$SNAPS_DIR/test-snap_1.0.snap" -noappend -comp xz -quiet
mksquashfs "$SNAPS_DIR/test-snap-2.0" "$SNAPS_DIR/test-snap_2.0.snap" -noappend -comp xz -quiet
mksquashfs "$SNAPS_DIR/test-classic-snap-1.0" "$SNAPS_DIR/test-classic-snap_1.0.snap" -noappend -comp xz -quiet
mksquashfs "$SNAPS_DIR/test-configure-snap-1.0" "$SNAPS_DIR/test-configure-snap_1.0.snap" -noappend -comp xz -quiet
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh -e
# Validating configure hook: rejects any value for 'bad-key', accepts everything else.
# Used by functional tests to exercise how snaps reject configuration at set time.
bad="$(snapctl get bad-key)"
if [ -n "$bad" ]; then
echo "bad-key is not allowed" >&2
exit 1
fi
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: test-configure-snap
version: "1.0"
summary: Snap with a validating configure hook for charmlibs.snap functional tests
confinement: strict
base: core24
Loading
Loading