Repository: canonical/operator
Pull request #1323 (2024-08-20 06:24:49)

docs: Live reload with sphinx-autobuild
- [x] it works 🎉 
- [x] sphinx-autobuild was already included in pyproject.toml, was there already a way to rebuild on change?
- [x] let's paint that bike shed and decide on the `tox -e <name>` for this

dimaqq: WDYT folks, do we need it or not?
benhoyt: I'm not opposed to it. I think I would have used this when working on docs(trings). `docs-live` is okay. Or maybe `docs-server`?
tonyandrewmeyer: I'm not opposed to this, but I doubt I would use it. I tend to write, then do `tox -e docs` and read the output to make sure I didn't mess up a reference etc and then refresh the page in my browser. It doesn't seem like this would change that workflow enough that I would make use of it. But again, not opposed to it if it would be useful for others.

`docs-live-reload`?

Patch:
diff --git a/ops/__init__.py b/ops/__init__.py
index c4b0d95..8fccdc6 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -175,7 +175,7 @@ __all__ = [  # noqa: RUF022 `__all__` is not sorted
 # isort:skip_file
 
 # Import pebble explicitly. It's the one module we don't import names from below.
-from . import pebble  # type: ignore
+from . import pebble
 
 # Also import charm explicitly. This is not strictly necessary as the
 # "from .charm" import automatically does that, but be explicit since this
@@ -184,7 +184,7 @@ from . import charm  # type: ignore # noqa: F401 `.charm` imported but unused
 
 # Import the main module, which we've overriden in main.py to be callable.
 # This allows "import ops; ops.main(Charm)" to work as expected.
-from . import main  # type: ignore
+from . import main
 
 # Explicitly import names from submodules so users can just "import ops" and
 # then use them as "ops.X".
diff --git a/ops/_private/yaml.py b/ops/_private/yaml.py
index 1ae0f88..f3ceadb 100644
--- a/ops/_private/yaml.py
+++ b/ops/_private/yaml.py
@@ -25,7 +25,7 @@ _safe_dumper = getattr(yaml, 'CSafeDumper', yaml.SafeDumper)
 
 def safe_load(stream: Union[str, TextIO]) -> Any:
     """Same as yaml.safe_load, but use fast C loader if available."""
-    return yaml.load(stream, Loader=_safe_loader)  # type: ignore  # noqa: S506
+    return yaml.load(stream, Loader=_safe_loader)  # noqa: S506
 
 
 def safe_dump(data: Any, stream: Optional[TextIO] = None) -> str:
diff --git a/ops/charm.py b/ops/charm.py
index 5862b39..b1550c8 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -1650,7 +1650,7 @@ class RelationMeta:
         self.interface_name = raw['interface']
 
         self.limit = limit = raw.get('limit', None)
-        if limit is not None and not isinstance(limit, int):  # type: ignore
+        if limit is not None and not isinstance(limit, int):
             raise TypeError(f'limit should be an int, not {type(limit)}')
 
         self.scope = raw.get('scope') or self._default_scope
diff --git a/ops/framework.py b/ops/framework.py
index 7c96ee5..11c09e5 100644
--- a/ops/framework.py
+++ b/ops/framework.py
@@ -485,7 +485,7 @@ class ObjectEvents(Object):
                 event_kinds.append(attr_name)
         return event_kinds
 
-    def events(self) -> Dict[str, EventSource]:
+    def events(self) -> Dict[str, BoundEvent]:
         """Return a mapping of event_kinds to bound_events for all available events."""
         return {event_kind: getattr(self, event_kind) for event_kind in self._event_kinds()}
 
@@ -789,7 +789,7 @@ class Framework(Object):
         event_kind = bound_event.event_kind
         emitter = bound_event.emitter
 
-        self.register_type(event_type, emitter, event_kind)  # type: ignore
+        self.register_type(event_type, emitter, event_kind)
 
         if hasattr(emitter, 'handle'):
             emitter_path = emitter.handle.path
@@ -824,7 +824,7 @@ class Framework(Object):
         """Return the next event key that should be used, incrementing the internal counter."""
         # Increment the count first; this means the keys will start at 1, and 0
         # means no events have been emitted.
-        self._stored['event_count'] += 1  # type: ignore  #(we know event_count holds an int)
+        self._stored['event_count'] += 1
         return str(self._stored['event_count'])
 
     def _emit(self, event: EventBase):
@@ -956,7 +956,7 @@ class Framework(Object):
                 self._storage.drop_notice(event_path, observer_path, method_name)
             # We intentionally consider this event to be dead and reload it from
             # scratch in the next path.
-            self.framework._forget(event)  # type: ignore
+            self.framework._forget(event)
 
         if not deferred and last_event_path is not None:
             self._storage.drop_snapshot(last_event_path)
@@ -1068,10 +1068,10 @@ class BoundStoredState:
     if TYPE_CHECKING:
         # to help the type checker and IDEs:
         @property
-        def _data(self) -> StoredStateData: ...  # type: ignore
+        def _data(self) -> StoredStateData: ...
 
         @property
-        def _attr_name(self) -> str: ...  # type: ignore
+        def _attr_name(self) -> str: ...
 
     def __init__(self, parent: Object, attr_name: str):
         parent.framework.register_type(StoredStateData, parent)
@@ -1086,7 +1086,7 @@ class BoundStoredState:
         self.__dict__['_data'] = data
         self.__dict__['_attr_name'] = attr_name
 
-        parent.framework.observe(parent.framework.on.commit, self._data.on_commit)  # type: ignore
+        parent.framework.observe(parent.framework.on.commit, self._data.on_commit)
 
     @typing.overload
     def __getattr__(self, key: Literal['on']) -> ObjectEvents:
@@ -1228,14 +1228,14 @@ def _wrap_stored(parent_data: StoredStateData, value: Any) -> Any:
 
 def _unwrap_stored(parent_data: StoredStateData, value: Any) -> Any:
     if isinstance(value, (StoredDict, StoredList, StoredSet)):
-        return value._under  # pyright: ignore[reportPrivateUsage]
+        return value._under
     return value
 
 
 def _wrapped_repr(obj: '_StoredObject') -> str:
     t = type(obj)
     if obj._under:
-        return f'{t.__module__}.{t.__name__}({obj._under!r})'  # type: ignore
+        return f'{t.__module__}.{t.__name__}({obj._under!r})'
     else:
         return f'{t.__module__}.{t.__name__}()'
 
diff --git a/ops/main.py b/ops/main.py
index 94105cb..d232a7f 100644
--- a/ops/main.py
+++ b/ops/main.py
@@ -55,7 +55,7 @@ def _exe_path(path: Path) -> Optional[Path]:
 
 def _create_event_link(
     charm: 'ops.charm.CharmBase',
-    bound_event: 'ops.framework.EventSource',
+    bound_event: 'ops.framework.BoundEvent',
     link_to: Union[str, Path],
 ):
     """Create a symlink for a particular event.
@@ -66,7 +66,7 @@ def _create_event_link(
         link_to: What the event link should point to
     """
     # type guard
-    assert bound_event.event_kind, f'unbound EventSource {bound_event}'
+    assert bound_event.event_kind, f'unbound BoundEvent {bound_event}'
 
     if issubclass(bound_event.event_type, ops.charm.HookEvent):
         event_dir = charm.framework.charm_dir / 'hooks'
@@ -437,7 +437,7 @@ class _Manager:
             self._model_backend, debug=self._juju_context.debug, exc_stderr=handling_action
         )
 
