Skip to content

feat: support charmcraft.yaml format as meta for testing.Context#2296

Open
james-garner-canonical wants to merge 20 commits intocanonical:mainfrom
james-garner-canonical:26-01+feat+support-charmcraft-yaml-as-meta-for-testing-context
Open

feat: support charmcraft.yaml format as meta for testing.Context#2296
james-garner-canonical wants to merge 20 commits intocanonical:mainfrom
james-garner-canonical:26-01+feat+support-charmcraft-yaml-as-meta-for-testing-context

Conversation

@james-garner-canonical
Copy link
Contributor

@james-garner-canonical james-garner-canonical commented Jan 30, 2026

This PR adds support for providing charmcraft.yaml formatted data to the meta argument of testing.Context.

Initially I implemented Dima's suggestion for how to process testing.Context(..., meta=<my charmcraft.yaml dict>), which is to get 'actions' and 'config' from 'meta' if they're not otherwise specified. However, this leads to some unexpected behaviour where the charm name is not automatically derived in the case where we have meta={'actions': {...}}, breaking symmetry with the handling of meta=None, actions={...}.

To address this, this PR switches from holding onto the actual dict objects provided as arguments, and instead works with deep copies. This allows us to pop 'actions' and 'config' without unexpected side effects for the caller, restoring symmetry with the behaviour when actions and config are provided separately.

This also eliminates any odd behaviour that might arise from the caller mutating the meta dict after passing it to Context. This is arguably a breaking change, although manipulating testing behaviour in this way would be very much not recommended.

Resolves #1424


Open question: Currently actions= silently takes precedence (though 'actions' is still dropped from meta before passing it on to the _CharmSpec, and if this leaves meta empty, we set a default charm name as we would otherwise). Should it instead be an error to provide (e.g.) 'actions' in meta and also provide the actions= argument? I can't think of when you'd want the silently override behaviour, but it's more backwards compatible with current behaviour.

@james-garner-canonical james-garner-canonical force-pushed the 26-01+feat+support-charmcraft-yaml-as-meta-for-testing-context branch from 6b900f1 to 488c5fa Compare February 5, 2026 23:47
@james-garner-canonical james-garner-canonical marked this pull request as ready for review February 6, 2026 03:02
Copy link
Contributor

@dimaqq dimaqq left a comment

Choose a reason for hiding this comment

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

Looks good!



CONFIG: Mapping[str, Any] = {
'options': {'perambulator-abbreviation': {'type': 'string', 'default': 'pram'}}
Copy link
Contributor

Choose a reason for hiding this comment

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

😆

:arg charm_type: the :class:`ops.CharmBase` subclass to handle the event.
:arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict).
If none is provided, we will search for a ``metadata.yaml`` file in the charm root.
Alternatively, can be a full ``charmcraft.yaml`` dict, in which case ``config`` and
Copy link
Contributor

Choose a reason for hiding this comment

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

The docstring update makes sense.

I wonder if there are some other doc places to update though.
Would you mind going through sections like "how to write unit tests" to see if something can be simplified with the new way?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Our how to guides assume these arguments are left empty and the data is searched for in the appropriate files in the charm root.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking about examples like:
Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'})

That alone seems fine, but if there was meta=..., config={...}, then we'd probably want to update that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

None of our examples appear to use both meta and config, and it seems that none of them use actions. For the ones that only use config, I think leaving them as config={...} is nicer than changing to. meta={'config': {...}}.

