Repository: canonical/operator
Pull request #1332 (2024-08-23 07:50:53)

test: bump pyright to 1.1.377
In preparation to enabling `reportUnnecessaryTypeIgnoreComment`, bumping pyright to today's latest.

super-tox run: OK (tested on host, macos)
- 58 charms pass against canoincal/operator@main
- 58 charms pass against dimaqq/operator@bump-pyright


Patch:
diff --git a/ops/framework.py b/ops/framework.py
index 7c96ee5..c47e6b9 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()}
 
diff --git a/ops/main.py b/ops/main.py
index 94105cb..871a749 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'
diff --git a/ops/model.py b/ops/model.py
index 3b9c4d8..434c0e2 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -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})'
         )
 
 
diff --git a/ops/pebble.py b/ops/pebble.py
index 3d8b85c..2b36b35 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
@@ -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,
@@ -1825,7 +1827,7 @@ def _reader_to_websocket(
     ws.send('{"command":"end"}')  # type: ignore # 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(
@@ -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/pyproject.toml b/pyproject.toml
index e6f21a6..9672000 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.
diff --git a/tox.ini b/tox.ini
index 68acc7e..7c08c93 100644
--- a/tox.ini
+++ b/tox.ini
@@ -69,7 +69,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 fd02830..ab84d53 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: da7ab85806174545930e71da6b5b1285b53fb41f  # 2024-07-24T19:54:11Z
+            commit: a0c3d8036ec68eca8573fd5b8dee549d3503e42a  # rev464 rev463 2024-08-23T12:27:19Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: f6b6e2a6b86b25d071d719d3f0a3bd121d42d439  # 2024-07-24T19:48:55Z
+            commit: 387a4eca4e6edb32159f69bd32233926826a6b42  # 2024-08-23T12:22:25Z
           - charm-repo: canonical/mysql-operator
-            commit: 643084e9e13089d1112389a4439f33ba3f035894  # rev257 2024-07-23T01:22:29Z
+            commit: 30505488c189f686d12e514c050f99cc4e0c91ab  # rev275 rev274 2024-08-20T14:48:44Z
           - charm-repo: canonical/mysql-k8s-operator
-            commit: 00881dd574d63c778c05fa0c111a9c336154680c  # 2024-07-24T19:37:27Z
+            commit: a301a4a79febd3f5130598c4a74288a9fc5f8bb1  # 2024-08-22T08:21:59Z
     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 14adcbe..dad4b55 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -16,11 +16,11 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
-            commit: 8b8c9a1d1b1352fd0c797ea37878e64c65a0f4ef  # rev127 2024-07-17T12:10:15Z
+            commit: 4fdf6823d0337c87b63d84f029dc0603fc6426d9  # rev131 2024-08-22T13:55:44Z
           - charm-repo: canonical/prometheus-k8s-operator
-            commit: 76e80816dc67958fa8be6752ef99b34371e84f87  # rev210 2024-07-16T13:35:14Z
+            commit: 030ff51443d80e33e13abee99075444e048b86de  # rev211 2024-08-23T07:30:50Z
           - charm-repo: canonical/grafana-k8s-operator
-            commit: a2688ade7af5d0dc1607c1ac560faf065966f063  # rev117 2024-06-18T22:02:42Z
+            commit: 1c80f746f8edeae6fd23ddf31eed45f5b88c06b4  # rev118 2024-08-12T14:02:32Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/ops/testing.py b/ops/testing.py
index 72c64e1..02ab2ee 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -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..d96a286 100644
--- a/test/test_framework.py
+++ b/test/test_framework.py
@@ -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.
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..b72b997 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -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..01f0780 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -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,6 +3333,7 @@ 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']
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