Repository: canonical/operator

Issue:
Pass charm type to Context.manager
Hi,

When I am writing tests using the new testing framework formerly known as scenario, I find myself adding a line like this:

```python
    with ctx(ctx.on.start(), state_in) as manager:
        charm = cast(KafkaCharm, manager.charm)  #  <-- This line...
        assert not charm.broker.healthy  # <-- ...gives me autocomplete and type checker validation

```

In larger code bases, this boilerplate adds up to numerous lines.

With a few tweaks to `Context` and `Manager`, we can make them variadic types and get this nice user experience:

![Capture d’écran du 2024-10-31 17-27-55](https://github.com/user-attachments/assets/bcd0b7e2-07b3-45b1-b8a6-7eaa67e02fc3)


If that's something you are interested in, I can open a PR. Have a look at [my branch on ops-scenario](https://github.com/Batalex/ops-scenario/commit/94931d34363913deeb527aff12b2a852ceb386f1), and let me know what you think!

Discussion:

Patch:
diff --git a/.github/workflows/build_scenario_wheels.yaml b/.github/workflows/build_scenario_wheels.yaml
deleted file mode 100644
index 8783384..0000000
--- a/.github/workflows/build_scenario_wheels.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Build ops-scenario wheels
-
-# TODO: adjust this workflow to properly build ops-scenario from the operator repo
-# and then this should be adjusted to run when appropriate (not on push to main
-# any more, but as part of the releasing workflow we agree on).
-on:
-  workflow_dispatch
-#  push:
-#    branches:
-#      - main
-
-jobs:
-  build_wheel:
-    name: Build wheel on ubuntu (where else???)
-    runs-on: ubuntu-latest
-
-    steps:
-      - uses: actions/checkout@v4
-
-      - uses: actions/setup-python@v5
-
-      - name: Install build
-        run: pip install build
-
-      - name: Build wheel
-        run: python -m build
-
-      - uses: actions/upload-artifact@v3
-        with:
-          path: ./dist/*.whl
-
-      - name: Get the version
-        id: get_version
-        run: echo "VERSION=$(sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml)" >> $GITHUB_OUTPUT
-
-      - name: release
-        uses: actions/create-release@v1
-        id: create_release
-        with:
-          draft: false
-          prerelease: false
-          tag_name: ${{ steps.get_version.outputs.VERSION }}
-          release_name: ${{ steps.get_version.outputs.VERSION }}
-
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
-
-      - name: upload wheel
-        uses: actions/upload-release-asset@v1
-        env:
-          GITHUB_TOKEN: ${{ github.token }}
-        with:
-          upload_url: ${{ steps.create_release.outputs.upload_url }}
-          asset_path: ./dist/ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
-          asset_name: ops_scenario-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl
-          asset_content_type: application/wheel
-
-      - name: Publish to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
-        with:
-          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/charmcraft-pack.yaml b/.github/workflows/charmcraft-pack.yaml
index 1c8c24b..6bca4a4 100644
--- a/.github/workflows/charmcraft-pack.yaml
+++ b/.github/workflows/charmcraft-pack.yaml
@@ -37,7 +37,7 @@ jobs:
           cat requirements.txt
 
       - name: Set up LXD
-        uses: canonical/setup-lxd@8fb85546a934dfb994becf81341dd387ffe6aabb
+        uses: canonical/setup-lxd@54a5806e490d92e207b57183cf111ed54bbdeff4
         with:
           channel: 5.0/stable
 
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish-ops-scenario.yaml
similarity index 81%
rename from .github/workflows/publish.yml
rename to .github/workflows/publish-ops-scenario.yaml
index 05c1527..a5d9cf4 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish-ops-scenario.yaml
@@ -1,8 +1,8 @@
-name: Publish
+name: Publish (ops-scenario)
 on:
-  release:
-    types:
-      - published
+  push:
+    tags:
+      - 'scenario-7.*'
 
 jobs:
   framework-tests:
@@ -12,7 +12,7 @@ jobs:
   hello-charm-tests:
     uses: ./.github/workflows/hello-charm-tests.yaml
   build-n-publish:
-    name: Build and Publish to PyPI
+    name: Build and Publish ops-scenario to PyPI
     runs-on: ubuntu-latest
     permissions:
       id-token: write
@@ -27,9 +27,10 @@ jobs:
         run: pip install wheel build
       - name: Build
         run: python -m build
+        working-directory: ./testing
       - name: Attest build provenance
         uses: actions/attest-build-provenance@v1.4.3
         with:
-          subject-path: 'dist/*'
+          subject-path: 'testing/dist/*'
       - name: Publish
         uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/publish-ops.yaml b/.github/workflows/publish-ops.yaml
new file mode 100644
index 0000000..3e729f9
--- /dev/null
+++ b/.github/workflows/publish-ops.yaml
@@ -0,0 +1,39 @@
+name: Publish (ops)
+on:
+  push:
+    tags:
+      # TODO: When we come to ops v3.x we will need to reconsider what to do
+      # here (we might want to start prefixing with 'v' for one thing), and we
+      # will likely be publishing at least three packages: ops, ops-scenario,
+      # and ops-harness.
+      - '2.*'
+
+jobs:
+  framework-tests:
+    uses: ./.github/workflows/framework-tests.yaml
+  observability-charm-tests:
+    uses: ./.github/workflows/observability-charm-tests.yaml
+  hello-charm-tests:
+    uses: ./.github/workflows/hello-charm-tests.yaml
+  build-n-publish:
+    name: Build and Publish ops to PyPI
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      attestations: write
+      contents: read
+    needs: [framework-tests, observability-charm-tests, hello-charm-tests]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup Python
+        uses: actions/setup-python@v5
+      - name: Install build dependencies
+        run: pip install wheel build
+      - name: Build
+        run: python -m build
+      - name: Attest build provenance
+        uses: actions/attest-build-provenance@v1.4.3
+        with:
+          subject-path: 'dist/*'
+      - name: Publish
+        uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml
index 3f69d1e..f339254 100644
--- a/.github/workflows/smoke.yaml
+++ b/.github/workflows/smoke.yaml
@@ -2,8 +2,8 @@ name: ops Smoke Tests
 
 on:
   workflow_dispatch:
-    schedule:
-      - cron:  '0 7 25 * *'
+  schedule:
+    - cron:  '0 7 25 * *'
 
 jobs:
   test:
@@ -24,7 +24,7 @@ jobs:
       # LXD is required for charmcraft to pack, even if it's not used as the
       # Juju cloud.
       - name: Set up LXD
-        uses: canonical/setup-lxd@8fb85546a934dfb994becf81341dd387ffe6aabb
+        uses: canonical/setup-lxd@54a5806e490d92e207b57183cf111ed54bbdeff4
         with:
           channel: 5.0/stable
 
diff --git a/.github/workflows/tiobe.yaml b/.github/workflows/tiobe.yaml
index 6c79c22..c038573 100644
--- a/.github/workflows/tiobe.yaml
+++ b/.github/workflows/tiobe.yaml
@@ -19,10 +19,6 @@ jobs:
       - name: Generate coverage report
         run: |
           tox -e coverage
-          # Annoyingly, the coverage.xml file needs to be in a .coverage folder.
-          rm .coverage
-          mkdir .coverage
-          mv coverage.xml .coverage/
 
       - name: TICS GitHub Action
         uses: tiobe/tics-github-action@v3
diff --git a/.github/workflows/validate-pr-title.yaml b/.github/workflows/validate-pr-title.yaml
index 483a928..104ba8c 100644
--- a/.github/workflows/validate-pr-title.yaml
+++ b/.github/workflows/validate-pr-title.yaml
@@ -10,7 +10,7 @@ permissions:
   pull-requests: read
 
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
   cancel-in-progress: true
 
 jobs:
diff --git a/.gitignore b/.gitignore
index 1744684..167e84a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ venv
 .vscode
 .coverage
 coverage.xml
+.coverage.data
 /.tox
 .*.swp
 
diff --git a/HACKING.md b/HACKING.md
index 0ba4ffb..2873ede 100644
--- a/HACKING.md
+++ b/HACKING.md
@@ -179,14 +179,17 @@ Pull requests should have a short title that follows the
 * revert
 * test
 
-If the PR is limited to changes in ops.testing (Harness), also include the scope
-`(harness)` in the title. At present, we do not add a scope in any other cases.
+At present, we only add a scope in these cases:
+
+* If the PR is limited to changes in ops/_private/harness.py, also include the scope `(harness)`
+* If the PR is limited to changes in testing/, also include the scope `(testing)`
 
 For example:
 
 * feat: add the ability to observe change-updated events
 * fix!: correct the type hinting for config data
 * docs(harness): clarify the types of exceptions that Harness.add_user_secret may raise
+* ci(testing): adjust the workflow that publishes ops-scenario
 
 Note that the commit messages to the PR's branch do not need to follow the
 conventional commit format, as these will be squashed into a single commit to `main`
@@ -202,10 +205,10 @@ The copyright information in existing files does not need to be updated when tho
 
 In general, new functionality
 should always be accompanied by user-focused documentation that is posted to
-https://juju.is/docs/sdk.  The content for this site is written and hosted on
-https://discourse.charmhub.io/c/doc.  New documentation should get a new
+https://juju.is/docs/sdk. The content for this site is written and hosted on
+https://discourse.charmhub.io/c/doc. New documentation should get a new
 topic/post on this Discourse forum and then should be linked into the main
-docs navigation page(s) as appropriate.  The ops library's SDK page
+docs navigation page(s) as appropriate. The ops library's SDK page
 content is pulled from
 [the corresponding Discourse topic](https://discourse.charmhub.io/t/the-charmed-operator-software-development-kit-sdk-docs/4449).
 Each page on [juju.is](https://juju.is/docs/sdk) has a link at the bottom that
@@ -221,13 +224,21 @@ Currently we don't publish separate versions of documentation for separate relea
 next to the relevant content (e.g. headings, etc.).
 
 The ops library's API reference is automatically built and published to
-[ops.readthedocs.io](https://ops.readthedocs.io/en/latest/).  Please be complete with
+[ops.readthedocs.io](https://ops.readthedocs.io/en/latest/). Please be complete with
 docstrings and keep them informative for _users_. The published docs are always
 for the in-development (main branch) of ops, and do not include any notes
-indicating changes or additions across versions - we encourage all charmers to
+indicating changes or additions across ops versions - we encourage all charmers to
 promptly upgrade to the latest version of ops, and to refer to the release notes
 and changelog for learning about changes.
 
+We do note when features behave differently when using different Juju versions.
+Use the `.. jujuadded:: x.y` directive to indicate that the feature is only
+available when using version x.y (or higher) of Juju, `..jujuchanged:: x.y`
+when the feature's behaviour _in ops_ changes, and `..jujuremoved:: x.y` when
+the feature will be available in ops but not in that version (or later) of Juju.
+Unmarked features are assumed to work and be available in the current LTS
+version of Juju.
+
 During the release process, changes also get a new entry in [CHANGES.md](CHANGES.md).
 These are grouped into the same groupings as
 [commit messages](https://www.conventionalcommits.org/en/)
@@ -307,47 +318,155 @@ the build frontend is [build](https://pypi.org/project/build/).
 
 # Publishing a Release
 
-To make a release of the ops library, do the following:
-
-1. Visit the [releases page on GitHub](https://github.com/canonical/operator/releases).
-2. Click "Draft a new release"
-3. The "Release Title" is simply the full version number, in the form `<major>.<minor>.<patch>`
-   and a brief summary of the main changes in the release
-   E.g. 2.3.12 Bug fixes for the Juju foobar feature when using Python 3.12
-4. Use the "Generate Release Notes" button to get a copy of the changes into the
-   notes field.
-5. Group the changes by the commit type (feat, fix, etc.) and use full names (e.g., "Features",
-   not "feat") for group headings. Strip the commit type prefix from the bullet point. Strip the
-   username (who did each commit) if the author is a member of the Charm Tech team.
-6. Where appropriate, collapse multiple tightly related bullet points into a
-   single point that refers to multiple commits.
-7. Create a new branch, and copy this text to the [CHANGES.md](CHANGES.md) file,
-   stripping out links, who did each commit, the new contributor list, and the
-   link to the full changelog.
-8. Change [version.py](ops/version.py)'s `version` to the
-   [appropriate string](https://semver.org/).
-9. Check if there's a `chore: update charm pins` auto-generated PR in the queue. If it looks
-   good, merge it and check that tests still pass. If needed, you can re-trigger the
-   `Update Charm Pins` workflow manually to ensure latest charms and ops get tested.
-10. Add, commit, and push, and open a PR to get the changelog and version bump
+To make a release of the `ops` and/or `ops-scenario` packages, do the following:
+
+1. Check if there's a `chore: update charm pins` auto-generated PR in the queue.
+   If it looks good, merge it and check that tests still pass. If needed, you
+   can re-trigger the `Update Charm Pins` workflow manually to ensure latest
+   charms and ops get tested.
+2. Visit the [releases page on GitHub](https://github.com/canonical/operator/releases).
+3. Click "Draft a new release"
+4. The "Release Title" is the full version numbers of ops and/or ops-scenario,
+   in the form `ops <major>.<minor>.<patch> and ops-scenario <major>.<minor>.<patch>`
+   and a brief summary of the main changes in the release.
+   For example: `ops 2.3.12 Bug fixes for the Juju foobar feature when using Python 3.12`
+5. If the last release was for both `ops` and `ops-scenario`, leave the previous
+   tag choice on `auto`. If the last release was for only one package, change
+   the previous tag to be the last time the same package(s) were being released.
+6. Have the release create a new tag, in the form `<major>.<minor>.<patch>` for
+   `ops` and `scenario-<major>.<minor>.<patch>` for `ops-scenario`. If releasing
+   both packages, use the ops tag.
+7. Use the "Generate Release Notes" button to get a copy of the changes into the
+   notes field. The 'Release Documentation' section below details the form that
+   the release notes and changelog should take.
+8. For `ops`, change [version.py](ops/version.py)'s `version` to the
+   appropriate string. For `ops-scenario`, change the version in
+   [testing/pyproject.toml](testing/pyproject.toml). Both packages use
+   [semantic versioning]](https://semver.org/), and adjust independently
+   (that is: ops 2.18 doesn't imply ops-scenario 2.18, or any other number).
+9. Add, commit, and push, and open a PR to get the changelogs and version bumps
    into main (and get it merged).
-11. Back in the GitHub releases page, tweak the release notes - for example,
-   you might want to have a short paragraph at the intro on particularly
-   noteworthy changes.
-12. Have someone else in the Charm-Tech team proofread the release notes.
-13. When you are ready, click "Publish". (If you are not ready, click "Save as Draft".)
+10. Save the release notes as a draft, and have someone else in the Charm-Tech
+   team proofread the release notes.
+11. If the release includes both `ops` and `ops-scenario` packages, then push a
+   new tag in the form `scenario-<major>.<minor>.<patch>`. This is done by
+   executing `git tag scenario-x.y.z`, then `git push upstream tag scenario-x.y.z` locally
+   (assuming you have configured `canonical/operator` as a remote named
+   `upstream`).
+12. When you are ready, click "Publish". GitHub will create the additional tag.
+
+Pushing the tags will trigger automatic builds for the Python packages and
+publish them to PyPI ([ops](https://pypi.org/project/ops/) and
+[ops-scenario](https://pypi.org/project/ops-scenario)) (authorisation is handled
+via a [Trusted Publisher](https://docs.pypi.org/trusted-publishers/) relationship).
+Note that it sometimes take a bit of time for the new releases to show up.
+
+See [.github/workflows/publish-ops.yaml](.github/workflows/publish-ops.yaml) and
+[.github/workflows/publish-ops-scenario.yaml](.github/workflows/publish-ops-scenario.yaml) for details.
+(Note that the versions in the YAML refer to versions of the GitHub actions, not the versions of the ops library.)
 
-This will trigger an automatic build for the Python package and publish it to
-[PyPI](https://pypi.org/project/ops/)) (authorisation is handled via a
-[Trusted Publisher](https://docs.pypi.org/trusted-publishers/) relationship).
-Note that it sometimes take a bit of time for the new release to show up.
+You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
 
-See [.github/workflows/publish.yml](.github/workflows/publish.yml) for details. (Note that the versions in publish.yml refer to versions of the GitHub actions, not the versions of the ops library.)
+13. Announce the release on [Discourse](https://discourse.charmhub.io/c/framework/42) and [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com).
 
-You can troubleshoot errors on the [Actions Tab](https://github.com/canonical/operator/actions).
+14. Open a PR to change the version strings to the expected
+   next version, with ".dev0" appended (for example, if 3.14.1 is the next
+   expected version, use `'3.14.1.dev0'`).
+
+## Release Documentation
+
+We produce several pieces of documentation for `ops` and `ops-scenario`
+releases, each serving a separate purpose and covering a different level.
+
+Avoid using the word "Scenario", preferring "unit testing API" or "state
+transition testing". Users should install `ops-scenario` with
+`pip install ops[testing]` rather than using the `ops-scenario` package name
+directly.
+
+### `git log`
+
+`git log` is used to see every change since a previous release. Obviously, no
+special work needs to be done so that this is available. A link to the GitHub
+view of the log will be included at the end of the GitHub release notes when
+the "Generate Release Notes" button is used, in the form:
+
+```
+**Full Changelog**: https://github.com/canonical/operator/compare/2.17.0...2.18.0
+```
+
+These changes include both `ops` and `ops-scenario`. If someone needs to see
+changes only for one of the packages, then the `/testing/` folder can be
+filtered in/out.
+
+### CHANGES.md
+
+A changelog is kept in version control that simply lists the changes in each
+release, other than chores like bumping version numbers. The changelog for `ops`
+is at the top level, in [CHANGES.md](CHANGES.md), and the changelog for
+`ops-scenario` is in the `/testing` folder, [CHANGES.md](testing/CHANGES.md).
+There will be overlap between the two files, as many PRs will include changes to
+common infrastructure, or will adjust both `ops` and also the testing API in
+`ops-scenario`.
+
+Adding the changes is done in preparation for a release. Use the "Generate
+Release Notes" button in the GitHub releases page, and copy the text to the
+CHANGES.md files.
+
+* Group the changes by the commit type (feat, fix, and so on) and use full names
+  ("Features", not "feat", "Fixes", not "fix") for group headings.
+* Remove any bullets that do not apply to the package (`ops` only changes for
+  `ops-scenario`, and `ops-scenario` only changes for `ops`).
+* Strip the commit type prefix from the bullet point, and capitalise the first
+  word.
+* Strip the username (who did each commit) if the author is a member of the
+  Charm Tech team.
+* Replace the link to the pull request with the PR number in parentheses.
+* Where appropriate, collapse multiple tightly related bullet points into a
+  single point that refers to multiple commits.
+
+For example: the PR
+
+```
+* docs: clarify where StoredState is stored by @benhoyt in https://github.com/canonical/operator/pull/2006
+```
 
-13. Announce the release on [Discourse](https://discourse.charmhub.io/c/framework/42) and [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com)
+is added to the "Documentation" section as:
+
+```
+* Clarify where StoredState is stored (#2006)
+```
+
+### GitHub Release Notes
+
+The GitHub release notes include the list of changes found in the changelogs,
+but:
+
+* If both `ops` and `ops-scenario` packages are being released, include all the
+  changes in the same set of release notes. If only one package is being
+  released, remove any bullets that apply only to the other package.
+* The links to the PRs are left in full.
+* Add a section above the list of changes that briefly outlines any key changes
+  in the release.
+
+### Discourse Release Announcement
+
+Post to the [framework category](https://discourse.charmhub.io/c/framework/42)
+with a subject matching the GitHub release title.
+
+The post should resemble this:
+
+```
+The Charm Tech team has just released version x.y.z of ops!
+
+It’s available from PyPI by using `pip install ops`, and `pip install ops[testing]`,
+which will pick up the latest version. Upgrade by running `pip install --upgrade ops`.
+
+The main improvements in this release are ...
+
+Read more in the [full release notes on GitHub](link to the GitHub release).
+```
 
-14. Open a PR to change [version.py](ops/version.py)'s `version` to the expected
-   next version, with ".dev0" appended (for example, if 3.14.1 is the next expected version, use
-   `'3.14.1.dev0'`).
+In the post, outline the key improvements both in `ops` and `ops-scenario` - 
+the point here is to encourage people to check out the full notes and to upgrade
+promptly, so ensure that you entice them with the best that the new versions
+have to offer.
diff --git a/ops/model.py b/ops/model.py
index a87001e..a86c2db 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -2662,7 +2662,7 @@ class Container:
                     if info.type is pebble.FileType.DIRECTORY:
                         self.make_dir(dstpath, make_parents=True)
                         continue
-                    with open(info.path) as src:
+                    with open(info.path, 'rb') as src:
                         self.push(
                             dstpath,
                             src,
diff --git a/ops/pebble.py b/ops/pebble.py
index 6301fa5..f17e1cf 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -2173,7 +2173,7 @@ class Client:
         timeout: float = 30.0,
         delay: float = 0.1,
     ) -> ChangeID:
-        """Stop services by name and wait (poll) for them to be started.
+        """Stop services by name and wait (poll) for them to be stopped.
 
         Args:
             services: Non-empty list of services to stop.
diff --git a/tox.ini b/tox.ini
index 947454c..af454c7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -107,6 +107,7 @@ commands =
 
 [testenv:coverage]
 description = Run unit tests with coverage
+allowlist_externals = mkdir
 passenv =
     RUN_REAL_PEBBLE_TESTS
     PEBBLE
@@ -120,9 +121,10 @@ deps =
     -e .
     -e testing
 commands =
-    coverage run --source={[vars]src_path} \
+    mkdir -p .report
+    coverage run --source={[vars]src_path},testing/src/scenario \
              -m pytest --ignore={[vars]tst_path}smoke -v --tb native {posargs}
-    coverage xml
+    coverage xml -o .report/coverage.xml
     coverage report
 
 [testenv:pebble]


Test patch:
diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml
index b6f1e56..4feb33c 100644
--- a/.github/workflows/db-charm-tests.yaml
+++ b/.github/workflows/db-charm-tests.yaml
@@ -15,13 +15,13 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/postgresql-operator
-            commit: 283463b6df9fd6c81c3221668cbcd4de9927a58d  # rev503 rev502 2024-10-22T11:52:52Z
+            commit: 564932c23370b9848b19c4f136673f972050787b  # 2024-11-26T15:33:51Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: fb0276647d1409c67e12459f8b64acca881d0b4a  # 2024-10-24T11:26:42Z
+            commit: 97c77472a661d4207c458ba7ef92cbb78baf661b  # rev467 rev466 2024-11-26T10:45:43Z
           - charm-repo: canonical/mysql-operator
-            commit: 30d63e8d96f8730b5189d95a3f4c0214351801c0  # 2024-10-18T15:50:23Z
+            commit: a235a9918a50832005f66d9925c086a07f22f88e  # rev308 rev307 2024-11-26T14:03:28Z
           - charm-repo: canonical/mysql-k8s-operator
-            commit: b9da8fccd108780bdb551b6dbcac7017f7908990  # 2024-10-18T15:49:54Z
+            commit: 30dd0d4a16db7bb61cd511784ad9a162b988efb2  # 2024-11-19T13:36:57Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/framework-tests.yaml b/.github/workflows/framework-tests.yaml
index 50305ee..c159365 100644
--- a/.github/workflows/framework-tests.yaml
+++ b/.github/workflows/framework-tests.yaml
@@ -120,7 +120,6 @@ jobs:
         uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
-          allow-prereleases: true
 
       - name: Install build dependencies
         run: pip install wheel build
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index d604ade..a825209 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -16,13 +16,12 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
-            commit: fe233592f9324a6cf4aa9dbc71577ee170cf9eb3  # rev138 2024-10-18T12:11:02Z
-          # Waiting for an upstream PR:
-          # https://github.com/canonical/prometheus-k8s-operator/pull/639
-          #- charm-repo: canonical/prometheus-k8s-operator
-          #  commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c  # rev135 2024-09-06T12:10:02Z
+            commit: c640a9ae31e942522911c1d351b3fea6de5165a8  # 2024-11-25T12:26:55Z
+          - charm-repo: canonical/prometheus-k8s-operator
+            commit: 6d22620a68c388095cde8c76bbf7256447918aec # rev217 2024-11-26T11:12:26Z
+            disabled: true # Waiting for an upstream PR: https://github.com/canonical/prometheus-k8s-operator/pull/639
           - charm-repo: canonical/grafana-k8s-operator
-            commit: 07a52a65f272e115ab786f30cdb3b53be6b34bb7
+            commit: 5b6e32c7ab1e58c248c30265d2f5c474e2db755d  # rev124 2024-11-26T12:06:49Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
@@ -39,7 +38,9 @@ jobs:
         run: pip install tox~=4.2
 
       - name: Run the charm's unit tests
+        if: !matrix.disabled
         run: tox -vve unit
 
       - name: Run the charm's static analysis checks
+        if: !matrix.disabled
         run: tox -vve static-charm
diff --git a/.github/workflows/published-charms-tests.yaml b/.github/workflows/published-charms-tests.yaml
index bb08b7d..c614e42 100644
--- a/.github/workflows/published-charms-tests.yaml
+++ b/.github/workflows/published-charms-tests.yaml
@@ -76,7 +76,7 @@ jobs:
             sed -i -e "/^ops[ ><=]/d" -e "/canonical\/operator/d" -e "/#egg=ops/d" requirements.txt
             echo -e "\ngit+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA#egg=ops" >> requirements.txt
           elif [ -e "poetry.lock" ]; then
-            poetry add git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@GITHUB_SHA --lock
+            poetry add git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_SHA --lock
           else
             echo "Error: No requirements.txt or poetry.lock file found"
             exit 1
diff --git a/.github/workflows/test-publish-ops-scenario.yaml b/.github/workflows/test-publish-ops-scenario.yaml
new file mode 100644
index 0000000..90bec52
--- /dev/null
+++ b/.github/workflows/test-publish-ops-scenario.yaml
@@ -0,0 +1,35 @@
+name: Test Publish (ops-scenario)
+on: [workflow_dispatch, workflow_call]
+
+jobs:
+  framework-tests:
+    uses: ./.github/workflows/framework-tests.yaml
+  observability-charm-tests:
+    uses: ./.github/workflows/observability-charm-tests.yaml
+  hello-charm-tests:
+    uses: ./.github/workflows/hello-charm-tests.yaml
+  build-n-publish:
+    name: Build and Publish ops-scenario to Test PyPI
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+      attestations: write
+      contents: read
+    needs: [framework-tests, observability-charm-tests, hello-charm-tests]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup Python
+        uses: actions/setup-python@v5
+      - name: Install build dependencies
+        run: pip install wheel build
+      - name: Build
+        run: python -m build
+        working-directory: ./testing
+      - name: Attest build provenance
+        uses: actions/attest-build-provenance@v1.4.3
+        with:
+          subject-path: 'testing/dist/*'
+      - name: Publish to test.pypi.org
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/test-publish.yml b/.github/workflows/test-publish-ops.yaml
similarity index 93%
rename from .github/workflows/test-publish.yml
rename to .github/workflows/test-publish-ops.yaml
index da6fb55..37ea924 100644
--- a/.github/workflows/test-publish.yml
+++ b/.github/workflows/test-publish-ops.yaml
@@ -1,4 +1,4 @@
-name: Test Publish
+name: Test Publish (ops)
 on: [workflow_dispatch, workflow_call]
 
 jobs:
@@ -9,7 +9,7 @@ jobs:
   hello-charm-tests:
     uses: ./.github/workflows/hello-charm-tests.yaml
   build-n-publish:
-    name: Build and Publish to Test PyPI
+    name: Build and Publish ops to Test PyPI
     runs-on: ubuntu-latest
     permissions:
       id-token: write
diff --git a/test/test_model.py b/test/test_model.py
index 01df174..541eeb1 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -1369,8 +1369,8 @@ def test_recursive_push_and_pull(case: PushPullCase):
     for file in case.files:
         fpath = os.path.join(push_src.name, file[1:])
         os.makedirs(os.path.dirname(fpath), exist_ok=True)
-        with open(fpath, 'w') as f:
-            f.write('hello')
+        with open(fpath, 'wb') as f:
+            f.write(b'push \xc3\x28')  # invalid UTF-8 to ensure binary works
     if case.dirs:
         for directory in case.dirs:
             fpath = os.path.join(push_src.name, directory[1:])
@@ -1402,6 +1402,8 @@ def test_recursive_push_and_pull(case: PushPullCase):
     ), f'push_path gave wrong expected errors: want {case.errors}, got {errors}'
     for fpath in case.want:
         assert c.exists(fpath), f'push_path failed: file {fpath} missing at destination'
+        content = c.pull(fpath, encoding=None).read()
+        assert content == b'push \xc3\x28'
     for fdir in case.want_dirs:
         assert c.isdir(fdir), f'push_path failed: dir {fdir} missing at destination'
 
diff --git a/testing/CHANGES.md b/testing/CHANGES.md
new file mode 100644
index 0000000..b60094e
--- /dev/null
+++ b/testing/CHANGES.md
@@ -0,0 +1,43 @@
+# 7.0.5 - 20 Sep 2024
+
+## Features
+
+* Use a slightly more strict type for `AnyJson`
+
+# 7.0.4 - 18 Sep 2024
+
+## Chores
+
+* Add a `py.typed` file
+
+# 7.0.3 - 18 Sep 2024
+
+## Fixes
+
+* `ops.Model.get_relation` should not raise when a relation with the specified ID does not exist
+
+# 7.0.2 - 13 Sep 2024
+
+## Refactor
+
+* Adjustments to handle the upcoming release of ops 2.17
+
+# 7.0.1 - 9 Sep 2024
+
+## Fixes
+
+* Fix broken Python 3.8 compatibility.
+
+# 7.0.0 - 9 Sep 2024
+
+## Features
+
+* Support for testing Pebble check events
+* Container exec mocking can match against a command prefix
+* Inspect a list of the commands that a charm has `exec`'d in a container
+* Add consistency checks for `StoredState`
+* Specifying your event is now done via `ctx.on` attributes
+* The context manager is accessed via the `Context` object
+* State collections are frozensets instead of lists
+* Most classes now expect at least some arguments to be passed as keywords
+* Secret tests are much simpler - particularly, revision numbers do not need to be managed
diff --git a/testing/src/scenario/__init__.py b/testing/src/scenario/__init__.py
index b4dff01..2cf042c 100644
--- a/testing/src/scenario/__init__.py
+++ b/testing/src/scenario/__init__.py
@@ -60,9 +60,10 @@ status is `unknown`. With that, we can write the simplest possible test:
         assert out.unit_status == UnknownStatus()
 """
 
+from ops._private.harness import ActionFailed  # For backwards compatibility.
+
 from .context import CharmEvents, Context, Manager
 from .errors import StateValidationError  # For backwards compatibility.
-from ops._private.harness import ActionFailed  # For backwards compatibility.
 from .state import (
     ActiveStatus,
     Address,
diff --git a/testing/src/scenario/_consistency_checker.py b/testing/src/scenario/_consistency_checker.py
index 81df133..335c9c2 100644
--- a/testing/src/scenario/_consistency_checker.py
+++ b/testing/src/scenario/_consistency_checker.py
@@ -170,14 +170,16 @@ def check_event_consistency(
     errors: List[str] = []
     warnings: List[str] = []
 
-    # custom event: can't make assumptions about its name and its semantics
-    # todo: should we then just skip the other checks?
     if not event._is_builtin_event(charm_spec):
-        warnings.append(
-            "this is a custom event; if its name makes it look like a builtin one "
-            "(e.g. a relation event, or a workload event), you might get some false-negative "
-            "consistency checks.",
-        )
+        # This is a custom event - we can't make assumptions about its name and
+        # semantics. It doesn't really make sense to do checks that are designed
+        # for relations, workloads, and so on - most likely those will end up
+        # with false positives. Realistically, we can't know about what the
+        # requirements for the custom event are (in terms of the state), so we
+        # skip everything here. Perhaps in the future, custom events could
+        # optionally include some sort of state metadata that made testing
+        # consistency possible?
+        return Results(errors, warnings)
 
     if event._is_relation_event:
         _check_relation_event(charm_spec, event, state, errors, warnings)
diff --git a/testing/src/scenario/context.py b/testing/src/scenario/context.py
index ef0ffca..8087480 100644
--- a/testing/src/scenario/context.py
+++ b/testing/src/scenario/context.py
@@ -2,6 +2,12 @@
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
 
+"""Test Context
+
+The test `Context` object provides the context of the wider Juju system that the
+specific `State` exists in, and the events that can be executed on that `State`.
+"""
+
 from __future__ import annotations
 
 import functools
@@ -9,11 +15,11 @@ import tempfile
 from contextlib import contextmanager
 from pathlib import Path
 from typing import (
+    Generic,
     TYPE_CHECKING,
     Any,
     Callable,
     Mapping,
-    cast,
 )
 
 import ops
@@ -43,7 +49,6 @@ if TYPE_CHECKING:  # pragma: no cover
     from .ops_main_mock import Ops
     from .state import (
         AnyJson,
-        CharmType,
         JujuLogLine,
         RelationBase,
         State,
@@ -55,7 +60,7 @@ logger = scenario_logger.getChild("runtime")
 _DEFAULT_JUJU_VERSION = "3.5"
 
 
-class Manager:
+class Manager(Generic[CharmType]):
     """Context manager to offer test code some runtime charm object introspection.
 
     This class should not be instantiated directly: use a :class:`Context`
@@ -69,7 +74,7 @@ class Manager:
 
     def __init__(
         self,
-        ctx: Context,
+        ctx: Context[CharmType],
         arg: _Event,
         state_in: State,
     ):
@@ -78,20 +83,21 @@ class Manager:
         self._state_in = state_in
 
         self._emitted: bool = False
+        self._wrapped_ctx = None
 
-        self.ops: Ops | None = None
+        self.ops: Ops[CharmType] | None = None
 
     @property
-    def charm(self) -> ops.CharmBase:
+    def charm(self) -> CharmType:
         """The charm object instantiated by ops to handle the event.
 
         The charm is only available during the context manager scope.
         """
-        if not self.ops:
+        if self.ops is None or self.ops.charm is None:
             raise RuntimeError(
                 "you should __enter__ this context manager before accessing this",
             )
-        return cast(ops.CharmBase, self.ops.charm)
+        return self.ops.charm
 
     @property
     def _runner(self):
@@ -99,8 +105,7 @@ class Manager:
 
     def __enter__(self):
         self._wrapped_ctx = wrapped_ctx = self._runner(self._arg, self._state_in)
-        ops = wrapped_ctx.__enter__()
-        self.ops = ops
+        self.ops = wrapped_ctx.__enter__()
         return self
 
     def run(self) -> State:
@@ -113,6 +118,7 @@ class Manager:
         self._emitted = True
 
         # wrap up Runtime.exec() so that we can gather the output state
+        assert self._wrapped_ctx is not None
         self._wrapped_ctx.__exit__(None, None, None)
 
         assert self._ctx._output_state is not None
@@ -355,7 +361,7 @@ class CharmEvents:
         return _Event(f"{name}_action", action=_Action(name, **kwargs))
 
 
-class Context:
+class Context(Generic[CharmType]):
     """Represents a simulated charm's execution context.
 
     The main entry point to running a test. It contains:
@@ -565,7 +571,7 @@ class Context:
         else:
             self.unit_status_history.append(state.unit_status)
 
-    def __call__(self, event: _Event, state: State):
+    def __call__(self, event: _Event, state: State) -> Manager[CharmType]:
         """Context manager to introspect live charm object before and after the event is emitted.
 
         Usage::
@@ -656,8 +662,8 @@ class Context:
             if self.action_results is not None:
                 self.action_results.clear()
             self._action_failure_message = None
-        with self._run(event=event, state=state) as ops:
-            ops.emit()
+        with self._run(event=event, state=state) as manager:
+            manager.emit()
         # We know that the output state will have been set by this point,
         # so let the type checkers know that too.
         assert self._output_state is not None
diff --git a/testing/src/scenario/logger.py b/testing/src/scenario/logger.py
index daa272b..a7909f3 100644
--- a/testing/src/scenario/logger.py
+++ b/testing/src/scenario/logger.py
@@ -2,6 +2,8 @@
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
 
+"""Test framework logger"""
+
 import logging
 import os
 
diff --git a/testing/src/scenario/mocking.py b/testing/src/scenario/mocking.py
index 7542257..b95b553 100644
--- a/testing/src/scenario/mocking.py
+++ b/testing/src/scenario/mocking.py
@@ -2,6 +2,12 @@
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
 
+"""Juju and Pebble mocking
+
+This module contains mocks for the Juju and Pebble APIs that are used by ops
+to interact with the Juju controller and the Pebble service manager.
+"""
+
 import datetime
 import io
 import shutil
@@ -33,6 +39,7 @@ from ops import (
     ModelError,
 )
 from ops._private.harness import ExecArgs, _TestingPebbleClient
+from ops.jujucontext import _JujuContext
 from ops.model import CloudSpec as CloudSpec_Ops
 from ops.model import Port as Port_Ops
 from ops.model import Secret as Secret_Ops  # lol
@@ -96,12 +103,14 @@ class _MockExecProcess:
             self._args.stdin = self.stdin.read()
 
     def wait(self):
+        """Wait for the (mock) process to finish."""
         self._close_stdin()
         self._waited = True
         if self._return_code != 0:
             raise ExecError(list(self._args.command), self._return_code, None, None)
 
     def wait_output(self):
+        """Wait for the (mock) process to finish and return tuple of (stdout, stderr)."""
         self._close_stdin()
         self._waited = True
         stdout = self.stdout.read() if self.stdout is not None else None
@@ -116,6 +125,7 @@ class _MockExecProcess:
         return stdout, stderr
 
     def send_signal(self, sig: Union[int, str]) -> NoReturn:  # noqa: U100
+        """Send the given signal to the (mock) process."""
         raise NotImplementedError()
 
 
@@ -130,8 +140,9 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
         event: "_Event",
         charm_spec: "_CharmSpec[CharmType]",
         context: "Context",
+        juju_context: "_JujuContext",
     ):
-        super().__init__()
+        super().__init__(juju_context=juju_context)
         self._state = state
         self._event = event
         self._context = context
@@ -148,8 +159,6 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
         protocol: "_RawPortProtocolLiteral",
         port: Optional[int] = None,
     ):
-        # fixme: the charm will get hit with a StateValidationError
-        #  here, not the expected ModelError...
         port_ = _port_cls_by_protocol[protocol](port=port)  # type: ignore
         ports = set(self._state.opened_ports)
         if port_ not in ports:
@@ -201,10 +210,8 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
             raise RelationNotFoundError() from None
 
     def _get_secret(self, id: Optional[str] = None, label: Optional[str] = None):
-        # FIXME: what error would a charm get IRL?
-        # ops 2.0 supports secrets, but juju only supports it from 3.0.2
         if self._context.juju_version < "3.0.2":
-            raise RuntimeError(
+            raise ModelError(
                 "secrets are only available in juju >= 3.0.2."
                 "Set ``Context.juju_version`` to 3.0.2+ to use them.",
             )
@@ -225,16 +232,15 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
                 raise SecretNotFoundError(id)
             return secrets[0]
 
-        elif label:
+        if label:
             try:
                 return self._state.get_secret(label=label)
             except KeyError:
                 raise SecretNotFoundError(label) from None
 
-        else:
-            # if all goes well, this should never be reached. ops.model.Secret will check upon
-            # instantiation that either an id or a label are set, and raise a TypeError if not.
-            raise RuntimeError("need id or label.")
+        # if all goes well, this should never be reached. ops.model.Secret will check upon
+        # instantiation that either an id or a label are set, and raise a TypeError if not.
+        raise RuntimeError("need id or label.")
 
     def _check_app_data_access(self, is_app: bool):
         if not isinstance(is_app, bool):
@@ -254,14 +260,13 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
         relation = self._get_relation_by_id(relation_id)
         if is_app and member_name == self.app_name:
             return relation.local_app_data
-        elif is_app:
+        if is_app:
             if isinstance(relation, PeerRelation):
                 return relation.local_app_data
-            elif isinstance(relation, (Relation, SubordinateRelation)):
+            if isinstance(relation, (Relation, SubordinateRelation)):
                 return relation.remote_app_data
-            else:
-                raise TypeError("relation_get: unknown relation type")
-        elif member_name == self.unit_name:
+            raise TypeError("relation_get: unknown relation type")
+        if member_name == self.unit_name:
             return relation.local_unit_data
 
         unit_id = int(member_name.split("/")[-1])
@@ -384,7 +389,6 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
         else:
             tgt = relation.local_unit_data
         tgt[key] = value
-        return None
 
     def secret_add(
         self,
@@ -581,10 +585,9 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
 
         if isinstance(relation, PeerRelation):
             return self.app_name
-        elif isinstance(relation, (Relation, SubordinateRelation)):
+        if isinstance(relation, (Relation, SubordinateRelation)):
             return relation.remote_app_name
-        else:
-            raise TypeError("relation_remote_app_name: unknown relation type")
+        raise TypeError("relation_remote_app_name: unknown relation type")
 
     def action_set(self, results: Dict[str, Any]):
         if not self._event.action:
@@ -703,7 +706,6 @@ class _MockModelBackend(_ModelBackend):  # type: ignore
             "it's deprecated API)",
         )
 
-    # TODO: It seems like this method has no tests.
     def resource_get(self, resource_name: str) -> str:
         # We assume that there are few enough resources that a linear search
         # will perform well enough.
diff --git a/testing/src/scenario/ops_main_mock.py b/testing/src/scenario/ops_main_mock.py
index 58bb1d0..5e4846e 100644
--- a/testing/src/scenario/ops_main_mock.py
+++ b/testing/src/scenario/ops_main_mock.py
@@ -6,7 +6,7 @@ import inspect
 import os
 import pathlib
 import sys
-from typing import TYPE_CHECKING, Any, Optional, Sequence, Type, cast
+from typing import TYPE_CHECKING, Any, Generic, Optional, Sequence, Type, cast
 
 import ops.charm
 import ops.framework
@@ -22,25 +22,15 @@ from ops.charm import CharmMeta
 from ops.log import setup_root_logging
 
 from .errors import BadOwnerPath, NoObserverError
+from .state import CharmType
 
 if TYPE_CHECKING:  # pragma: no cover
     from .context import Context
-    from .state import CharmType, State, _CharmSpec, _Event
+    from .state import State, _CharmSpec, _Event
 
 # pyright: reportPrivateUsage=false
 
 
-# TODO: Use ops.jujucontext's _JujuContext.charm_dir.
-def _get_charm_dir():
-    charm_dir = os.environ.get("JUJU_CHARM_DIR")
-    if charm_dir is None:
-        # Assume $JUJU_CHARM_DIR/lib/op/main.py structure.
-        charm_dir = pathlib.Path(f"{__file__}/../../..").resolve()
-    else:
-        charm_dir = pathlib.Path(charm_dir).resolve()
-    return charm_dir
-
-
 def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents:
     """Walk path on root to an ObjectEvents instance."""
     obj = root
@@ -62,6 +52,7 @@ def _get_owner(root: Any, path: Sequence[str]) -> ops.ObjectEvents:
 def _emit_charm_event(
     charm: "CharmBase",
     event_name: str,
+    juju_context: ops.jujucontext._JujuContext,
     event: Optional["_Event"] = None,
 ):
     """Emits a charm event based on a Juju event name.
@@ -69,7 +60,8 @@ def _emit_charm_event(
     Args:
         charm: A charm instance to emit an event from.
         event_name: A Juju event name to emit on a charm.
-        event_owner_path: Event source lookup path.
+        event: Event to emit.
+        juju_context: Juju context to use for the event.
     """
     owner = _get_owner(charm, event.owner_path) if event else charm.on
 
@@ -82,11 +74,7 @@ def _emit_charm_event(
             f"invalid event (not on charm.on).",
         )
 
-    args, kwargs = _get_event_args(
-        charm,
-        event_to_emit,
-        ops.jujucontext._JujuContext.from_dict(os.environ),
-    )
+    args, kwargs = _get_event_args(charm, event_to_emit, juju_context)
     ops_logger.debug("Emitting Juju event %s.", event_name)
     event_to_emit.emit(*args, **kwargs)
 
@@ -95,19 +83,22 @@ def setup_framework(
     charm_dir: pathlib.Path,
     state: "State",
     event: "_Event",
-    context: "Context",
+    context: "Context[CharmType]",
     charm_spec: "_CharmSpec[CharmType]",
+    juju_context: Optional[ops.jujucontext._JujuContext] = None,
 ):
     from .mocking import _MockModelBackend
 
+    if juju_context is None:
+        juju_context = ops.jujucontext._JujuContext.from_dict(os.environ)
     model_backend = _MockModelBackend(
         state=state,
         event=event,
         context=context,
         charm_spec=charm_spec,
+        juju_context=juju_context,
     )
-    debug = "JUJU_DEBUG" in os.environ
-    setup_root_logging(model_backend, debug=debug)
+    setup_root_logging(model_backend, debug=juju_context.debug)
     # ops sets sys.excepthook to go to Juju's debug-log, but that's not useful
     # in a testing context, so reset it.
     sys.excepthook = sys.__excepthook__
@@ -170,43 +161,49 @@ def setup_charm(
 def setup(
     state: "State",
     event: "_Event",
-    context: "Context",
+    context: "Context[CharmType]",
     charm_spec: "_CharmSpec[CharmType]",
+    juju_context: Optional[ops.jujucontext._JujuContext] = None,
 ):
     """Setup dispatcher, framework and charm objects."""
     charm_class = charm_spec.charm_type
-    charm_dir = _get_charm_dir()
+    if juju_context is None:
+        juju_context = ops.jujucontext._JujuContext.from_dict(os.environ)
+    charm_dir = juju_context.charm_dir
 
-    dispatcher = _Dispatcher(
-        charm_dir,
-        ops.jujucontext._JujuContext.from_dict(os.environ),
-    )
+    dispatcher = _Dispatcher(charm_dir, juju_context)
     dispatcher.run_any_legacy_hook()
 
-    framework = setup_framework(charm_dir, state, event, context, charm_spec)
+    framework = setup_framework(
+        charm_dir, state, event, context, charm_spec, juju_context
+    )
     charm = setup_charm(charm_class, framework, dispatcher)
     return dispatcher, framework, charm
 
 
-class Ops:
+class Ops(Generic[CharmType]):
     """Class to manage stepping through ops setup, event emission and framework commit."""
 
     def __init__(
         self,
         state: "State",
         event: "_Event",
-        context: "Context",
+        context: "Context[CharmType]",
         charm_spec: "_CharmSpec[CharmType]",
+        juju_context: Optional[ops.jujucontext._JujuContext] = None,
     ):
         self.state = state
         self.event = event
         self.context = context
         self.charm_spec = charm_spec
+        if juju_context is None:
+            juju_context = ops.jujucontext._JujuContext.from_dict(os.environ)
+        self.juju_context = juju_context
 
         # set by setup()
         self.dispatcher: Optional[_Dispatcher] = None
         self.framework: Optional[ops.Framework] = None
-        self.charm: Optional[ops.CharmBase] = None
+        self.charm: Optional["CharmType"] = None
 
         self._has_setup = False
         self._has_emitted = False
@@ -220,6 +217,7 @@ class Ops:
             self.event,
             self.context,
             self.charm_spec,
+            self.juju_context,
         )
 
     def emit(self):
@@ -236,7 +234,9 @@ class Ops:
             if not dispatcher.is_restricted_context():
                 framework.reemit()
 
-            _emit_charm_event(charm, dispatcher.event_name, self.event)
+            _emit_charm_event(
+                charm, dispatcher.event_name, self.juju_context, self.event
+            )
 
         except Exception:
             framework.close()
diff --git a/testing/src/scenario/runtime.py b/testing/src/scenario/runtime.py
index 1206c11..3ad2fd0 100644
--- a/testing/src/scenario/runtime.py
+++ b/testing/src/scenario/runtime.py
@@ -2,10 +2,11 @@
 # Copyright 2023 Canonical Ltd.
 # See LICENSE file for licensing details.
 
+"""Test framework runtime."""
+
 import copy
 import dataclasses
 import marshal
-import os
 import re
 import tempfile
 import typing
@@ -35,6 +36,7 @@ from ops import (
     NoTypeError,
     PreCommitEvent,
 )
+from ops.jujucontext import _JujuContext
 from ops.storage import NoSnapshotError, SQLiteStorage
 from ops.framework import _event_regex
 from ops._private.harness import ActionFailed
@@ -151,6 +153,10 @@ class _OpsMainContext:  # type: ignore
         pass
 
     def emit(self):
+        """Emit the event.
+
+        Within the test framework, this only requires recording that it was emitted.
+        """
         self._has_emitted = True
 
     def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):  # noqa: U100
@@ -183,15 +189,6 @@ class Runtime:
         self._app_name = app_name
         self._unit_id = unit_id
 
-    @staticmethod
-    def _cleanup_env(env: Dict[str, str]):
-        # TODO consider cleaning up env on __delete__, but ideally you should be
-        #  running this in a clean env or a container anyway.
-        # cleanup the env, in case we'll be firing multiple events, we don't want to pollute it.
-        for key in env:
-            # os.unsetenv does not always seem to work !?
-            del os.environ[key]
-
     def _get_event_env(self, state: "State", event: "_Event", charm_root: Path):
         """Build the simulated environment the operator framework expects."""
         env = {
@@ -202,13 +199,11 @@ class Runtime:
             "JUJU_MODEL_NAME": state.model.name,
             "JUJU_MODEL_UUID": state.model.uuid,
             "JUJU_CHARM_DIR": str(charm_root.absolute()),
-            # todo consider setting pwd, (python)path
         }
 
         if event._is_action_event and (action := event.action):
             env.update(
                 {
-                    # TODO: we should check we're doing the right thing here.
                     "JUJU_ACTION_NAME": action.name.replace("_", "-"),
                     "JUJU_ACTION_UUID": action.id,
                 },
@@ -254,7 +249,7 @@ class Runtime:
                 else:
                     logger.warning(
                         "remote unit ID unset; no remote unit data present. "
-                        "Is this a realistic scenario?",  # TODO: is it?
+                        "Is this a realistic scenario?",
                     )
 
             if remote_unit_id is not None:
@@ -306,14 +301,17 @@ class Runtime:
     @staticmethod
     def _wrap(charm_type: Type["CharmType"]) -> Type["CharmType"]:
         # dark sorcery to work around framework using class attrs to hold on to event sources
-        # todo this should only be needed if we call play multiple times on the same runtime.
-        #  can we avoid it?
+        # this should only be needed if we call play multiple times on the same runtime.
         class WrappedEvents(charm_type.on.__class__):
+            """The charm's event sources, but wrapped."""
+
             pass
 
         WrappedEvents.__name__ = charm_type.on.__class__.__name__
 
         class WrappedCharm(charm_type):
+            """The test charm's type, but with events wrapped."""
+
             on = WrappedEvents()
 
         WrappedCharm.__name__ = charm_type.__name__
@@ -411,7 +409,6 @@ class Runtime:
     def _exec_ctx(self, ctx: "Context"):
         """python 3.8 compatibility shim"""
         with self._virtual_charm_root() as temporary_charm_root:
-            # TODO: allow customising capture_events
             with _capture_events(
                 include_deferred=ctx.capture_deferred_events,
                 include_framework=ctx.capture_framework_events,
@@ -430,12 +427,9 @@ class Runtime:
         Returns the 'output state', that is, the state as mutated by the charm during the
         event handling.
 
-        This will set the environment up and call ops.main.main().
+        This will set the environment up and call ops.main().
         After that it's up to ops.
         """
-        # todo consider forking out a real subprocess and do the mocking by
-        #  mocking hook tool executables
-
         from ._consistency_checker import check_consistency  # avoid cycles
 
         check_consistency(state, event, self._charm_spec, self._juju_version)
@@ -457,7 +451,7 @@ class Runtime:
                 event=event,
                 charm_root=temporary_charm_root,
             )
-            os.environ.update(env)
+            juju_context = _JujuContext.from_dict(env)
 
             logger.info(" - Entering ops.main (mocked).")
             from .ops_main_mock import Ops  # noqa: F811
@@ -471,6 +465,7 @@ class Runtime:
                         self._charm_spec,
                         charm_type=self._wrap(charm_type),
                     ),
+                    juju_context=juju_context,
                 )
                 ops.setup()
 
@@ -489,9 +484,6 @@ class Runtime:
             finally:
                 logger.info(" - Exited ops.main.")
 
-                logger.info(" - Clearing env")
-                self._cleanup_env(env)
-
             logger.info(" - closing storage")
             output_state = self._close_storage(output_state, temporary_charm_root)
 
diff --git a/testing/src/scenario/state.py b/testing/src/scenario/state.py
index 29489ba..9d192e9 100644
--- a/testing/src/scenario/state.py
+++ b/testing/src/scenario/state.py
@@ -35,8 +35,9 @@ from typing import (
 )
 from uuid import uuid4
 
-import ops
 import yaml
+
+import ops
 from ops import pebble, CharmBase, CharmEvents, SecretRotate, StatusBase
 from ops import CloudCredential as CloudCredential_Ops
 from ops import CloudSpec as CloudSpec_Ops
@@ -152,7 +153,7 @@ def _max_posargs(n: int):
             # Also check if there are just not enough arguments at all, because
             # the default TypeError message will incorrectly describe some of
             # the arguments as positional.
-            elif n_posargs < len(required_args):
+            if n_posargs < len(required_args):
                 required_pos = [
                     f"'{arg}'"
                     for arg in required_args[n_posargs:]
@@ -355,9 +356,6 @@ class Secret(_max_posargs(1)):
         """Update the metadata."""
         # bypass frozen dataclass
         object.__setattr__(self, "_latest_revision", self._latest_revision + 1)
-        # TODO: if this is done twice in the same hook, then Juju ignores the
-        # first call, it doesn't continue to update like this does.
-        # Fix when https://github.com/canonical/operator/issues/1288 is resolved.
         if content:
             object.__setattr__(self, "latest_content", content)
         if label:
@@ -410,8 +408,7 @@ class BindAddress(_max_posargs(1)):
     """The MAC address of the interface."""
 
     def _hook_tool_output_fmt(self):
-        # dumps itself to dict in the same format the hook tool would
-        # todo support for legacy (deprecated) `interfacename` and `macaddress` fields?
+        """Dumps itself to dict in the same format the hook tool would."""
         dct = {
             "interface-name": self.interface_name,
             "addresses": [dataclasses.asdict(addr) for addr in self.addresses],
@@ -472,6 +469,8 @@ def _next_relation_id(*, update: bool = True):
 
 @dataclasses.dataclass(frozen=True)
 class RelationBase(_max_posargs(2)):
+    """Base class for the various types of integration (relation)."""
+
     endpoint: str
     """Relation endpoint name. Must match some endpoint name defined in the metadata."""
 
@@ -687,7 +686,6 @@ class Model(_max_posargs(1)):
     """A unique identifier for the model, typically generated by Juju."""
 
     # whatever juju models --format=json | jq '.models[<current-model-index>].type' gives back.
-    # TODO: make this exhaustive.
     type: Literal["kubernetes", "lxd"] = "kubernetes"
     """The type of Juju model."""
 
@@ -695,10 +693,6 @@ class Model(_max_posargs(1)):
     """Cloud specification information (metadata) including credentials."""
 
 
-# for now, proc mock allows you to map one command to one mocked output.
-# todo extend: one input -> multiple outputs, at different times
-
-
 _CHANGE_IDS = 0
 
 
@@ -1072,12 +1066,14 @@ class _EntityStatus:
         name: _RawStatusLiteral,
         message: str = "",
     ) -> _EntityStatus:
+        """Convert the status name, such as 'active', to the class, such as ActiveStatus."""
         # Note that this won't work for UnknownStatus.
         # All subclasses have a default 'name' attribute, but the type checker can't tell that.
         return cls._entity_statuses[name](message=message)  # type:ignore
 
     @classmethod
     def from_ops(cls, obj: StatusBase) -> _EntityStatus:
+        """Convert from the ops.StatusBase object to the matching _EntityStatus object."""
         return cls.from_status_name(obj.name, obj.message)
 
 
@@ -1692,18 +1688,19 @@ class DeferredEvent:
     # them because they need a Handle.
     @property
     def name(self):
+        """A comparable name for the event."""
         return self.handle_path.split("/")[-1].split("[")[0]
 
 
 class _EventType(str, Enum):
-    framework = "framework"
-    builtin = "builtin"
-    relation = "relation"
-    action = "action"
-    secret = "secret"
-    storage = "storage"
-    workload = "workload"
-    custom = "custom"
+    FRAMEWORK = "framework"
+    BUILTIN = "builtin"
+    RELATION = "relation"
+    ACTION = "action"
+    SECRET = "secret"
+    STORAGE = "storage"
+    WORKLOAD = "workload"
+    CUSTOM = "custom"
 
 
 class _EventPath(str):
@@ -1737,36 +1734,36 @@ class _EventPath(str):
     def _get_suffix_and_type(s: str) -> tuple[str, _EventType]:
         for suffix in _RELATION_EVENTS_SUFFIX:
             if s.endswith(suffix):
-                return suffix, _EventType.relation
+                return suffix, _EventType.RELATION
 
         if s.endswith(_ACTION_EVENT_SUFFIX):
-            return _ACTION_EVENT_SUFFIX, _EventType.action
+            return _ACTION_EVENT_SUFFIX, _EventType.ACTION
 
         if s in _SECRET_EVENTS:
-            return s, _EventType.secret
+            return s, _EventType.SECRET
 
         if s in _FRAMEWORK_EVENTS:
-            return s, _EventType.framework
+            return s, _EventType.FRAMEWORK
 
         # Whether the event name indicates that this is a storage event.
         for suffix in _STORAGE_EVENTS_SUFFIX:
             if s.endswith(suffix):
-                return suffix, _EventType.storage
+                return suffix, _EventType.STORAGE
 
         # Whether the event name indicates that this is a workload event.
         if s.endswith(_PEBBLE_READY_EVENT_SUFFIX):
-            return _PEBBLE_READY_EVENT_SUFFIX, _EventType.workload
+            return _PEBBLE_READY_EVENT_SUFFIX, _EventType.WORKLOAD
         if s.endswith(_PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX):
-            return _PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.workload
+            return _PEBBLE_CUSTOM_NOTICE_EVENT_SUFFIX, _EventType.WORKLOAD
         if s.endswith(_PEBBLE_CHECK_FAILED_EVENT_SUFFIX):
-            return _PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.workload
+            return _PEBBLE_CHECK_FAILED_EVENT_SUFFIX, _EventType.WORKLOAD
         if s.endswith(_PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX):
-            return _PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.workload
+            return _PEBBLE_CHECK_RECOVERED_EVENT_SUFFIX, _EventType.WORKLOAD
 
         if s in _BUILTIN_EVENTS:
-            return "", _EventType.builtin
+            return "", _EventType.BUILTIN
 
-        return "", _EventType.custom
+        return "", _EventType.CUSTOM
 
 
 @dataclasses.dataclass(frozen=True)
@@ -1843,27 +1840,27 @@ class _Event:  # type: ignore
     @property
     def _is_relation_event(self) -> bool:
         """Whether the event name indicates that this is a relation event."""
-        return self._path.type is _EventType.relation
+        return self._path.type is _EventType.RELATION
 
     @property
     def _is_action_event(self) -> bool:
         """Whether the event name indicates that this is a relation event."""
-        return self._path.type is _EventType.action
+        return self._path.type is _EventType.ACTION
 
     @property
     def _is_secret_event(self) -> bool:
         """Whether the event name indicates that this is a secret event."""
-        return self._path.type is _EventType.secret
+        return self._path.type is _EventType.SECRET
 
     @property
     def _is_storage_event(self) -> bool:
         """Whether the event name indicates that this is a storage event."""
-        return self._path.type is _EventType.storage
+        return self._path.type is _EventType.STORAGE
 
     @property
     def _is_workload_event(self) -> bool:
         """Whether the event name indicates that this is a workload event."""
-        return self._path.type is _EventType.workload
+        return self._path.type is _EventType.WORKLOAD
 
     # this method is private because _CharmSpec is not quite user-facing; also,
     # the user should know.
@@ -1884,7 +1881,7 @@ class _Event:  # type: ignore
         # However, our Event data structure ATM has no knowledge of which Object/Handle it is
         # owned by. So the only thing we can do right now is: check whether the event name,
         # assuming it is owned by the charm, LOOKS LIKE that of a builtin event or not.
-        return self._path.type is not _EventType.custom
+        return self._path.type is not _EventType.CUSTOM
 
     def deferred(self, handler: Callable[..., Any], event_id: int = 1) -> DeferredEvent:
         """Construct a DeferredEvent from this Event."""
diff --git a/testing/tests/test_e2e/test_event.py b/testing/tests/test_e2e/test_event.py
index 2efc728..3151b49 100644
--- a/testing/tests/test_e2e/test_event.py
+++ b/testing/tests/test_e2e/test_event.py
@@ -9,34 +9,34 @@ from scenario.state import State, _CharmSpec, _Event, _EventType
 @pytest.mark.parametrize(
     "evt, expected_type",
     (
-        ("foo_relation_changed", _EventType.relation),
-        ("foo_relation_created", _EventType.relation),
-        ("foo_bar_baz_relation_created", _EventType.relation),
-        ("foo_storage_attached", _EventType.storage),
-        ("foo_storage_detaching", _EventType.storage),
-        ("foo_bar_baz_storage_detaching", _EventType.storage),
-        ("foo_pebble_ready", _EventType.workload),
-        ("foo_bar_baz_pebble_ready", _EventType.workload),
-        ("foo_pebble_custom_notice", _EventType.workload),
-        ("foo_bar_baz_pebble_custom_notice", _EventType.workload),
-        ("secret_remove", _EventType.secret),
-        ("pre_commit", _EventType.framework),
-        ("commit", _EventType.framework),
-        ("collect_unit_status", _EventType.framework),
-        ("collect_app_status", _EventType.framework),
-        ("foo", _EventType.custom),
-        ("kaboozle_bar_baz", _EventType.custom),
+        ("foo_relation_changed", _EventType.RELATION),
+        ("foo_relation_created", _EventType.RELATION),
+        ("foo_bar_baz_relation_created", _EventType.RELATION),
+        ("foo_storage_attached", _EventType.STORAGE),
+        ("foo_storage_detaching", _EventType.STORAGE),
+        ("foo_bar_baz_storage_detaching", _EventType.STORAGE),
+        ("foo_pebble_ready", _EventType.WORKLOAD),
+        ("foo_bar_baz_pebble_ready", _EventType.WORKLOAD),
+        ("foo_pebble_custom_notice", _EventType.WORKLOAD),
+        ("foo_bar_baz_pebble_custom_notice", _EventType.WORKLOAD),
+        ("secret_remove", _EventType.SECRET),
+        ("pre_commit", _EventType.FRAMEWORK),
+        ("commit", _EventType.FRAMEWORK),
+        ("collect_unit_status", _EventType.FRAMEWORK),
+        ("collect_app_status", _EventType.FRAMEWORK),
+        ("foo", _EventType.CUSTOM),
+        ("kaboozle_bar_baz", _EventType.CUSTOM),
     ),
 )
 def test_event_type(evt, expected_type):
     event = _Event(evt)
     assert event._path.type is expected_type
 
-    assert event._is_relation_event is (expected_type is _EventType.relation)
-    assert event._is_storage_event is (expected_type is _EventType.storage)
-    assert event._is_workload_event is (expected_type is _EventType.workload)
-    assert event._is_secret_event is (expected_type is _EventType.secret)
-    assert event._is_action_event is (expected_type is _EventType.action)
+    assert event._is_relation_event is (expected_type is _EventType.RELATION)
+    assert event._is_storage_event is (expected_type is _EventType.STORAGE)
+    assert event._is_workload_event is (expected_type is _EventType.WORKLOAD)
+    assert event._is_secret_event is (expected_type is _EventType.SECRET)
+    assert event._is_action_event is (expected_type is _EventType.ACTION)
 
     class MyCharm(CharmBase):
         pass
@@ -55,7 +55,7 @@ def test_event_type(evt, expected_type):
             "containers": {"foo": {}, "foo_bar_baz": {}},
         },
     )
-    assert event._is_builtin_event(spec) is (expected_type is not _EventType.custom)
+    assert event._is_builtin_event(spec) is (expected_type is not _EventType.CUSTOM)
 
 
 def test_emitted_framework():
diff --git a/testing/tests/test_runtime.py b/testing/tests/test_runtime.py
index 11c4603..b303fad 100644
--- a/testing/tests/test_runtime.py
+++ b/testing/tests/test_runtime.py
@@ -85,11 +85,11 @@ def test_unit_name(app_name, unit_id):
         state=State(),
         event=_Event("start"),
         context=Context(my_charm_type, meta=meta),
-    ) as ops:
-        assert ops.charm.unit.name == f"{app_name}/{unit_id}"
+    ) as manager:
+        assert manager.charm.unit.name == f"{app_name}/{unit_id}"
 
 
-def test_env_cleanup_on_charm_error():
+def test_env_clean_on_charm_error():
     meta = {"name": "frank", "requires": {"box": {"interface": "triangle"}}}
 
     my_charm_type = charm_type()
@@ -101,14 +101,19 @@ def test_env_cleanup_on_charm_error():
         ),
     )
 
-    rel = Relation("box")
-    with pytest.raises(UncaughtCharmError):
+    remote_name = "ava"
+    rel = Relation("box", remote_app_name=remote_name)
+    with pytest.raises(UncaughtCharmError) as exc:
         with runtime.exec(
             state=State(relations={rel}),
             event=_Event("box_relation_changed", relation=rel),
             context=Context(my_charm_type, meta=meta),
-        ):
-            assert os.getenv("JUJU_REMOTE_APP")
+        ) as manager:
+            assert manager.juju_context.remote_app_name == remote_name
+            assert "JUJU_REMOTE_APP" not in os.environ
             _ = 1 / 0  # raise some error
+    # Ensure that some other error didn't occur (like AssertionError!).
+    assert "ZeroDivisionError" in str(exc.value)
 
+    # Ensure that the Juju environment didn't leak into the outside one.
     assert os.getenv("JUJU_REMOTE_APP", None) is None