Repository: canonical/operator

Issue:
Recent versions of ops fail when there are no config options
In my charmcraft.yaml I have no config options:
```yaml
config:
  options:
```

As a result, the install hook fails (ops-2.22.0 and ops-2.21.1):
```
  File "/var/lib/juju/agents/unit-swfd-0/charm/venv/lib/python3.12/site-packages/ops/charm.py", line 1609, in __init__
    for name, config in config_raw_.get('options', {}).items():
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'items'
```

There are no errors with ops-2.17.

Discussion:
@tonyandrewmeyer: Until the fix is out, a workaround is to remove the empty `options` map (or the higher level `config` map).@dimaqq: Canonical YAML would be:

```yaml
config:
  options: {}
```

I was bit in the behind more than once by this. That is by YAML shorthand semantics.

Solution patch:
diff --git a/.github/workflows/charmcraft-pack.yaml b/.github/workflows/charmcraft-pack.yaml
index 430f647..efb7da3 100644
--- a/.github/workflows/charmcraft-pack.yaml
+++ b/.github/workflows/charmcraft-pack.yaml
@@ -43,7 +43,7 @@ jobs:
           CLONE_SHA: ${{ github.event.pull_request.head.sha }}
 
       - name: Set up LXD
-        uses: canonical/setup-lxd@54a5806e490d92e207b57183cf111ed54bbdeff4
+        uses: canonical/setup-lxd@a3c85fc6fb7fff43fcfeae87659e41a8f635b7dd
         with:
           channel: 5.0/stable
 
diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml
index 2a3cd0f..b96aa23 100644
--- a/.github/workflows/integration.yaml
+++ b/.github/workflows/integration.yaml
@@ -31,5 +31,5 @@ jobs:
       - uses: actions/checkout@v4
         with:
           persist-credentials: false
-      - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca  # v6.0.1
+      - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb  # v6.1.0
       - run: uvx tox -e integration -- --log-cli-level=INFO -s -k "${{ matrix.test }}"
diff --git a/.github/workflows/publish-ops-tracing.yaml b/.github/workflows/publish-ops-tracing.yaml
index 238f3a5..efa326a 100644
--- a/.github/workflows/publish-ops-tracing.yaml
+++ b/.github/workflows/publish-ops-tracing.yaml
@@ -17,7 +17,7 @@ jobs:
       - uses: actions/checkout@v4
         with:
           persist-credentials: false
-      - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca  # v6.0.1
+      - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb  # v6.1.0
         with:
           enable-cache: false
       - run: |
diff --git a/.github/workflows/test-publish-ops-tracing.yaml b/.github/workflows/test-publish-ops-tracing.yaml
index 2c3b321..76f967e 100644
--- a/.github/workflows/test-publish-ops-tracing.yaml
+++ b/.github/workflows/test-publish-ops-tracing.yaml
@@ -13,7 +13,7 @@ jobs:
       - uses: actions/checkout@v4
         with:
           persist-credentials: false
-      - uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca  # v6.0.1
+      - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb  # v6.1.0
         with:
           enable-cache: false
       - run: |
diff --git a/.github/workflows/zizmor.yaml b/.github/workflows/zizmor.yaml
index f04f1e3..80ded8b 100644
--- a/.github/workflows/zizmor.yaml
+++ b/.github/workflows/zizmor.yaml
@@ -20,7 +20,7 @@ jobs:
           persist-credentials: false
 
       - name: Install the latest version of uv
-        uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca  # v6.0.1
+        uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb  # v6.1.0
 
       - name: Run zizmor
         run: uvx zizmor --format sarif . > results.sarif