-        logger.debug('ops %s up and running.', ops.__version__)  # type:ignore
+        logger.debug('ops %s up and running.', ops.__version__)
 
     def _make_storage(self, dispatcher: _Dispatcher):
         charm_state_path = self._charm_root / self._charm_state_path
diff --git a/ops/model.py b/ops/model.py
index 3b9c4d8..9102f30 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -896,7 +896,7 @@ class RelationMapping(Mapping[str, List['Relation']]):
         is_peer = relation_name in self._peers
         relation_list: Optional[List[Relation]] = self._data[relation_name]
         if not isinstance(relation_list, list):
-            relation_list = self._data[relation_name] = []  # type: ignore
+            relation_list = self._data[relation_name] = []
             for rid in self._backend.relation_ids(relation_name):
                 if rid == self._broken_relation_id:
                     continue
@@ -1181,6 +1181,7 @@ class SecretInfo:
         expires: Optional[datetime.datetime],
         rotation: Optional[SecretRotate],
         rotates: Optional[datetime.datetime],
+        description: Optional[str] = None,
     ):
         self.id = Secret._canonicalize_id(id)
         self.label = label
@@ -1188,6 +1189,7 @@ class SecretInfo:
         self.expires = expires
         self.rotation = rotation
         self.rotates = rotates
+        self.description = description
 
     @classmethod
     def from_dict(cls, id: str, d: Dict[str, Any]) -> 'SecretInfo':
@@ -1205,6 +1207,7 @@ class SecretInfo:
             expires=timeconv.parse_rfc3339(expires) if expires is not None else None,
             rotation=rotation,
             rotates=timeconv.parse_rfc3339(rotates) if rotates is not None else None,
+            description=typing.cast(Optional[str], d.get('description')),
         )
 
     def __repr__(self):
@@ -1215,7 +1218,8 @@ class SecretInfo:
             f'revision={self.revision}, '
             f'expires={self.expires!r}, '
             f'rotation={self.rotation}, '
-            f'rotates={self.rotates!r})'
+            f'rotates={self.rotates!r}, '
+            f'description={self.description!r})'
         )
 
 
@@ -2079,7 +2083,7 @@ class StorageMapping(Mapping[str, List['Storage']]):
             storage_name: None for storage_name in storage_names
         }
 
-    def __contains__(self, key: str):  # pyright: ignore[reportIncompatibleMethodOverride]
+    def __contains__(self, key: str):
         return key in self._storage_map
 
     def __len__(self):
@@ -2097,7 +2101,7 @@ class StorageMapping(Mapping[str, List['Storage']]):
             storage_list = self._storage_map[storage_name] = []
             for storage_index in self._backend.storage_list(storage_name):
                 storage = Storage(storage_name, storage_index, self._backend)
-                storage_list.append(storage)  # type: ignore
+                storage_list.append(storage)
         return storage_list
 
     def request(self, storage_name: str, count: int = 1):
@@ -3140,7 +3144,7 @@ def _format_action_result_dict(
                 f"duplicate key detected in dictionary passed to 'action-set': {key!r}"
             )
         else:
-            output_[key] = value  # type: ignore
+            output_[key] = value
 
     return output_
 
@@ -3757,7 +3761,7 @@ class _ModelBackendValidator:
 
     @classmethod
     def format_metric_value(cls, value: Union[int, float]):
-        if not isinstance(value, (int, float)):  # pyright: ignore[reportUnnecessaryIsInstance]
+        if not isinstance(value, (int, float)):
             raise ModelError(
                 f'invalid metric value {value!r} provided:' ' must be a positive finite float'
             )
diff --git a/ops/pebble.py b/ops/pebble.py
index 3d8b85c..701b60d 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -17,6 +17,8 @@
 For a command-line interface for local testing, see test/pebble_cli.py.
 """
 
+from __future__ import annotations
+
 import binascii
 import builtins
 import copy
@@ -65,7 +67,7 @@ from typing import (
     Union,
 )
 
-import websocket  # type: ignore
+import websocket
 
 from ops._private import timeconv, yaml
 
@@ -466,10 +468,10 @@ class ChangeError(Error):
     err: str
     """Human-readable error message."""
 
-    change: 'Change'
+    change: Change
     """Change object associated with this error."""
 
-    def __init__(self, err: str, change: 'Change'):
+    def __init__(self, err: str, change: Change):
         """This shouldn't be instantiated directly."""
         self.err = err
         self.change = change
@@ -569,7 +571,7 @@ class SystemInfo:
         self.version = version
 
     @classmethod
-    def from_dict(cls, d: '_SystemInfoDict') -> 'SystemInfo':
+    def from_dict(cls, d: _SystemInfoDict) -> SystemInfo:
         """Create new SystemInfo object from dict parsed from JSON."""
         return cls(version=d['version'])
 
@@ -597,7 +599,7 @@ class Warning:
         self.repeat_after = repeat_after
 
     @classmethod