james-garner-canonical and others added 12 commits February 13, 2026 12:29
Co-authored-by: Dima Tisnek <dimaqq@gmail.com>
This PR drops Scenario's build dependency on
[setuptools_scm](https://pypi.org/project/setuptools-scm/), which we
don't need, as it's for versioning a package based on git
tags. This resolves the current build issue, example
identified by David
[here](https://github.com/canonical/operator/actions/runs/21809969678/job/62920050703#step:5:22).
The build issue can be reproduced locally on `main` with `uv build
--all`. I've verified that the build succeeds with `setuptools_scm`
removed. The root cause of why builds started failing isn't clear
though.
The Juju `credential-get` hook command is available for K8s models as of
Juju 3.6.10, so adjust Scenario's consistency checker to match.

Fixes canonical#2304
This is an automated PR to update the best practices documentation.

Co-authored-by: David Wilding <david.wilding@canonical.com>
This PR prepares the release of version 3.5.2.

---------

Co-authored-by: Tony Meyer <tony.meyer@gmail.com>
This PR updates the version files after the release.
Bumps `cryptography` from 46.0.2 to 46.0.5 to address [this security
issue](https://github.com/canonical/operator/security/dependabot/23).

The package is used by `pyjwt`, which is used by the release script, so
we aren't really exposed by this issue, but it's simple to pull in the
latest fixes and clear the security warning.
The 2.23 maintenance branch does not have the versions.md doc. Handle
that when releasing.
This PR splits [How to run workloads with a Kubernetes
charm](https://documentation.ubuntu.com/ops/latest/howto/run-workloads-with-a-charm-kubernetes/)
into a subcategory of the how-to guides. I've redistributed all the
content without making significant changes. We can do further
improvements in follow-on PRs.

Main parts to focus on reviewing:

- The introductory descriptions on [Manage
containers](https://canonical-ubuntu-documentation-library--2309.com.readthedocs.build/ops/2309/howto/manage-containers/)
and the scope of each page in that subcategory. I moved "Manage Pebble
metrics" into this subcategory because I feel it fits better here,
rather than as a top-level how-to guide (this move isn't in the plan I
prepared internally).

- The same descriptions at [How-to guides > Managing
containers](https://canonical-ubuntu-documentation-library--2309.com.readthedocs.build/ops/2309/howto/#managing-containers).
I'm copying the approach of "Legacy guides" - listing each of the pages
in the subcategory instead of only listing the subcategory itself. I'm
open to changing the approach if we don't like the duplication.

I also fixed `test_add_layer()` in [How-to guides > Manage the workload
container > Write unit
tests](https://canonical-ubuntu-documentation-library--2309.com.readthedocs.build/ops/2309/howto/manage-containers/manage-the-workload-container/#write-unit-tests).
The test previously defined `container` but used `container_in`. I've
switched the test to use `container_in` and `container_out` for clarity.
…nical#2315)

Fixes canonical#2312 

When `ops.hookcmds` has landed, we forgot to add the submodule to the
setuptools config in pyproject.toml and now we get warnings. The PR
addresses that.
As decided at the standup, bumping the default Juju version in Scenario
Context to the version of the current Juju LTS, 3.6.14.
Merge branch 'main' into 26-01+feat+support-charmcraft-yaml-as-meta-for-testing-context
Comment on lines +688 to +690
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
Copy link
Contributor

Choose a reason for hiding this comment

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

Given that we only pop two top-level keys from meta, why deepcopy?
Also, why copy config and actions, these don't seem to be modified.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thinking is that since we definitely need a copy if we're going to pop, why not go all the way and only hold onto copies, preventing any future errors or unintended interactions stemming from the same underlying issue.

Copy link
Contributor

Choose a reason for hiding this comment

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

So it's a drive-by change.
I think a stronger justification is needed.

Copy link
Contributor Author

@james-garner-canonical james-garner-canonical Feb 17, 2026

Choose a reason for hiding this comment

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

Holding onto an internal reference to the actual dictionary object passed by the caller makes both user code and our internal code error prone. This is highlighted by the fact that it's unsafe for us to pop from it as-is. It's true that we could try to preserve the problem in this PR, but why?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree that it would be cleaner if we broke the link, whether that's by a deep copy or other means (probably it has to be a deep copy at some point, since we use it for things like network-get mocking). However, I also agree that it doesn't really fit with this PR.

My suggestion is that we have a separate PR that does do a deep copy at this point in the code (I think a deep copy is probably simpler and more correct than any of the other options), merge that, and then do this PR.

Copy link
Contributor

@dimaqq dimaqq left a comment

Choose a reason for hiding this comment

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

Let's discuss 3x deepcopy.

Comment on lines +688 to +700
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta_config = meta.pop('config', None)
if config is None:
config = meta_config
meta_actions = meta.pop('actions', None)
if actions is None:
actions = meta_actions

Copy link
Contributor

Choose a reason for hiding this comment

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

Option 1:

Suggested change
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta_config = meta.pop('config', None)
if config is None:
config = meta_config
meta_actions = meta.pop('actions', None)
if actions is None:
actions = meta_actions
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta = {**meta}
meta_config = meta.pop('config', None)
if config is None:
config = meta_config
meta_actions = meta.pop('actions', None)
if actions is None:
actions = meta_actions

Option 2:

Suggested change
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta_config = meta.pop('config', None)
if config is None:
config = meta_config
meta_actions = meta.pop('actions', None)
if actions is None:
actions = meta_actions
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta_config = meta.get('config')
meta_actions = meta.get('actions')
# Optional, I think CharmSpec should be doing this instead:
meta = {k: v for k, v in meta.items if k not in ("config", "actions")}
if config is None:
config = meta_config
if actions is None:
actions = meta_actions

Option 3, if CharmSpec does the "right thing":

Suggested change
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
meta_config = meta.pop('config', None)
if config is None:
config = meta_config
meta_actions = meta.pop('actions', None)
if actions is None:
actions = meta_actions
# Extract config and actions from meta if it contains them (e.g., charmcraft.yaml)
# and they were not explicitly provided
if meta is not None:
if config is None:
config = meta.get('config')
if actions is None:
actions = meta.get('actions')

Doesn't that look so much simpler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

_CharmSpec just holds onto references to the three dictionaries.

The dicts are then used via _CharmSpec by the Scenario machinery to check consistency, write the metadata files to the virtual charm root, etc., or by user-facing methods like generating a State from a Context.

This is why I'm proposing that we should work with copies instead of the objects the user passes in.


Re: Options 1 and 2, either of those shallow copy based approaches would do the job for this PR's feature. I just think it would be worth making deep copies instead to remove the underlying problem that requires us to have to think about making copies here once and for all.

Copy link
Collaborator

@tonyandrewmeyer tonyandrewmeyer left a comment

Choose a reason for hiding this comment

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

I feel like it may be better to raise an error if there's an actions/config dict provided both in the meta argument and separately, rather than let one of them 'win'.

I wonder if we should add an example somewhere in the documentation (maybe in one of the library docs that isn't going to be moved to charmlibs?). In most cases, charms are ideally never using this and always auto-spec'ing. However, if they do use it, I would rather we encourage:

ctx = Context(MyCharm, charmcraft_dict)

rather than

ctx = Context(MyCharm, meta=charmcraft_dict)

And reserve the meta= keyword passing for when it's specifically the metadata dict.

I haven't tried running this since it's unclear what the exact implementation of the core piece will be, but I'm happy to approve trusting that it'll work at that point.


def test_init_with_bad_meta():
ctx = Context(MyCharm, meta={'a truth universally acknowledged': 'it'})
with pytest.raises((UncaughtCharmError, KeyError)):
Copy link
Collaborator

Choose a reason for hiding this comment

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

With #2314 coming, it's probably easier to preemptively use that approach here.

:arg actions: charm actions to use. Needs to be a valid actions.yaml format (as a dict).
If none is provided, we will search for a ``actions.yaml`` file in the charm root.
If none is provided, will be extracted from ``meta`` if present.
Otherwise, if no meta, actions, or config were provided, will be loaded from the
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice update fixing these to mention charmcraft.yaml in the autospec mode.

Comment on lines +688 to +690
meta = copy.deepcopy(meta)
config = copy.deepcopy(config)
actions = copy.deepcopy(actions)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree that it would be cleaner if we broke the link, whether that's by a deep copy or other means (probably it has to be a deep copy at some point, since we use it for things like network-get mocking). However, I also agree that it doesn't really fit with this PR.

My suggestion is that we have a separate PR that does do a deep copy at this point in the code (I think a deep copy is probably simpler and more correct than any of the other options), merge that, and then do this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for passing charmcraft.yaml as metadata to testing.Context

5 participants