feat: support for generating charmcraft YAML from Python classes#1975
feat: support for generating charmcraft YAML from Python classes#1975tonyandrewmeyer wants to merge 98 commits intocanonical:mainfrom
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.
| if hasattr(field.default, 'default_factory') | ||
| else field.default_factory | ||
| ) | ||
| # A hack to avoid importing Pydantic here. |
There was a problem hiding this comment.
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.
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.
May I ask why -- is it the extra runtime overhead for users?
There was a problem hiding this comment.
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>
| with open(args.charmcraft_yaml, 'w') as raw: | ||
| yaml.safe_dump(charmcraft_yaml, raw) |
There was a problem hiding this comment.
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.
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.
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.
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.
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.
I'd like to see a test that looks exactly like this.
|
this brings back some memories: https://github.com/PietroPasotti/jinx |
| # 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.
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.
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?
| my_str: str = 'foo' | ||
| """A string value.""" | ||
|
|
||
| my_secret: ops.Secret | None = None |
There was a problem hiding this comment.
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.
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?
|
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.
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.