-
Notifications
You must be signed in to change notification settings - Fork 125
feat: support for generating charmcraft YAML from Python classes #1975
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
… don't catch the error themselves.
…break the config into multiple classes.
…if the secret isn't available, but avoids Juju calls when loading config.
In #1975, we'd like to include a couple of docstrings. This PR clears the way for that, as well as any other doctests we wish to use in the future. The PR tweaks some docstrings that are in private methods, so not in in our reference documentation. In addition: * `ActionEvent.set_results` changes from a block of `>>>` text to a set of bullet points. If this was doctest'ed then it'd actually call the method, but doing that with `set_results` itself would be complicated. This seems just as clear to me. * `pebble.Client.exec` has a couple of lines to disable doctest. This could be a doctest, but that would require a real Pebble, which we currently split off into a different set of tests. This is a bit ugly, but putting `doctest: +SKIP` on most of the lines is uglier (in my opinion), and I don't think there's a cleaner way to skip a single docstring. --------- Co-authored-by: Dave Wilding <tech@dpw.me>
james-garner-canonical
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Partial review for now.
| ] | ||
|
|
||
| [project.scripts] | ||
| update-charmcraft-schema = "ops_tools._update_charmcraft_yaml:main" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name is a bit confusing to me. I also wonder if subcommands might be a nicer way to handle this as we add more commands. How about if the script is ops-tools, and the functionality added in this PR is under ops-tools generate-metadata or similar?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not that keen on having things as sub-commands under a single ops-tools command. For one thing, I don't like the duplication:
uvx --from=ops-tools ops-tools generate-metadata
But also I feel like ops-tools is meant to be a collection of tools, not a single tool with multiple subcommands.
However, I haven't finished going through the CLI spec in detail, so maybe it has recommendations here that should be followed.
I would like to resolve the comment around how this should work (just a diff/lint, merging, replacing) before locking down what the command looks like and is named.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re: duplication, you shouldn't need any if the command name is the same as the package name, right? Just uvx ops-tools generate-metadata. Good call on figuring out the rest of the commandline interface first though, I agree that's more important, and I have some thoughts there too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving aside subcommands, since with uv that just means +/- the --from flag, I think the existing interface is nice overall. The main parts I find confusing are what will actually happen with the content we generate.
I'd suggest using mutually exclusive groups for the output options, and defaulting to a non-destructive output method like writing the generated content to stdout. How about:
--update(current behaviour, write to existing file)--diff(current option)--output=FILE(default behaviour, with-for stdout)
I'd also be happy with a--stdoutflag instead of--output=if that's simpler.
The --merge flag option had me a little confused too, I thought it was the option that turned on the --update behaviour, but that's (currently) unconditional. Instead, it controls whether to keep actions/options that are in the charmcraft.yaml file but not present in our generated output, right? In that case, how about --delete / --no-delete flags for this (borrowing rsync semantics)?
Regarding the name, how about update-charmcraft-yaml instead? The term charmcraft schema seems a little off somehow. All together that gives us:
uvx {--from} ops-tools update-charmcraft-yaml
--path=... # This might be nice as an optional positional argument instead?
--config=... ...
--action=... ...
[--delete / --no-delete (default)]
[--update / --diff / --output='-' (default)]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It also seems like it would be nice to try this out without consuming a charmcraft.yaml file at all. What do you think about additional entry points (scripts or subcommands) with much simpler interfaces that just write the actions/config options to stdout?
uvx {--from} ops-tools generate-actions CLASS...
uvx {--from} ops-tools generate-config CLASS...
I'd suggest writing in the actions.yaml and config.yaml format, but there could always be a flag added to control that.
| # The many type: ignores are required because we don't want to import | ||
| # pydantic in this code. | ||
| options: dict[str, OptionDict] = {} | ||
| for name, field in cls.model_fields.items(): # type: ignore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is as far as I've gotten in my review so far. I'm wondering here if we can reuse any of the logic already implemented in ops for extracting fields from classes, e.g. ops.charm._juju_fields.
Co-authored-by: James Garner <james.garner@canonical.com>
Co-authored-by: James Garner <james.garner@canonical.com>
Co-authored-by: James Garner <james.garner@canonical.com>
| if hasattr(field.default, 'default_factory') | ||
| else field.default_factory | ||
| ) | ||
| # A hack to avoid importing Pydantic here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if ops-tools just depended on Pydantic? We don't want to add a heavy dependency to ops itself, but would this all be a lot easier if we included it in ops-tools?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm pretty strongly opposed to pulling Pydantic in, even for ops-tools. It would be a bit easier, but not a lot, I feel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
May I ask why -- is it the extra runtime overhead for users?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've left some major and minor comments.
Major: I think the CLI interface is pretty nice, but what actually happens with the output could be made more clear. I'd like to see non-destructive default behaviour by default, and I'd also like to be able to dump a Python class to a Juju action/config definition without worrying about charmcraft.yaml at all.
More minor: while reading through the code, I've left a number of suggestions that I think might make some of the logic easier to follow. However ...
Big picture questions:
- How much simpler would interacting with Pydantic model classes be if
ops-toolscould just import Pydantic?- Additionally, if we're importing a module that defines a Pydantic class, won't we have to have Pydantic installed anyway?
- We could either add Pydantic to our own deps
- Or if we're relying on the user doing
--with=pydanticor something, then we could move our Pydantic handling logic to a module that importspydanticand only import that module as needed. This would have the benefit of letting us fudge around the Pydantic version, but seems more complicated.
- I may be wrong about this, but my impression is that we're doing similar class traversal logic in a few different places for different purposes. This is some of the trickier code to follow imo. Would it be worthwhile to centralise this logic and parse the classes we're consuming into an intermediate data structure that has everything we want represented nicely?
- I also had kind of assumed that the core class parsing logic would be in
ops(internals), and we'd use it inops-tools, since we already parse classes for loading actions/config/relation data inops. That wouldn't work if we went down the route of using Pydantic here, but perhaps something to keep in mind.
- I also had kind of assumed that the core class parsing logic would be in
Co-authored-by: James Garner <james.garner@canonical.com>
dimaqq
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First round of reviews.
| last_attr = None | ||
| for child in node.body: | ||
| if isinstance(child, (ast.Assign, ast.AnnAssign)): | ||
| target = None # Make the type checker happy. | ||
| if isinstance(child, ast.Assign): | ||
| target = child.targets[0] | ||
| elif isinstance(child, ast.AnnAssign): | ||
| target = child.target | ||
| assert isinstance(target, ast.Name) | ||
| last_attr = target.id |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer to see unit tests for this class, the top-level function below in addition to the "functional" tests for YAML generation.
I've been bitten by AST before 🐍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ouch I left this comment "Pending" on my side for three weeks!
| with open(args.charmcraft_yaml, 'w') as raw: | ||
| yaml.safe_dump(charmcraft_yaml, raw) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we want folks to use this feature, we could either:
- advocate that charmers abandon separate config/actions/metadata.yaml (my pref), or
- support updating both charmcraft.yaml and metadata.yaml, and autodetect which is present
P.S. I'm all for updating "in place", however we must use a round-trip safe yaml parser/serialiser and have tests to prove it, including YAML with comments in "inconvenient" places. I've run into that with the charm pin updater script, there are plenty of corner cases when key or content is interspersed with comments.
| default = None | ||
| for field in dataclasses.fields(cls): | ||
| if field.name != name: | ||
| continue | ||
| break | ||
| else: | ||
| return default |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My 2c: if dataclasses doesn't provide a dict, a helper method that builds a dict is the most natural way to go.
| exit_code += 2 | ||
| sys.exit(exit_code) | ||
|
|
||
| if args.merge and 'config' in charmcraft_yaml: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: Is this for 12-factor charms?
Personally I would like to avoid merge like a plague of rats gorged on plagued infested locusts who ate all the plagued corn...
12F could be solved by a stub / base class for options.
| config = charmcraft_yaml['config']['options'].update(config['options']) | ||
| raw_yaml = _insert_into_charmcraft_yaml(raw_yaml, 'config', {'config': config}) | ||
| if actions: | ||
| if args.merge and 'actions' in charmcraft_yaml: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there unit tests and functional / test vector tests for the merge flag?
| generated_schema = ops_tools.config_to_juju_schema(config_class) | ||
| expected_schema = { | ||
| 'options': { | ||
| 'basic-bool': { | ||
| 'type': 'boolean', | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is great, but omits YAML formatting.
There needs to be a test for verbatim YAML output.
I'm thinking a stability test perhaps (we may change the code, but the output keeps same "canonical" indent).
| actions: | ||
| run-backup: | ||
| description: Backup the database. | ||
| params: | ||
| filename: | ||
| type: string | ||
| description: The name of the backup file. | ||
| compression: | ||
| type: string | ||
| description: The type of compression to use. | ||
| default: gzip | ||
| enum: [gzip, bzip2] | ||
| required: [filename] | ||
| additionalProperties: false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to see a test that looks exactly like this.
|
this brings back some memories: https://github.com/PietroPasotti/jinx |
dimaqq
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Partial review
| # during unit tests, and test_main failures that subprocess out are often | ||
| # difficult to debug. Uncomment this line to get more informative errors when | ||
| # running the tests. | ||
| # When uncommented the test_hook_and_dispatch_with_failing_hook test will fail. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit could be perhaps written as "test expected to fail if uncommented".
| # TODO: ideally, we would look for ConfigBase classes in the charm | ||
| # module and autoload from there at this point. Leaving this until the | ||
| # conversation about if & how the generation is done is resolved. | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not getting the point of this comment.
Specifically, if I were to read it a few years later, I would be missing the context.
Could future work be tracked in GitHub issues or Jira tickets or roadmap items instead?
| framework.observe(self.on.config_changed, self._on_config_changed) | ||
|
|
||
| def _on_config_changed(self, event: ops.ConfigChangedEvent): | ||
| self.typed_config = self.load_config(Config, 10, c='foo') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question this is unexpected to me.
I would have thought that default are specified in a model, not in the load config call site.
The defaults can be included in both dataclasses and pydantic models, so why add the extra arguments feature in the load_config method?
P.S. maybe I was lazy reading the spec, but somehow I don't recall this API 🙈
P.P.S. I guess it's because Juju action parameters don't have defaults like Juju config does, isn't it?
That also means that we should be testing both required and non-required parameters somewhere... perhaps a negative test in Scenario?
| my_str: str = 'foo' | ||
| """A string value.""" | ||
|
|
||
| my_secret: ops.Secret | None = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issues
What are the semantics for optional-Secret-typed fields?
I guess if a value is passed, the a secret with this id (not label) must exist, or load_config() fails.
And I guess on error, same errors = raise|blocked applies, doesn't it?
And if nothing is passed (None, not empty string), then field if None.
This needs to be documented somewhere.
The spec describes Secret fields, but not optional Secret fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implementation remains the same, doesn't it?
option_type = self.meta.config.get(key)
# Convert secret IDs to secret objects. We create the object rather
# that using model.get_secret so that it's entirely lazy, in the
# same way that SecretEvent.secret is.
if option_type and option_type.type == 'secret':
assert isinstance(value, str) # Juju will have made sure of this.
value = model.Secret(
backend=self.model._backend,
id=value,
_secret_set_cache=self.model._cache._secret_set_cache,
)
config[attr] = valueThe value seems mandatory?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR introduces a new ops-tools package that provides utilities for generating charmcraft.yaml configuration and action schemas from Python classes. The main contribution enables developers to maintain a single source of truth for charm configuration in Python code rather than duplicating information across Python and YAML files.
Key Changes
- New
ops-toolspackage with schema generation functionality from Python classes (dataclasses, Pydantic models, or plain classes) - Command-line tool
update-charmcraft-schemafor automatedcharmcraft.yamlupdates - Integration with the workspace, build, and release processes
Reviewed Changes
Copilot reviewed 21 out of 24 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
tools/src/ops_tools/_generate_yaml.py |
Core schema generation logic for config and actions |
tools/src/ops_tools/_update_charmcraft_yaml.py |
CLI tool for updating charmcraft.yaml files |
tools/src/ops_tools/_attrdocs.py |
Extracts attribute docstrings from Python classes |
tools/tests_tools/test_generate_yaml_*.py |
Test coverage for schema generation |
uv.lock |
Dependency lock file updates (beautifulsoup4, coverage, lxml, etc.) |
pyproject.toml |
Workspace configuration for new tools package |
release.py |
Release automation for ops-tools |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| action='append', | ||
| help='Python class with optional module path (can be specified multiple times). ' | ||
| 'For example, "src.config:Config". The module defaults to "src.charm".' | ||
| 'The class may be a regular expression.', |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing space after the period. Should be 'The module defaults to "src.charm". The class may be a regular expression.'
| 'The class may be a regular expression.', | |
| ' The class may be a regular expression.', |
| 'For example, "src.backup:BackupAction". The module defaults to "src.charm".' | ||
| 'The class may be a regular expression.', |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing space after the period. Should be 'The module defaults to "src.charm". The class may be a regular expression.'
|
Tracking issue: #2163 |
A new package,
ops-toolsis added. This currently only provides a script to updatecharmcraft.yamlbased on config and action Python classes, but is designed to also offer other tools in the future. It is released simultaneously with the otherops-*packages, but is not required by any of the others, or offered as an extra.Charmers can use the exported functions (which take a Python class object and return a dictionary suitable for serialisation to YAML), but are expected to use the provided script (that works with a
charmcraft.yamlfile and Python modules).Classes can provide a
to_juju_schema()method if they need to provide YAML in a different way. The script will pass the generated config/action as a base to optionally work from.The implementation is designed to work with the
load_configandload_paramsfunctionality in ops. In particular, that means working with four types of class:Only reference documentation is included in this PR (Preview). The intention is that there will be a follow-up PR that includes at least how-to documentation.
More details are available in the spec. This PR replaces #1702.