diff --git a/docs/howto/write-integration-tests-for-a-charm.md b/docs/howto/write-integration-tests-for-a-charm.md
index d7c78e2..4877197 100644
--- a/docs/howto/write-integration-tests-for-a-charm.md
+++ b/docs/howto/write-integration-tests-for-a-charm.md
@@ -17,9 +17,9 @@ The instructions all use the Juju `python-libjuju` client, either through the `p
 
 ## Prepare your environment
 
-In order to run integrations tests you will need to have your environment set up with `tox` installed.
+In order to run integrations tests you will need to have your environment set up with a Juju controller and have `tox` installed.
 
-> See more: {external+juju:ref}`How to set up your development environment <manage-your-deployment-environment>`
+> See more: {external+juju:ref}`Set up your deployment <set-up-your-deployment>`
 
 ## Prepare the `tox.ini` configuration file
 
@@ -53,7 +53,7 @@ import pytest
 from pytest_operator.plugin import OpsTest
 ```
 
-The `ops_test` fixture is your entry point to the `pytest-operator` library, and the preferred way of interacting with Juju in integration tests. The fixture will create a model for each test file -- if you write two tests that should not share a model, make sure to place them in different files. The fixture is a module-scoped context which, on entry, adds to Juju a randomly-named new model and destroys it on exit. All tests in that module, and all interactions with the `ops_test` object, will take place against that model. Once you have used `ops_test` to get a model in which to run your integration tests, most of the remaining integration test code will interact with Juju via the `python-libjuju` package.  
+The `ops_test` fixture is your entry point to the `pytest-operator` library, and the preferred way of interacting with Juju in integration tests. The fixture will create a model for each test file -- if you write two tests that should not share a model, make sure to place them in different files. The fixture is a module-scoped context which, on entry, adds to Juju a randomly-named new model and destroys it on exit. All tests in that module, and all interactions with the `ops_test` object, will take place against that model. Once you have used `ops_test` to get a model in which to run your integration tests, most of the remaining integration test code will interact with Juju via the `python-libjuju` package.
 
 ```{note}
 
@@ -68,24 +68,24 @@ async def test_operation(ops_test: OpsTest):
     # Tweak the config:
     app: Application = ops_test.model.applications.get("tester")
     await app.set_config({"my-key": "my-value"})
-    
+
     # Add another charm and integrate them:
     await ops_test.model.deploy('other-app')
     await ops_test.model.relate('tester:endpoint1', 'other-app:endpoint2')
-    
+
     # Scale it up:
     await app.add_unit(2)
-    
+
     # Remove another app:
     await ops_test.model.remove_application('yet-another-app')
-    
+
     # Run an action on a unit:
     unit: Unit = app.units[1]
     action = await unit.run('my-action')
     assert action.results == <foo>
-    
+
     # What this means depends on the workload:
-    assert charm_operates_correctly()  
+    assert charm_operates_correctly()
 ```
 
 `python-libjuju` has, of course, an API for all inverse operations: remove an app, scale it down, remove a relation...