-    def from_dict(cls, d: '_WarningDict') -> 'Warning':
+    def from_dict(cls, d: _WarningDict) -> Warning:
         """Create new Warning object from dict parsed from JSON."""
         return cls(
             message=d['message'],
@@ -638,7 +640,7 @@ class TaskProgress:
         self.total = total
 
     @classmethod
-    def from_dict(cls, d: '_ProgressDict') -> 'TaskProgress':
+    def from_dict(cls, d: _ProgressDict) -> TaskProgress:
         """Create new TaskProgress object from dict parsed from JSON."""
         return cls(
             label=d['label'],
@@ -688,7 +690,7 @@ class Task:
         self.data = data or {}
 
     @classmethod
-    def from_dict(cls, d: '_TaskDict') -> 'Task':
+    def from_dict(cls, d: _TaskDict) -> Task:
         """Create new Task object from dict parsed from JSON."""
         return cls(
             id=TaskID(d['id']),
@@ -756,7 +758,7 @@ class Change:
         self.data = data or {}
 
     @classmethod
-    def from_dict(cls, d: '_ChangeDict') -> 'Change':
+    def from_dict(cls, d: _ChangeDict) -> Change:
         """Create new Change object from dict parsed from JSON."""
         return cls(
             id=ChangeID(d['id']),
@@ -798,7 +800,7 @@ class Plan:
     documented at https://github.com/canonical/pebble/#layer-specification.
     """
 
-    def __init__(self, raw: Optional[Union[str, 'PlanDict']] = None):
+    def __init__(self, raw: Optional[Union[str, PlanDict]] = None):
         if isinstance(raw, str):  # noqa: SIM108
             d = yaml.safe_load(raw) or {}  # type: ignore
         else:
@@ -817,7 +819,7 @@ class Plan:
         }
 
     @property
-    def services(self) -> Dict[str, 'Service']:
+    def services(self) -> Dict[str, Service]:
         """This plan's services mapping (maps service name to Service).
 
         This property is currently read-only.
@@ -825,7 +827,7 @@ class Plan:
         return self._services
 
     @property
-    def checks(self) -> Dict[str, 'Check']:
+    def checks(self) -> Dict[str, Check]:
         """This plan's checks mapping (maps check name to :class:`Check`).
 
         This property is currently read-only.
@@ -833,14 +835,14 @@ class Plan:
         return self._checks
 
     @property
-    def log_targets(self) -> Dict[str, 'LogTarget']:
+    def log_targets(self) -> Dict[str, LogTarget]:
         """This plan's log targets mapping (maps log target name to :class:`LogTarget`).
 
         This property is currently read-only.
         """
         return self._log_targets
 
-    def to_dict(self) -> 'PlanDict':
+    def to_dict(self) -> PlanDict:
         """Convert this plan to its dict representation."""
         fields = [
             ('services', {name: service.to_dict() for name, service in self._services.items()}),
@@ -859,7 +861,7 @@ class Plan:
 
     __str__ = to_yaml
 
-    def __eq__(self, other: Union['PlanDict', 'Plan']) -> bool:
+    def __eq__(self, other: Union[PlanDict, Plan]) -> bool:
         if isinstance(other, dict):
             return self.to_dict() == other
         elif isinstance(other, Plan):
@@ -879,13 +881,13 @@ class Layer:
     #: Long-form description of this layer.
     description: str
     #: Mapping of name to :class:`Service` defined by this layer.
-    services: Dict[str, 'Service']
+    services: Dict[str, Service]
     #: Mapping of check to :class:`Check` defined by this layer.
-    checks: Dict[str, 'Check']
+    checks: Dict[str, Check]
     #: Mapping of target to :class:`LogTarget` defined by this layer.
-    log_targets: Dict[str, 'LogTarget']
+    log_targets: Dict[str, LogTarget]
 
-    def __init__(self, raw: Optional[Union[str, 'LayerDict']] = None):
+    def __init__(self, raw: Optional[Union[str, LayerDict]] = None):
         if isinstance(raw, str):  # noqa: SIM108
             d = yaml.safe_load(raw) or {}  # type: ignore # (Any 'raw' type)
         else:
@@ -906,7 +908,7 @@ class Layer:
         """Convert this layer to its YAML representation."""
         return yaml.safe_dump(self.to_dict())
 
-    def to_dict(self) -> 'LayerDict':
+    def to_dict(self) -> LayerDict:
         """Convert this layer to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -921,7 +923,7 @@ class Layer:
     def __repr__(self) -> str:
         return f'Layer({self.to_dict()!r})'
 
-    def __eq__(self, other: Union['LayerDict', 'Layer']) -> bool:
+    def __eq__(self, other: Union[LayerDict, Layer]) -> bool:
         """Reports whether this layer configuration is equal to another."""
         if isinstance(other, dict):
             return self.to_dict() == other
@@ -936,7 +938,7 @@ class Layer:
 class Service:
     """Represents a service description in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: Optional['ServiceDict'] = None):
+    def __init__(self, name: str, raw: Optional[ServiceDict] = None):
         self.name = name
         dct: ServiceDict = raw or {}
         self.summary = dct.get('summary', '')
@@ -961,7 +963,7 @@ class Service:
         self.backoff_limit = dct.get('backoff-limit', '')
         self.kill_delay = dct.get('kill-delay', '')
 
-    def to_dict(self) -> 'ServiceDict':
+    def to_dict(self) -> ServiceDict:
         """Convert this service object to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -989,7 +991,7 @@ class Service:
         dct = {name: value for name, value in fields if value}
         return typing.cast('ServiceDict', dct)
 
-    def _merge(self, other: 'Service'):
+    def _merge(self, other: Service):
         """Merges this service object with another service definition.
 
         For attributes present in both objects, the passed in service
@@ -1008,7 +1010,7 @@ class Service:
     def __repr__(self) -> str:
         return f'Service({self.to_dict()!r})'
 
-    def __eq__(self, other: Union['ServiceDict', 'Service']) -> bool:
+    def __eq__(self, other: Union[ServiceDict, Service]) -> bool:
         """Reports whether this service configuration is equal to another."""
         if isinstance(other, dict):
             return self.to_dict() == other
@@ -1051,7 +1053,7 @@ class ServiceInfo:
         return self.current == ServiceStatus.ACTIVE
 
     @classmethod
-    def from_dict(cls, d: '_ServiceInfoDict') -> 'ServiceInfo':
+    def from_dict(cls, d: _ServiceInfoDict) -> ServiceInfo:
         """Create new ServiceInfo object from dict parsed from JSON."""
         try:
             startup = ServiceStartup(d['startup'])
@@ -1079,7 +1081,7 @@ class ServiceInfo:
 class Check:
     """Represents a check in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: Optional['CheckDict'] = None):
+    def __init__(self, name: str, raw: Optional[CheckDict] = None):
         self.name = name
         dct: CheckDict = raw or {}
         self.override: str = dct.get('override', '')
@@ -1107,7 +1109,7 @@ class Check:
             exec_ = copy.deepcopy(exec_)
         self.exec: Optional[ExecDict] = exec_
 
-    def to_dict(self) -> 'CheckDict':
+    def to_dict(self) -> CheckDict:
         """Convert this check object to its dict representation."""
         level: str = self.level.value if isinstance(self.level, CheckLevel) else self.level
         fields = [
@@ -1126,7 +1128,7 @@ class Check:
     def __repr__(self) -> str:
         return f'Check({self.to_dict()!r})'
 
-    def __eq__(self, other: Union['CheckDict', 'Check']) -> bool:
+    def __eq__(self, other: Union[CheckDict, Check]) -> bool:
         """Reports whether this check configuration is equal to another."""
         if isinstance(other, dict):
             return self.to_dict() == other
@@ -1135,7 +1137,7 @@ class Check:
         else:
             return NotImplemented
 
-    def _merge_exec(self, other: 'ExecDict') -> None:
+    def _merge_exec(self, other: ExecDict) -> None:
         """Merges this exec object with another exec definition.
 
         For attributes present in both objects, the passed in exec
@@ -1153,7 +1155,7 @@ class Check:
             else:
                 self.exec[name] = value
 
-    def _merge_http(self, other: 'HttpDict') -> None:
+    def _merge_http(self, other: HttpDict) -> None:
         """Merges this http object with another http definition.
 
         For attributes present in both objects, the passed in http
@@ -1171,7 +1173,7 @@ class Check:
             else:
                 self.http[name] = value
 
-    def _merge_tcp(self, other: 'TcpDict') -> None:
+    def _merge_tcp(self, other: TcpDict) -> None:
         """Merges this tcp object with another tcp definition.
 
         For attributes present in both objects, the passed in tcp
@@ -1184,7 +1186,7 @@ class Check:
                 continue
             self.tcp[name] = value
 
-    def _merge(self, other: 'Check'):
+    def _merge(self, other: Check):
         """Merges this check object with another check definition.
 
         For attributes present in both objects, the passed in check
@@ -1221,7 +1223,7 @@ class CheckStatus(enum.Enum):
 class LogTarget:
     """Represents a log target in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: Optional['LogTargetDict'] = None):
+    def __init__(self, name: str, raw: Optional[LogTargetDict] = None):
         self.name = name
         dct: LogTargetDict = raw or {}
         self.override: str = dct.get('override', '')
@@ -1232,7 +1234,7 @@ class LogTarget:
         labels = copy.deepcopy(labels) if labels is not None else {}
         self.labels: Dict[str, str] = labels
 
-    def to_dict(self) -> 'LogTargetDict':
+    def to_dict(self) -> LogTargetDict:
         """Convert this log target object to its dict representation."""
         fields = [
             ('override', self.override),
@@ -1247,7 +1249,7 @@ class LogTarget:
     def __repr__(self):
         return f'LogTarget({self.to_dict()!r})'
 
-    def __eq__(self, other: Union['LogTargetDict', 'LogTarget']):
+    def __eq__(self, other: Union[LogTargetDict, LogTarget]):
         if isinstance(other, dict):
             return self.to_dict() == other
         elif isinstance(other, LogTarget):
@@ -1255,7 +1257,7 @@ class LogTarget:
         else:
             return NotImplemented
 
-    def _merge(self, other: 'LogTarget'):
+    def _merge(self, other: LogTarget):
         """Merges this log target object with another log target definition.
 
         For attributes present in both objects, the passed in log target
@@ -1293,7 +1295,7 @@ class FileInfo:
     name: str
     """Base name of the file."""
 
-    type: Union['FileType', str]
+    type: Union[FileType, str]
     """Type of the file ("file", "directory", "symlink", etc)."""
 
     size: Optional[int]
@@ -1321,7 +1323,7 @@ class FileInfo:
         self,
         path: str,
         name: str,
-        type: Union['FileType', str],
+        type: Union[FileType, str],
         size: Optional[int],
         permissions: int,
         last_modified: datetime.datetime,
@@ -1342,7 +1344,7 @@ class FileInfo:
         self.group = group
 
     @classmethod
-    def from_dict(cls, d: '_FileInfoDict') -> 'FileInfo':
+    def from_dict(cls, d: _FileInfoDict) -> FileInfo:
         """Create new FileInfo object from dict parsed from JSON."""
         try:
             file_type = FileType(d['type'])
@@ -1436,7 +1438,7 @@ class CheckInfo:
         self.change_id = change_id
 
     @classmethod
-    def from_dict(cls, d: '_CheckInfoDict') -> 'CheckInfo':
+    def from_dict(cls, d: _CheckInfoDict) -> CheckInfo:
         """Create new :class:`CheckInfo` object from dict parsed from JSON."""
         try:
             level = CheckLevel(d.get('level', ''))
@@ -1582,7 +1584,7 @@ class Notice:
     """How long since one of these last occurred until Pebble will drop the notice."""
 
     @classmethod
-    def from_dict(cls, d: '_NoticeDict') -> 'Notice':
+    def from_dict(cls, d: _NoticeDict) -> Notice:
         """Create new Notice object from dict parsed from JSON."""
         try:
             notice_type = NoticeType(d['type'])
@@ -1649,11 +1651,11 @@ class ExecProcess(Generic[AnyStr]):
         stdin: Optional[IO[AnyStr]],
         stdout: Optional[IO[AnyStr]],
         stderr: Optional[IO[AnyStr]],
-        client: 'Client',
+        client: Client,
         timeout: Optional[float],
-        control_ws: '_WebSocket',
-        stdio_ws: '_WebSocket',
-        stderr_ws: Optional['_WebSocket'],
+        control_ws: _WebSocket,
+        stdio_ws: _WebSocket,
+        stderr_ws: Optional[_WebSocket],
         command: List[str],
         encoding: Optional[str],
         change_id: ChangeID,
@@ -1801,8 +1803,8 @@ def _has_fileno(f: Any) -> bool:
 
 
 def _reader_to_websocket(
-    reader: '_WebsocketReader',
-    ws: '_WebSocket',
+    reader: _WebsocketReader,
+    ws: _WebSocket,
     encoding: str,
     cancel_reader: Optional[int] = None,
     bufsize: int = 16 * 1024,
@@ -1822,10 +1824,10 @@ def _reader_to_websocket(
             chunk = chunk.encode(encoding)
         ws.send_binary(chunk)
 
-    ws.send('{"command":"end"}')  # type: ignore # Send "end" command as TEXT frame to signal EOF
+    ws.send('{"command":"end"}')  # Send "end" command as TEXT frame to signal EOF
 
 
-def _websocket_to_writer(ws: '_WebSocket', writer: '_WebsocketWriter', encoding: Optional[str]):
+def _websocket_to_writer(ws: _WebSocket, writer: _WebsocketWriter, encoding: Optional[str]):
     """Receive messages from websocket (until end signal) and write to writer."""
     while True:
         chunk = ws.recv()
@@ -1853,7 +1855,7 @@ def _websocket_to_writer(ws: '_WebSocket', writer: '_WebsocketWriter', encoding:
 class _WebsocketWriter(io.BufferedIOBase):
     """A writable file-like object that sends what's written to it to a websocket."""
 
-    def __init__(self, ws: '_WebSocket'):
+    def __init__(self, ws: _WebSocket):
         self.ws = ws
 
     def writable(self):
@@ -1875,7 +1877,7 @@ class _WebsocketWriter(io.BufferedIOBase):
 class _WebsocketReader(io.BufferedIOBase):
     """A readable file-like object whose reads come from a websocket."""
 
-    def __init__(self, ws: '_WebSocket'):
+    def __init__(self, ws: _WebSocket):
         self.ws = ws
         self.remaining = b''
         self.eof = False
@@ -2000,7 +2002,7 @@ class Client:
     @staticmethod
     def _ensure_content_type(
         headers: email.message.Message,
-        expected: 'Literal["multipart/form-data", "application/json"]',
+        expected: Literal['multipart/form-data', 'application/json'],
     ):
         """Parse Content-Type header from headers and ensure it's equal to expected.
 
@@ -2317,9 +2319,7 @@ class Client:
 
         raise TimeoutError(f'timed out waiting for change {change_id} ({timeout} seconds)')
 
-    def add_layer(
-        self, label: str, layer: Union[str, 'LayerDict', Layer], *, combine: bool = False
-    ):
+    def add_layer(self, label: str, layer: Union[str, LayerDict, Layer], *, combine: bool = False):
         """Dynamically add a new layer onto the Pebble configuration layers.
 
         If combine is False (the default), append the new layer as the top
@@ -2431,7 +2431,7 @@ class Client:
         return f
 
     @staticmethod
-    def _raise_on_path_error(resp: '_FilesResponse', path: str):
+    def _raise_on_path_error(resp: _FilesResponse, path: str):
         result = resp['result'] or []  # in case it's null instead of []
         paths = {item['path']: item for item in result}
         if path not in paths:
@@ -2443,7 +2443,7 @@ class Client:
     def push(
         self,
         path: str,
-        source: '_IOSource',
+        source: _IOSource,
         *,
         encoding: str = 'utf-8',
         make_dirs: bool = False,
@@ -2504,7 +2504,7 @@ class Client:
         user: Optional[str],
         group_id: Optional[int],
         group: Optional[str],
-    ) -> '_AuthDict':
+    ) -> _AuthDict:
         d: _AuthDict = {}
         if permissions is not None:
             d['permissions'] = format(permissions, '03o')
@@ -2519,7 +2519,7 @@ class Client:
         return d
 
     def _encode_multipart(
-        self, metadata: Dict[str, Any], path: str, source: '_IOSource', encoding: str
+        self, metadata: Dict[str, Any], path: str, source: _IOSource, encoding: str
     ):
         # Python's stdlib mime/multipart handling is screwy and doesn't handle
         # binary properly, so roll our own.
@@ -2598,7 +2598,7 @@ class Client:
         if itself:
             query['itself'] = 'true'
         resp = self._request('GET', '/v1/files', query)
-        result = resp['result'] or []  # in case it's null instead of []
+        result: List[_FileInfoDict] = resp['result'] or []  # in case it's null instead of []
         return [FileInfo.from_dict(d) for d in result]
 
     def make_dir(
@@ -2891,7 +2891,7 @@ class Client:
             stdio_ws = self._connect_websocket(task_id, 'stdio')
             if not combine_stderr:
                 stderr_ws = self._connect_websocket(task_id, 'stderr')
-        except websocket.WebSocketException as e:  # type: ignore
+        except websocket.WebSocketException as e:
             # Error connecting to websockets, probably due to the exec/change
             # finishing early with an error. Call wait_change to pick that up.
             change = self.wait_change(ChangeID(change_id))
@@ -2966,7 +2966,7 @@ class Client:
         )
         return process
 
-    def _connect_websocket(self, task_id: str, websocket_id: str) -> '_WebSocket':
+    def _connect_websocket(self, task_id: str, websocket_id: str) -> _WebSocket:
         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
         # Set socket timeout to a short timeout during connection phase, in
         # case the Pebble side times out (5s), so this side doesn't hang. See:
@@ -3207,7 +3207,7 @@ class _FilesParser:
     def _get_open_tempfile(self):
         return self._files[self.current_filename]
 
-    def get_response(self) -> Optional['_FilesResponse']:
+    def get_response(self) -> Optional[_FilesResponse]:
         """Return the deserialized JSON object from the multipart "response" field."""
         return self._response
 
@@ -3215,7 +3215,7 @@ class _FilesParser:
         """Return a list of filenames from the "files" parts of the response."""
         return list(self._files.keys())
 
-    def get_file(self, path: str, encoding: Optional[str]) -> '_TextOrBinaryIO':
+    def get_file(self, path: str, encoding: Optional[str]) -> _TextOrBinaryIO:
         """Return an open file object containing the data."""
         mode = 'r' if encoding else 'rb'
         # We're using text-based file I/O purely for file encoding purposes, not for
@@ -3235,8 +3235,8 @@ class _MultipartParser:
     def __init__(
         self,
         marker: bytes,
-        handle_header: '_HeaderHandler',
-        handle_body: '_BodyHandler',
+        handle_header: _HeaderHandler,
+        handle_body: _BodyHandler,
         max_lookahead: int = 0,
         max_boundary_length: int = 0,
     ):
diff --git a/ops/storage.py b/ops/storage.py
index 5024e53..f9ba0fc 100644
--- a/ops/storage.py
+++ b/ops/storage.py
@@ -25,7 +25,7 @@ from datetime import timedelta
 from pathlib import Path
 from typing import Any, Callable, Generator, List, Optional, Tuple, Union, cast
 
-import yaml  # pyright: ignore[reportMissingModuleSource]
+import yaml
 
 logger = logging.getLogger()
 
@@ -402,7 +402,7 @@ class _JujuStorageBackend:
         p = _run(['state-get', key], stdout=subprocess.PIPE, check=True)
         if p.stdout == '' or p.stdout == '\n':
             raise KeyError(key)
-        return yaml.load(p.stdout, Loader=_SimpleLoader)  # type: ignore  # noqa: S506
+        return yaml.load(p.stdout, Loader=_SimpleLoader)  # noqa: S506
 
     def delete(self, key: str) -> None:
         """Remove a key from being tracked.
diff --git a/pyproject.toml b/pyproject.toml
index e6f21a6..58a7789 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -161,6 +161,11 @@ ignore = [
     "C408",
 ]
 
+[tool.ruff.lint.pyupgrade]
+# Preserve types, even if a file imports `from __future__ import annotations`.
+# Since we target py 3.8, we want to keep using List[str] and Optional[str]
+keep-runtime-typing = true
+
 [tool.ruff.lint.per-file-ignores]
 "test/*" = [
     # All documentation linting.
@@ -210,5 +215,6 @@ reportMissingModuleSource = false
 reportPrivateUsage = false
 reportUnnecessaryIsInstance = false
 reportUnnecessaryComparison = false
+reportUnnecessaryTypeIgnoreComment = "error"
 disableBytesTypePromotions = false
 stubPath = ""
diff --git a/tox.ini b/tox.ini
index 68acc7e..72a4c62 100644
--- a/tox.ini
+++ b/tox.ini
@@ -49,6 +49,16 @@ commands_pre =
 commands =
     sphinx-build -W --keep-going docs/ docs/_build/html
 
+[testenv:docs-live]
+description = Live development: build the Sphinx docs with autoreloading enabled
+deps = {[testenv:docs]deps}
+commands_pre =
+    {[testenv:docs]commands_pre}
+# https://github.com/sphinx-doc/sphinx-autobuild/issues/170
+change_dir = {tox_root}/docs
+commands =
+    sphinx-autobuild ./ ./_build/html --watch . --port 8000
+
 [testenv:fmt]
 description = Apply coding style standards to code
 deps =
@@ -69,7 +79,7 @@ description = Run static type checker
 deps =
     PyYAML==6.*
     websocket-client==1.*
-    pyright==1.1.345
+    pyright==1.1.377
     pytest~=7.2
     typing_extensions~=4.2
 commands =


Test patch:
diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml
index ab84d53..acef622 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: a0c3d8036ec68eca8573fd5b8dee549d3503e42a  # rev464 rev463 2024-08-23T12:27:19Z
+            commit: e59139a097bd407b5e785daf8193b777cc691af9  # 2024-08-26T10:45:00Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: 387a4eca4e6edb32159f69bd32233926826a6b42  # 2024-08-23T12:22:25Z
+            commit: beed68055e33191721896a3b8e28274a99e7de7b  # 2024-08-26T16:47:32Z
           - charm-repo: canonical/mysql-operator
-            commit: 30505488c189f686d12e514c050f99cc4e0c91ab  # rev275 rev274 2024-08-20T14:48:44Z
+            commit: 25a8a79680f3fbf3c36e9e1d8c20a852f54129c1  # 2024-08-26T13:48:51Z
           - charm-repo: canonical/mysql-k8s-operator
-            commit: a301a4a79febd3f5130598c4a74288a9fc5f8bb1  # 2024-08-22T08:21:59Z
+            commit: 52df6266067d7233b7a067f7a967170b4096dba8  # rev183 rev182 2024-08-26T13:44:33Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index dad4b55..09aa1b0 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -16,9 +16,9 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
-            commit: 4fdf6823d0337c87b63d84f029dc0603fc6426d9  # rev131 2024-08-22T13:55:44Z
+            commit: 4b2f567c43bc67e9d1fe24f018ba186dcd0396ed  # 2024-08-28T11:32:28Z
           - charm-repo: canonical/prometheus-k8s-operator
-            commit: 030ff51443d80e33e13abee99075444e048b86de  # rev211 2024-08-23T07:30:50Z
+            commit: 27025e5d840ec09331608b398cf4e49e5fe256e9  # 2024-08-28T11:32:32Z
           - charm-repo: canonical/grafana-k8s-operator
             commit: 1c80f746f8edeae6fd23ddf31eed45f5b88c06b4  # rev118 2024-08-12T14:02:32Z
     steps:
diff --git a/ops/testing.py b/ops/testing.py
index 72c64e1..40d60fc 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -389,7 +389,7 @@ class Harness(Generic[CharmType]):
 
         TestEvents.__name__ = self._charm_cls.on.__class__.__name__
 
-        class TestCharm(self._charm_cls):  # type: ignore
+        class TestCharm(self._charm_cls):
             on = TestEvents()
 
         # Note: jam 2020-03-01 This is so that errors in testing say MyCharm has no attribute foo,
@@ -2814,6 +2814,7 @@ class _TestingModelBackend:
             expires=secret.expire_time,
             rotation=rotation,
             rotates=rotates,
+            description=secret.description,
         )
 
     def secret_set(
diff --git a/test/pebble_cli.py b/test/pebble_cli.py
index 0e7a207..405a078 100644
--- a/test/pebble_cli.py
+++ b/test/pebble_cli.py
@@ -195,7 +195,7 @@ def main():
             result = client.get_checks(level=pebble.CheckLevel(args.level), names=args.name)
         elif args.command == 'exec':
             environment = {}
-            for env in args.env or []:
+            for env in typing.cast(typing.List[str], args.env) or []:
                 key, _, value = env.partition('=')
                 environment[key] = value
 
diff --git a/test/test_framework.py b/test/test_framework.py
index a3f5924..ad92aef 100644
--- a/test/test_framework.py
+++ b/test/test_framework.py
@@ -354,8 +354,8 @@ class TestFramework:
 
         framework.commit()
 
-        assert obs._stored.myinitdata == 41  # type: ignore
-        assert obs._stored.mydata == 42  # type: ignore
+        assert obs._stored.myinitdata == 41
+        assert obs._stored.mydata == 42
         assert obs.seen, [ops.PreCommitEvent, ops.CommitEvent]
         framework.close()
 
@@ -363,11 +363,11 @@ class TestFramework:
 
         new_obs = PreCommitObserver(other_framework, None)
 
-        assert obs._stored.myinitdata == 41  # type: ignore
-        assert new_obs._stored.mydata == 42  # type: ignore
+        assert obs._stored.myinitdata == 41
+        assert new_obs._stored.mydata == 42
 
         with pytest.raises(AttributeError):
-            new_obs._stored.myotherdata  # type: ignore
+            new_obs._stored.myotherdata
 
     def test_defer_and_reemit(self, request: pytest.FixtureRequest):
         framework = create_framework(request)
@@ -1083,7 +1083,7 @@ class TestStoredState:
                 ops.StoredList(None, [1, 2, 3])  # type: ignore
             )
             == 'ops.framework.StoredList([1, 2, 3])'
-        )  # type: ignore
+        )
 
     def test_stored_set_repr(self):
         assert repr(ops.StoredSet(None, set())) == 'ops.framework.StoredSet()'  # type: ignore
@@ -1159,14 +1159,14 @@ class TestStoredState:
         assert isinstance(obj, _StoredProtocol)
 
         try:
-            obj._stored.foo  # type: ignore
+            obj._stored.foo
         except AttributeError as e:
             assert str(e) == "attribute 'foo' is not stored"
         else:
             pytest.fail('AttributeError not raised')
 
         try:
-            obj._stored.on = 'nonono'  # type: ignore
+            obj._stored.on = 'nonono'
         except AttributeError as e:
             assert str(e) == "attribute 'on' is reserved and cannot be set"
         else:
@@ -1458,14 +1458,14 @@ class TestStoredState:
                 lambda res, expected_res: _assert_equal(res, expected_res),
             ),
             (
-                lambda: set(),
+                lambda: typing.cast(typing.Set[str], set()),
                 None,
                 set(),
                 lambda a, b: None,
                 lambda res, expected_res: _assert_equal(res, expected_res),
             ),
             (
-                lambda: set(),
+                lambda: typing.cast(typing.Set[str], set()),
                 'a',
                 {'a'},
                 lambda a, b: a.add(b),
@@ -1479,7 +1479,7 @@ class TestStoredState:
                 lambda res, expected_res: _assert_equal(res, expected_res),
             ),
             (
-                lambda: set(),
+                lambda: typing.cast(typing.Set[str], set()),
                 {'a'},
                 set(),
                 # Nested sets are not allowed as sets themselves are not hashable.
@@ -1850,7 +1850,7 @@ class TestBreakpoint:
     def test_breakpoint_not_really_names(self, request: pytest.FixtureRequest, name: typing.Any):
         framework = create_framework(request)
         with pytest.raises(TypeError) as excinfo:
-            framework.breakpoint(name)  # type: ignore
+            framework.breakpoint(name)
         assert str(excinfo.value) == 'breakpoint names must be strings'
 
     def check_trace_set(
diff --git a/test/test_helpers.py b/test/test_helpers.py
index 2bd2a9d..a97fa8e 100644
--- a/test/test_helpers.py
+++ b/test/test_helpers.py
@@ -105,7 +105,7 @@ def create_framework(
 
     if meta is None:
         meta = ops.CharmMeta()
-    model = ops.Model(meta, _ModelBackend('local/0'))  # type: ignore
+    model = ops.Model(meta, _ModelBackend('local/0'))
     # We can pass foo_event as event_name because we're not actually testing dispatch.
     framework = ops.Framework(
         SQLiteStorage(':memory:'),
@@ -175,7 +175,7 @@ done
         path.chmod(0o755)
         # TODO: this hardcodes the path to bash.exe, which works for now but might
         #       need to be set via environ or something like that.
-        path.with_suffix('.bat').write_text(  # type: ignore
+        path.with_suffix('.bat').write_text(
             f'@"C:\\Program Files\\git\\bin\\bash.exe" {path} %*\n'
         )
 
diff --git a/test/test_jujuversion.py b/test/test_jujuversion.py
index 07d4207..713248b 100644
--- a/test/test_jujuversion.py
+++ b/test/test_jujuversion.py
@@ -43,7 +43,7 @@ def test_parsing(vs: str, major: int, minor: int, tag: str, patch: int, build: i
     assert v.build == build
 
 
-@unittest.mock.patch('os.environ', new={})  # type: ignore
+@unittest.mock.patch('os.environ', new={})
 def test_from_environ():
     # JUJU_VERSION is not set
     v = ops.JujuVersion.from_environ()
diff --git a/test/test_main.py b/test/test_main.py
index c962cdd..98ed5fb 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -381,10 +381,7 @@ class _TestMain(abc.ABC):
             class ThisCharmEvents(MyCharmEvents):
                 pass
 
-            class _HasStored(typing.Protocol):
-                _stored: ops.StoredState
-
-            class Charm(self.charm_module.Charm, _HasStored):  # type: ignore
+            class Charm(self.charm_module.Charm):
                 on = ThisCharmEvents()
 
             mycharm = Charm(framework)
diff --git a/test/test_model.py b/test/test_model.py
index 5a5b362..c8266dc 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -1865,7 +1865,7 @@ containers:
                 'status': 'up',
                 'failures': 0,
                 'threshold': 3,
-            }),  # type: ignore
+            }),
             pebble.CheckInfo.from_dict({
                 'name': 'c2',
                 'level': 'alive',
@@ -1911,7 +1911,7 @@ containers:
                 'status': 'up',
                 'failures': 0,
                 'threshold': 3,
-            })  # type: ignore
+            })
         ])
         c = container.get_check('c1')
         assert container.pebble.requests == [('get_checks', None, ('c1',))]  # type: ignore
@@ -1934,7 +1934,7 @@ containers:
                 'status': 'up',
                 'failures': 0,
                 'threshold': 3,
-            }),  # type: ignore
+            }),
             pebble.CheckInfo.from_dict({
                 'name': 'c2',
                 'level': 'alive',
@@ -3206,7 +3206,7 @@ class TestModelBackend:
             ({'a': float('-inf')}, {}),
             ({'a': float('nan')}, {}),
             ({'foo': 'bar'}, {}),  # type: ignore
-            ({'foo': '1O'}, {}),  # type: ignore
+            ({'foo': '1O'}, {}),
         ]
         for metrics, labels in invalid_inputs:
             with pytest.raises(ops.ModelError):
@@ -3454,7 +3454,7 @@ class TestSecrets:
         ]
         for content, kwargs, exc_type in errors:
             with pytest.raises(exc_type):
-                model.unit.add_secret(content, **kwargs)  # type: ignore
+                model.unit.add_secret(content, **kwargs)
 
     def test_add_secret_errors(self, model: ops.Model):
         errors: typing.Any = [
@@ -3474,9 +3474,9 @@ class TestSecrets:
         ]
         for content, kwargs, exc_type in errors:
             with pytest.raises(exc_type):
-                model.app.add_secret(content, **kwargs)  # type: ignore
+                model.app.add_secret(content, **kwargs)
             with pytest.raises(exc_type):
-                model.unit.add_secret(content, **kwargs)  # type: ignore
+                model.unit.add_secret(content, **kwargs)
 
     def test_get_secret_id(self, fake_script: FakeScript, model: ops.Model):
         fake_script.write('secret-get', """echo '{"foo": "g"}'""")
@@ -3565,6 +3565,7 @@ class TestSecretInfo:
             expires=datetime.datetime(2022, 12, 9, 14, 10, 0),
             rotation=ops.SecretRotate.MONTHLY,
             rotates=datetime.datetime(2023, 1, 9, 14, 10, 0),
+            description='desc',
         )
         assert info.id == 'secret:3'
         assert info.label == 'lbl'
@@ -3572,6 +3573,7 @@ class TestSecretInfo:
         assert info.expires == datetime.datetime(2022, 12, 9, 14, 10, 0)
         assert info.rotation == ops.SecretRotate.MONTHLY
         assert info.rotates == datetime.datetime(2023, 1, 9, 14, 10, 0)
+        assert info.description == 'desc'
 
         assert repr(info).startswith('SecretInfo(')
         assert repr(info).endswith(')')
@@ -3586,6 +3588,7 @@ class TestSecretInfo:
                 'expiry': '2022-12-09T14:10:00Z',
                 'rotation': 'yearly',
                 'rotates': '2023-01-09T14:10:00Z',
+                'description': 'desc',
             },
         )
         assert info.id == 'secret:4'
@@ -3594,6 +3597,7 @@ class TestSecretInfo:
         assert info.expires == datetime.datetime(2022, 12, 9, 14, 10, 0, tzinfo=utc)
         assert info.rotation == ops.SecretRotate.YEARLY
         assert info.rotates == datetime.datetime(2023, 1, 9, 14, 10, 0, tzinfo=utc)
+        assert info.description == 'desc'
 
         info = ops.SecretInfo.from_dict(
             'secret:4',
@@ -3609,6 +3613,7 @@ class TestSecretInfo:
         assert info.expires is None
         assert info.rotation is None
         assert info.rotates is None
+        assert info.description is None
 
         info = ops.SecretInfo.from_dict('5', {'revision': 9})
         assert info.id == 'secret:5'
diff --git a/test/test_pebble.py b/test/test_pebble.py
index 2141cd6..b5e723f 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -26,7 +26,7 @@ import unittest.mock
 import unittest.util
 
 import pytest
-import websocket  # type: ignore
+import websocket
 
 import test.fake_pebble as fake_pebble
 from ops import pebble
@@ -1902,6 +1902,7 @@ class TestClient:
             'status-code': 200,
             'type': 'sync',
         })
+        excinfo: pytest.ExceptionInfo[pebble.ChangeError]
         with pytest.raises(pebble.ChangeError) as excinfo:
             client.autostart_services()
         assert isinstance(excinfo.value, pebble.Error)
@@ -3332,12 +3333,13 @@ class TestExec:
         self.add_responses(client, '456', 1)
 
         process = client.exec(['false'])
+        excinfo: pytest.ExceptionInfo[pebble.ExecError[str]]
         with pytest.raises(pebble.ExecError) as excinfo:
             process.wait()
         assert excinfo.value.command == ['false']
         assert excinfo.value.exit_code == 1
-        assert excinfo.value.stdout is None  # type: ignore
-        assert excinfo.value.stderr is None  # type: ignore
+        assert excinfo.value.stdout is None
+        assert excinfo.value.stderr is None
 
         assert client.requests == [
             ('POST', '/v1/exec', None, self.build_exec_data(['false'])),
diff --git a/test/test_real_pebble.py b/test/test_real_pebble.py
index 6f5d971..b3abac3 100644
--- a/test/test_real_pebble.py
+++ b/test/test_real_pebble.py
@@ -163,6 +163,7 @@ class TestRealPebble:
         process = client.exec(['true'])
         process.wait()
 
+        excinfo: pytest.ExceptionInfo[pebble.ExecError[str]]
         with pytest.raises(pebble.ExecError) as excinfo:
             process = client.exec(['/bin/sh', '-c', 'exit 42'])
             process.wait()
@@ -179,13 +180,13 @@ class TestRealPebble:
         assert out == b'OUT\n'
         assert err == b'ERR\n'
 
+        excinfo: pytest.ExceptionInfo[pebble.ExecError[str]]
         with pytest.raises(pebble.ExecError) as excinfo:
             process = client.exec(['/bin/sh', '-c', 'echo OUT; echo ERR >&2; exit 42'])
             process.wait_output()
-        exc = typing.cast(pebble.ExecError[str], excinfo.value)
-        assert exc.exit_code == 42
-        assert exc.stdout == 'OUT\n'
-        assert exc.stderr == 'ERR\n'
+        assert excinfo.value.exit_code == 42
+        assert excinfo.value.stdout == 'OUT\n'
+        assert excinfo.value.stderr == 'ERR\n'
 
     def test_exec_send_stdin(self, client: pebble.Client):
         process = client.exec(['awk', '{ print toupper($0) }'], stdin='foo\nBar\n')
diff --git a/test/test_testing.py b/test/test_testing.py
index 8bf8459..d49a64b 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -6313,6 +6313,7 @@ class TestHandleExec:
     ):
         harness.handle_exec(container, ['foo'], result=10)
 
+        excinfo: pytest.ExceptionInfo[pebble.ExecError[str]]
         with pytest.raises(pebble.ExecError) as excinfo:
             container.exec(['foo']).wait()
         assert excinfo.value.exit_code == 10