Skip to content

feat: support for generating charmcraft YAML from Python classes#1975

Draft
tonyandrewmeyer wants to merge 98 commits intocanonical:mainfrom
tonyandrewmeyer:config-schema
Draft

feat: support for generating charmcraft YAML from Python classes#1975
tonyandrewmeyer wants to merge 98 commits intocanonical:mainfrom
tonyandrewmeyer:config-schema

Conversation

@tonyandrewmeyer
Copy link
Copy Markdown
Collaborator

@tonyandrewmeyer tonyandrewmeyer commented Aug 11, 2025

A new package, ops-tools is added. This currently only provides a script to update charmcraft.yaml based on config and action Python classes, but is designed to also offer other tools in the future. It is released simultaneously with the other ops-* 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.yaml file 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_config and load_params functionality in ops. In particular, that means working with four types of class:

  • A generic Python class (no inheritance other than object).
  • Standard library dataclasses (I personally believe these are the best choice for almost all cases).
  • Pydantic dataclasses
  • Pydantic 2.x BaseModel classes

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.

Comment thread tools/src/ops_tools/_update_charmcraft_yaml.py Outdated
Comment thread tools/src/ops_tools/_update_charmcraft_yaml.py Outdated
if hasattr(field.default, 'default_factory')
else field.default_factory
)
# A hack to avoid importing Pydantic here.
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.

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?

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'm pretty strongly opposed to pulling Pydantic in, even for ops-tools. It would be a bit easier, but not a lot, I feel.

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.

May I ask why -- is it the extra runtime overhead for users?

Copy link
Copy Markdown
Contributor

@james-garner-canonical james-garner-canonical left a 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:

  1. How much simpler would interacting with Pydantic model classes be if ops-tools could 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=pydantic or something, then we could move our Pydantic handling logic to a module that imports pydantic and only import that module as needed. This would have the benefit of letting us fudge around the Pydantic version, but seems more complicated.
  2. 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 in ops-tools, since we already parse classes for loading actions/config/relation data in ops. That wouldn't work if we went down the route of using Pydantic here, but perhaps something to keep in mind.

Co-authored-by: James Garner <james.garner@canonical.com>
Copy link
Copy Markdown
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.

First round of reviews.

Comment thread tools/src/ops_tools/_attrdocs.py
Comment on lines +97 to +98
with open(args.charmcraft_yaml, 'w') as raw:
yaml.safe_dump(charmcraft_yaml, raw)
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.

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.

Comment thread tools/src/ops_tools/_generate_yaml.py Outdated
Comment on lines +82 to +88
default = None
for field in dataclasses.fields(cls):
if field.name != name:
continue
break
else:
return default
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.

My 2c: if dataclasses doesn't provide a dict, a helper method that builds a dict is the most natural way to go.

Comment thread tools/src/ops_tools/_update_charmcraft_yaml.py
exit_code += 2
sys.exit(exit_code)

if args.merge and 'config' in charmcraft_yaml:
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.

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:
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.

Are there unit tests and functional / test vector tests for the merge flag?

Comment on lines +129 to +134
generated_schema = ops_tools.config_to_juju_schema(config_class)
expected_schema = {
'options': {
'basic-bool': {
'type': 'boolean',
},
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.

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).

Comment thread tools/README.md
Comment on lines +74 to +87
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
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'd like to see a test that looks exactly like this.

@PietroPasotti
Copy link
Copy Markdown
Contributor

this brings back some memories: https://github.com/PietroPasotti/jinx

Copy link
Copy Markdown
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.

Partial review

Comment thread test/charms/test_main/src/charm.py Outdated
# 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.
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.

nit could be perhaps written as "test expected to fail if uncommented".

Comment thread testing/src/scenario/state.py Outdated
Comment on lines +1978 to +1981
# 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.

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 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?

Comment thread testing/tests/test_e2e/test_config.py
my_str: str = 'foo'
"""A string value."""

my_secret: ops.Secret | None = None
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.

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.

Copy link
Copy Markdown
Contributor

@dimaqq dimaqq Sep 29, 2025

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] = value

The value seems mandatory?

This comment was marked as outdated.

@benhoyt
Copy link
Copy Markdown
Collaborator

benhoyt commented Nov 4, 2025

Tracking issue: #2163

setuptools_scm was pulled in but never used (no git-tag based
versioning) — and on modern setuptools it fails to import because
it still references the removed pkg_resources.
Two post-merge regressions in ops-tools:

1. _attr_to_yaml_type was treating ops.Secret | None as a multi-type union
   (keeping NoneType in the hint set) and falling back to 'string'. Strip
   NoneType so Optional[T] is treated as T, restoring 'type: secret' for
   optional secret fields.

2. Pydantic 2.12 no longer inlines Enum schemas; it emits $defs + $ref.
   action_to_juju_schema now calls model_json_schema() (schema() is
   deprecated) and inlines referenced schemas so the Juju YAML retains
   type / enum instead of a dangling $ref.
* JSON_TYPES comment wording (james-garner-canonical): tighten the
  description of what's handled outside the mapping.
* test_main charm comment (dimaqq): reword to match the style of the
  surrounding comment.
* _attrdocs unit tests (dimaqq): add two edge cases -- a class
  docstring must not attach to the first attribute, and a method
  between attributes must not break docstring collection.
Share the dataclass / Pydantic class traversal with ops.charm rather
than reimplementing it here. ops-tools stays permissive by catching
the ValueError that _juju_fields raises on plain classes and falling
back to get_type_hints.
update-charmcraft-schema requires a charmcraft.yaml to read and write
back, which makes it awkward to try the generator interactively, pipe
the output somewhere else, or drive it from a charmcraft override-build
part (where charmcraft.yaml may not declare config/actions at all).

Add two standalone tools that take one or more Python class specifiers
and write the matching Juju YAML (config.yaml / actions.yaml format)
to stdout. They share get_class_from_module with the existing tool
(moved into _generate_juju_yaml.py) but do not read charmcraft.yaml.
@tonyandrewmeyer tonyandrewmeyer marked this pull request as draft April 17, 2026 01:59
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.

6 participants