@@ -129,7 +129,7 @@ As an alternative to `wait_for_idle`, you can explicitly block until the applica
 
 > Example implementations: [charm-coredns](https://github.com/charmed-kubernetes/charm-coredns/blob/b1d83b6a31200924fefcd288336bc1f9323c6a72/tests/integration/test_integration.py#L21), [charm-calico](https://github.com/charmed-kubernetes/charm-calico/blob/e1dfdda92fefdba90b7b7e5247fbc861c34ad146/tests/integration/test_calico_integration.py#L18)
 
-> See more: 
+> See more:
 > - [`pytest-operator` | `ops_test.build_charm`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1020)
 > - [`python-libjuju` | `model.deploy `](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/model.py#L1658)
 
@@ -161,7 +161,7 @@ For `oci-images` you can reference an image registry.
 > Example implementations: [kubernetes-control-plane](https://github.com/charmed-kubernetes/charm-kubernetes-control-plane/blob/8769db394bf377a03ce94066307ecf831b88ad17/tests/integration/test_kubernetes_control_plane_integration.py#L41), [synapse-operator](https://github.com/canonical/synapse-operator/blob/eb44f4959a00040f08b98470f8b17cae4cc616da/tests/integration/conftest.py#L119), [prometheus-k8s](https://github.com/canonical/prometheus-k8s-operator/blob/d29f323343a1e4906a8c71104fcd1de817b2c2e6/tests/integration/test_remote_write_with_zinc.py#L27)
 
 >
-> See more: 
+> See more:
 > - [`pytest-operator` | `build_resources`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1073)
 > - [`pytest-operator` |  `download_resources`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1101)
 > - [`python-libjuju` | `model.deploy`](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/model.py#L1658)
@@ -190,13 +190,13 @@ async def test_my_integration(ops_test: OpsTest):
 
 > See first: {ref}`manage-configuration`
 
-You can set a configuration option in your application and check its results. 
+You can set a configuration option in your application and check its results.
 
 ```python
 async def test_config_changed(ops_test: OpsTest):
     ...
         await ops_test.model.applications["synapse"].set_config({"server_name": "invalid_name"})
-        # In this case, when setting server_name to "invalid_name" 
+        # In this case, when setting server_name to "invalid_name"
         # we could for example expect a blocked status.
         await ops_test.model.wait_for_idle(status="blocked",  timeout=60)
     ...
@@ -209,7 +209,7 @@ async def test_config_changed(ops_test: OpsTest):
 
 > See also: {external+juju:ref}`Action <action>`
 
-You can execute an action on a unit and get its results. 
+You can execute an action on a unit and get its results.
 
 ```python
 async def test_run_action(ops_test: OpsTest):
@@ -244,7 +244,7 @@ How you can connect to a private or public address is dependent on your configur
 
 > Example implementations: [mongodb-k8s-operator](https://github.com/canonical/mongodb-k8s-operator/blob/8b9ebbee3f225ca98175c25781f1936dc4a62a7d/tests/integration/metrics_tests/test_metrics.py#L33), [tempo-k8s-operator](https://github.com/canonical/tempo-k8s-operator/blob/78a1143d99af99a1a56fe9ff82b1a3563e4fd2f7/tests/integration/test_integration.py#L69), [synapse](https://github.com/canonical/synapse-operator/blob/eb44f4959a00040f08b98470f8b17cae4cc616da/tests/integration/conftest.py#L170)
 
-> See more: 
+> See more:
 > - {external+juju:ref}`Charm development best practices > Fetching network information <charm-development-best-practices>`
 > - {external+juju:ref}`juju CLI commands > juju expose <command-juju-expose>`
 
@@ -272,13 +272,13 @@ so you don't need to include the `-m` parameter.
 > Example implementations: [prometheus-k8s-operator](https://github.com/canonical/prometheus-k8s-operator/blob/d29f323343a1e4906a8c71104fcd1de817b2c2e6/tests/integration/conftest.py#L86), [hardware-observer-operator](https://github.com/canonical/hardware-observer-operator/blob/08c50798ca1c133a5d8ba5c889e0bcb09771300b/tests/functional/conftest.py#L14)
 
 
-> See more: 
+> See more:
 > - [`pytest-operator` | `run`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L576)
 > - [`pytest-operator` | `juju`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L624)
 
 ### Use several models
 
-You can use `pytest-operator` with several models, in the same cloud or in 
+You can use `pytest-operator` with several models, in the same cloud or in
 different clouds. This way you can, for example, integrate machine charms
 with Kubernetes charms easily.
 
@@ -308,7 +308,7 @@ Using the new alias, you can switch context to the new created model, similar to
 
 > Example implementations: [`charm-kubernetes-autoscaler`](https://github.com/charmed-kubernetes/charm-kubernetes-autoscaler/blob/8f4ddf5d66802ade73ed3aab2bb8d09fd9e4d63a/tests/integration/test_kubernetes_autoscaler.py#L31)
 
-> See more: 
+> See more:
 > - {external+juju:ref}`Juju offers <manage-offers>`
 > - {external+juju:ref}`How to manage clouds <manage-clouds>`
 > - [pytest-operator | track_model](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L720)
@@ -369,7 +369,7 @@ firing rate with `fast_forward`. Inside the new async context you can put any co
 
 > Example implementations [`postgresql-k8s-operator`](https://github.com/canonical/postgresql-k8s-operator/blob/69b2c138fa6b974883aa6d3d15a3315189d321d8/tests/integration/ha_tests/test_upgrade.py#L58), [`synapse-operator`](https://github.com/canonical/synapse-operator/blob/05c00bb7666197d04f1c025c36d8339b10b64a1a/tests/integration/test_charm.py#L249)
 
- 
+
 > See more:
 > - [`pytest-operator` | `fast_forward`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1400)
 
@@ -382,7 +382,7 @@ By default you can run all your tests with:
 tox -e integration
 ```
 
-These tests will use the context of the current controller in Juju, and by default will create a new model per module, that will be destroyed when the test is finished. The cloud, controller and model name can be specified with the parameters `--cloud`, `--controller` and `--model` parameters. 
+These tests will use the context of the current controller in Juju, and by default will create a new model per module, that will be destroyed when the test is finished. The cloud, controller and model name can be specified with the parameters `--cloud`, `--controller` and `--model` parameters.
 
 If you specify the model name and do not delete the model on test tear down with the parameter `--keep-models`, you can reuse a model from a previous test run, as in the next example:
 ```text
@@ -399,7 +399,7 @@ There are different ways of specifying a subset of tests to run using `pytest`.
 tox -e integration -- tests/integration/test_charm.py -k "not test_one"
 ```
 
-> See more: 
+> See more:
 > - [`pytest-operator` | `skip_if_deployed`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L139)
 > - [`pytest | How to invoke pytest`](https://docs.pytest.org/en/7.1.x/how-to/usage.html)
 
@@ -418,6 +418,6 @@ You can disable crash dump generation with `--crash-dump=never`. To always creat
 tox -e integration -- --crash-dump=always --crash-dump-output=/tmp
 ```
 
-> See more: 
+> See more:
 > - [`juju-crashdump`](https://github.com/juju/juju-crashdump)
 > - [`pytest-operator` | `--crash-dump`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L97)
diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md
index 39f724d..287ba84 100644
--- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md
+++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md
@@ -1,15 +1,15 @@
 (set-up-your-development-environment)=
 # Set up your development environment
 
-> <small>{ref}`From zero to hero: Write your first Kubernetes charm <from-zero-to-hero-write-your-first-kubernetes-charm>`  > Set up your development environment</small> 
+> <small>{ref}`From zero to hero: Write your first Kubernetes charm <from-zero-to-hero-write-your-first-kubernetes-charm>`  > Set up your development environment</small>
 >
 > **See previous: {ref}`Study your application <study-your-application>`**
 
-In this chapter of the tutorial you will set up your development environment. 
+In this chapter of the tutorial you will set up your development environment.
 
-You will need a charm directory, the various tools in the charm SDK, Juju, and a Kubernetes cloud. And it’s a good idea if you can do all your work in an isolated development environment. 
+You will need a charm directory, the various tools in the charm SDK, Juju, and a Kubernetes cloud. And it’s a good idea if you can do all your work in an isolated development environment.
 
-To set all of this up, see {external+juju:ref}`Juju | Manage your deployment environment > Set things up <set-things-up>`, with the following changes: 
+To set all of this up, see {external+juju:ref}`Juju | Manage your deployment > Set up your deployment <set-up-your-deployment>`, with the following changes:
 
 - At the directory step, call your directory `fastapi-demo`.
 - At the VM setup step, call your VM `charm-dev`. Also set up Docker:
@@ -22,7 +22,6 @@ To set all of this up, see {external+juju:ref}`Juju | Manage your deployment env
 - At the cloud selection step, choose `microk8s`.
 - At the mount step, read the tips about how to edit files locally while running them inside the VM.
 
-Congratulations, your development environment is ready! 
+Congratulations, your development environment is ready!
 
 > **See next: {ref}`Create a minimal Kubernetes charm <create-a-minimal-kubernetes-charm>`**
-
diff --git a/ops/charm.py b/ops/charm.py
index 836186c..47ad8be 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -1541,7 +1541,9 @@ class CharmMeta:
     ):
         raw_: dict[str, Any] = raw or {}
         actions_raw_: dict[str, Any] = actions_raw or {}
-        config_raw_: dict[str, Any] = config_raw or {}
+        # config_raw might be {'options': None} - this is accepted by
+        # charmcraft, even though {'options: {}} would be more correct.
+        options_raw: dict[str, Any] = (config_raw or {}).get('options') or {}
 
         # When running in production, this data is generally loaded from
         # metadata.yaml. However, when running tests, this data is
@@ -1606,7 +1608,7 @@ class CharmMeta:
         # This is predominately for backwards compatibility with Harness. In a
         # real Juju environment this shouldn't be possible, because charmcraft
         # validates the config when packing.
-        for name, config in config_raw_.get('options', {}).items():
+        for name, config in options_raw.items():
             if 'type' not in config:
                 raise RuntimeError(
                     f'Incorrectly formatted config in YAML, option {name} is '
@@ -1619,7 +1621,7 @@ class CharmMeta:
                 default=config.get('default'),
                 description=config.get('description'),
             )
-            for name, config in config_raw_.get('options', {}).items()
+            for name, config in options_raw.items()
         }
         self.containers = {
             name: ContainerMeta(name, container)


Test patch:
diff --git a/test/test_charm.py b/test/test_charm.py
index 8b6c645..214935f 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -125,8 +125,44 @@ def test_observer_not_referenced_warning(
     assert 'Reference to ops.Object' in caplog.text
 
 
-def test_empty_action_and_config():
-    meta = ops.CharmMeta.from_yaml('name: my-charm', '', '')
+@pytest.mark.parametrize('empty_yaml', ['', '{}', None])
+def test_empty_action_and_config_from_yaml(empty_yaml: str | None):
+    meta = ops.CharmMeta.from_yaml('name: my-charm', empty_yaml, empty_yaml)
+    assert meta.actions == {}
+    assert meta.config == {}
+    if empty_yaml is not None:
+        meta = ops.CharmMeta.from_yaml('name: my-charm', config=f'options: {empty_yaml}')
+        assert meta.actions == {}
+        assert meta.config == {}
+
+
+@pytest.mark.parametrize('empty_yaml', ['', '{}', None])
+def test_empty_action_and_config_from_charm_root(empty_yaml: str | None):
+    with tempfile.TemporaryDirectory() as d:
+        td = pathlib.Path(d)
+        (td / 'metadata.yaml').write_text('name: my-charm')
+        if empty_yaml is not None:
+            (td / 'actions.yaml').write_text(empty_yaml)
+            (td / 'config.yaml').write_text(empty_yaml)
+        meta = ops.CharmMeta.from_charm_root(td)
+    assert meta.actions == {}
+    assert meta.config == {}
+    with tempfile.TemporaryDirectory() as d:
+        td = pathlib.Path(d)
+        (td / 'metadata.yaml').write_text('name: my-charm')
+        if empty_yaml is not None:
+            (td / 'config.yaml').write_text(f'options:\n  {empty_yaml}')
+        meta = ops.CharmMeta.from_charm_root(td)
+    assert meta.actions == {}
+    assert meta.config == {}
+
+
+@pytest.mark.parametrize('empty_yaml', [{}, None])
+def test_empty_action_and_config(empty_yaml: dict[str, typing.Any] | None):
+    meta = ops.CharmMeta({'name': 'my-charm'}, empty_yaml, empty_yaml)
+    assert meta.actions == {}
+    assert meta.config == {}
+    meta = ops.CharmMeta({'name': 'my-charm'}, config_raw={'options': empty_yaml})
     assert meta.actions == {}
     assert meta.config == {}