aboutsummaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorStefan Hajnoczi <stefanha@redhat.com>2023-10-16 12:37:48 -0400
committerStefan Hajnoczi <stefanha@redhat.com>2023-10-16 12:37:48 -0400
commit800485762e6564e04e2ab315132d477069562d91 (patch)
tree19ff602da72d056916ca091498584869c82d332e /python
parent9390f0fd3e30ac61e94ea2fc4a923d88ed486645 (diff)
parentb6aed193e5ecca32bb07e062f58f0daca06e7009 (diff)
Merge tag 'python-pull-request' of https://gitlab.com/jsnow/qemu into staging
Python Pullreq Python PR: - Use socketpair for all machine.py connections - Support Python 3.12 - Switch iotests over to using raise-on-error QMP command interface (Thank you very much, Vladimir!) # -----BEGIN PGP SIGNATURE----- # # iQIzBAABCAAdFiEE+ber27ys35W+dsvQfe+BBqr8OQ4FAmUpldkACgkQfe+BBqr8 # OQ4NtRAAnkEmXsECAxQ2ewvf3yK8PTFm4Oq5nqMIw+KB94ATrsGzk3z1rLvatSl3 # 6VLsV2+FWoOEyKrsfu5DIfbuo4d3TZTU7N2DIZpVpvO166K+fXbzp8skAg+n3BMC # tWkSOcnsT6+8aqyxxyASdHvbbE7pvPw8OA3oIIstsYeZ5/HHpOWXNj1kjCsnL0lW # 7y5h6UUKGmnCPdixyk042+AvKkT7GAKVjFnjUF5JHv0iR2KpQ+O9H7OEalqQT5w5 # eab4oMGuIYhzYe+MNpyybAB3Xd2pxhcppk+sl4dCE8qmMn7KRoTNw1iu+qhsNQfQ # JILZoCPtYMhpef4X0ulH8PFBMweBptqOjo4lpz9QIdMWTf86IE0yIT9DCy3aSjpp # ywwxhFKJS43gz4WHkEJlrY9PHwLsULaV/Cz6HKJAU6h9aFtcNdT4pkCOERnZ8X4C # yHlNReTG5Dz1sYzKJ/k9LTjAaVDasumR8/yadaUCwalj5zexQ27qlIM6oc5wdIRQ # up1VHi7odF5KHb6GeqdniuuEF6NBCYRAV5nz+dbd6exfKOaxYRrr48yh9SUm8QS6 # JCvMMFFAZCIrI/nkRVajbLi9L5O3fg5abtlzSzh9o4iyf8Rf/1gtKNxZRK1NZIjQ # cTYBJXpMulNx7bM2CPNsPWGqCTAjAcu10svqTA8luGj4fqdTNyU= # =02Bd # -----END PGP SIGNATURE----- # gpg: Signature made Fri 13 Oct 2023 15:09:13 EDT # gpg: using RSA key F9B7ABDBBCACDF95BE76CBD07DEF8106AAFC390E # gpg: Good signature from "John Snow (John Huston) <jsnow@redhat.com>" [full] # Primary key fingerprint: FAEB 9711 A12C F475 812F 18F2 88A9 064D 1835 61EB # Subkey fingerprint: F9B7 ABDB BCAC DF95 BE76 CBD0 7DEF 8106 AAFC 390E * tag 'python-pull-request' of https://gitlab.com/jsnow/qemu: (25 commits) python: use vm.cmd() instead of vm.qmp() where appropriate scripts: add python_qmp_updater.py tests/vm/basevm.py: use cmd() instead of qmp() iotests.py: pause_job(): drop return value iotests: drop some extra ** in qmp() call iotests: drop some extra semicolons iotests: refactor some common qmp result checks into generic pattern iotests: add some missed checks of qmp result iotests: QemuStorageDaemon: add cmd() method like in QEMUMachine. python/machine.py: upgrade vm.cmd() method python/qemu: rename command() to cmd() python: rename QEMUMonitorProtocol.cmd() to cmd_raw() scripts/cpu-x86-uarch-abi.py: use .command() instead of .cmd() qmp_shell.py: _fill_completion() use .command() instead of .cmd() python/qemu/qmp/legacy: cmd(): drop cmd_id unused argument Python: Enable python3.12 support configure: fix error message to say Python 3.8 python/qmp: remove Server.wait_closed() call for Python 3.12 Python/iotests: Add type hint for nbd module python/machine: remove unused sock_dir argument ... Signed-off-by: Stefan Hajnoczi <stefanha@redhat.com>
Diffstat (limited to 'python')
-rw-r--r--python/qemu/machine/console_socket.py29
-rw-r--r--python/qemu/machine/machine.py78
-rw-r--r--python/qemu/machine/qtest.py54
-rw-r--r--python/qemu/qmp/legacy.py10
-rw-r--r--python/qemu/qmp/protocol.py1
-rw-r--r--python/qemu/qmp/qmp_shell.py20
-rw-r--r--python/qemu/utils/qemu_ga_client.py2
-rw-r--r--python/qemu/utils/qom.py8
-rw-r--r--python/qemu/utils/qom_common.py2
-rw-r--r--python/qemu/utils/qom_fuse.py6
-rw-r--r--python/setup.cfg3
11 files changed, 139 insertions, 74 deletions
diff --git a/python/qemu/machine/console_socket.py b/python/qemu/machine/console_socket.py
index 4e28ba9bb2..0a4e09ffc7 100644
--- a/python/qemu/machine/console_socket.py
+++ b/python/qemu/machine/console_socket.py
@@ -24,19 +24,32 @@ class ConsoleSocket(socket.socket):
"""
ConsoleSocket represents a socket attached to a char device.
- Optionally (if drain==True), drains the socket and places the bytes
- into an in memory buffer for later processing.
-
- Optionally a file path can be passed in and we will also
- dump the characters to this file for debugging purposes.
+ :param address: An AF_UNIX path or address.
+ :param sock_fd: Optionally, an existing socket file descriptor.
+ One of address or sock_fd must be specified.
+ :param file: Optionally, a filename to log to.
+ :param drain: Optionally, drains the socket and places the bytes
+ into an in memory buffer for later processing.
"""
- def __init__(self, address: str, file: Optional[str] = None,
+ def __init__(self,
+ address: Optional[str] = None,
+ sock_fd: Optional[int] = None,
+ file: Optional[str] = None,
drain: bool = False):
+ if address is None and sock_fd is None:
+ raise ValueError("one of 'address' or 'sock_fd' must be specified")
+ if address is not None and sock_fd is not None:
+ raise ValueError("can't specify both 'address' and 'sock_fd'")
+
self._recv_timeout_sec = 300.0
self._sleep_time = 0.5
self._buffer: Deque[int] = deque()
- socket.socket.__init__(self, socket.AF_UNIX, socket.SOCK_STREAM)
- self.connect(address)
+ if address is not None:
+ socket.socket.__init__(self, socket.AF_UNIX, socket.SOCK_STREAM)
+ self.connect(address)
+ else:
+ assert sock_fd is not None
+ socket.socket.__init__(self, fileno=sock_fd)
self._logfile = None
if file:
# pylint: disable=consider-using-with
diff --git a/python/qemu/machine/machine.py b/python/qemu/machine/machine.py
index 35d5a672db..31cb9d617d 100644
--- a/python/qemu/machine/machine.py
+++ b/python/qemu/machine/machine.py
@@ -127,7 +127,6 @@ class QEMUMachine:
name: Optional[str] = None,
base_temp_dir: str = "/var/tmp",
monitor_address: Optional[SocketAddrT] = None,
- sock_dir: Optional[str] = None,
drain_console: bool = False,
console_log: Optional[str] = None,
log_dir: Optional[str] = None,
@@ -141,7 +140,6 @@ class QEMUMachine:
@param name: prefix for socket and log file names (default: qemu-PID)
@param base_temp_dir: default location where temp files are created
@param monitor_address: address for QMP monitor
- @param sock_dir: where to create socket (defaults to base_temp_dir)
@param drain_console: (optional) True to drain console socket to buffer
@param console_log: (optional) path to console log file
@param log_dir: where to create and keep log files
@@ -159,9 +157,10 @@ class QEMUMachine:
self._name = name or f"{id(self):x}"
self._sock_pair: Optional[Tuple[socket.socket, socket.socket]] = None
+ self._cons_sock_pair: Optional[
+ Tuple[socket.socket, socket.socket]] = None
self._temp_dir: Optional[str] = None
self._base_temp_dir = base_temp_dir
- self._sock_dir = sock_dir
self._log_dir = log_dir
self._monitor_address = monitor_address
@@ -187,9 +186,6 @@ class QEMUMachine:
self._console_index = 0
self._console_set = False
self._console_device_type: Optional[str] = None
- self._console_address = os.path.join(
- self.sock_dir, f"{self._name}.con"
- )
self._console_socket: Optional[socket.socket] = None
self._console_file: Optional[socket.SocketIO] = None
self._remove_files: List[str] = []
@@ -301,9 +297,7 @@ class QEMUMachine:
if self._qmp_set:
if self._sock_pair:
- fd = self._sock_pair[0].fileno()
- os.set_inheritable(fd, True)
- moncdev = f"socket,id=mon,fd={fd}"
+ moncdev = f"socket,id=mon,fd={self._sock_pair[0].fileno()}"
elif isinstance(self._monitor_address, tuple):
moncdev = "socket,id=mon,host={},port={}".format(
*self._monitor_address
@@ -318,8 +312,9 @@ class QEMUMachine:
for _ in range(self._console_index):
args.extend(['-serial', 'null'])
if self._console_set:
- chardev = ('socket,id=console,path=%s,server=on,wait=off' %
- self._console_address)
+ assert self._cons_sock_pair is not None
+ fd = self._cons_sock_pair[0].fileno()
+ chardev = f"socket,id=console,fd={fd}"
args.extend(['-chardev', chardev])
if self._console_device_type is None:
args.extend(['-serial', 'chardev:console'])
@@ -334,12 +329,10 @@ class QEMUMachine:
return self._args
def _pre_launch(self) -> None:
- if self._console_set:
- self._remove_files.append(self._console_address)
-
if self._qmp_set:
if self._monitor_address is None:
self._sock_pair = socket.socketpair()
+ os.set_inheritable(self._sock_pair[0].fileno(), True)
sock = self._sock_pair[1]
if isinstance(self._monitor_address, str):
self._remove_files.append(self._monitor_address)
@@ -353,6 +346,10 @@ class QEMUMachine:
nickname=self._name
)
+ if self._console_set:
+ self._cons_sock_pair = socket.socketpair()
+ os.set_inheritable(self._cons_sock_pair[0].fileno(), True)
+
# NOTE: Make sure any opened resources are *definitely* freed in
# _post_shutdown()!
# pylint: disable=consider-using-with
@@ -370,6 +367,9 @@ class QEMUMachine:
def _post_launch(self) -> None:
if self._sock_pair:
self._sock_pair[0].close()
+ if self._cons_sock_pair:
+ self._cons_sock_pair[0].close()
+
if self._qmp_connection:
if self._sock_pair:
self._qmp.connect()
@@ -397,6 +397,11 @@ class QEMUMachine:
finally:
assert self._qmp_connection is None
+ if self._sock_pair:
+ self._sock_pair[0].close()
+ self._sock_pair[1].close()
+ self._sock_pair = None
+
self._close_qemu_log_file()
self._load_io_log()
@@ -520,6 +525,11 @@ class QEMUMachine:
self._console_socket.close()
self._console_socket = None
+ if self._cons_sock_pair:
+ self._cons_sock_pair[0].close()
+ self._cons_sock_pair[1].close()
+ self._cons_sock_pair = None
+
def _hard_shutdown(self) -> None:
"""
Perform early cleanup, kill the VM, and wait for it to terminate.
@@ -692,21 +702,31 @@ class QEMUMachine:
conv_keys = True
qmp_args = self._qmp_args(conv_keys, args)
- ret = self._qmp.cmd(cmd, args=qmp_args)
+ ret = self._qmp.cmd_raw(cmd, args=qmp_args)
if cmd == 'quit' and 'error' not in ret and 'return' in ret:
self._quit_issued = True
return ret
- def command(self, cmd: str,
- conv_keys: bool = True,
- **args: Any) -> QMPReturnValue:
+ def cmd(self, cmd: str,
+ args_dict: Optional[Dict[str, object]] = None,
+ conv_keys: Optional[bool] = None,
+ **args: Any) -> QMPReturnValue:
"""
Invoke a QMP command.
On success return the response dict.
On failure raise an exception.
"""
+ if args_dict is not None:
+ assert not args
+ assert conv_keys is None
+ args = args_dict
+ conv_keys = False
+
+ if conv_keys is None:
+ conv_keys = True
+
qmp_args = self._qmp_args(conv_keys, args)
- ret = self._qmp.command(cmd, **qmp_args)
+ ret = self._qmp.cmd(cmd, **qmp_args)
if cmd == 'quit':
self._quit_issued = True
return ret
@@ -881,10 +901,19 @@ class QEMUMachine:
"""
if self._console_socket is None:
LOG.debug("Opening console socket")
+ if not self._console_set:
+ raise QEMUMachineError(
+ "Attempt to access console socket with no connection")
+ assert self._cons_sock_pair is not None
+ # os.dup() is used here for sock_fd because otherwise we'd
+ # have two rich python socket objects that would each try to
+ # close the same underlying fd when either one gets garbage
+ # collected.
self._console_socket = console_socket.ConsoleSocket(
- self._console_address,
+ sock_fd=os.dup(self._cons_sock_pair[1].fileno()),
file=self._console_log_path,
drain=self._drain_console)
+ self._cons_sock_pair[1].close()
return self._console_socket
@property
@@ -910,15 +939,6 @@ class QEMUMachine:
return self._temp_dir
@property
- def sock_dir(self) -> str:
- """
- Returns the directory used for sockfiles by this machine.
- """
- if self._sock_dir:
- return self._sock_dir
- return self.temp_dir
-
- @property
def log_dir(self) -> str:
"""
Returns a directory to be used for writing logs
diff --git a/python/qemu/machine/qtest.py b/python/qemu/machine/qtest.py
index 1c46138bd0..4f5ede85b2 100644
--- a/python/qemu/machine/qtest.py
+++ b/python/qemu/machine/qtest.py
@@ -24,6 +24,7 @@ from typing import (
Optional,
Sequence,
TextIO,
+ Tuple,
)
from qemu.qmp import SocketAddrT
@@ -38,23 +39,41 @@ class QEMUQtestProtocol:
:param address: QEMU address, can be either a unix socket path (string)
or a tuple in the form ( address, port ) for a TCP
connection
- :param server: server mode, listens on the socket (bool)
+ :param sock: An existing socket can be provided as an alternative to
+ an address. One of address or sock must be provided.
+ :param server: server mode, listens on the socket. Only meaningful
+ in conjunction with an address and not an existing
+ socket.
+
:raise socket.error: on socket connection errors
.. note::
No connection is established by __init__(), this is done
by the connect() or accept() methods.
"""
- def __init__(self, address: SocketAddrT,
+ def __init__(self,
+ address: Optional[SocketAddrT] = None,
+ sock: Optional[socket.socket] = None,
server: bool = False):
+ if address is None and sock is None:
+ raise ValueError("Either 'address' or 'sock' must be specified")
+ if address is not None and sock is not None:
+ raise ValueError(
+ "Either 'address' or 'sock' must be specified, but not both")
+ if sock is not None and server:
+ raise ValueError("server=True is meaningless when passing socket")
+
self._address = address
- self._sock = self._get_sock()
+ self._sock = sock or self._get_sock()
self._sockfile: Optional[TextIO] = None
+
if server:
+ assert self._address is not None
self._sock.bind(self._address)
self._sock.listen(1)
def _get_sock(self) -> socket.socket:
+ assert self._address is not None
if isinstance(self._address, tuple):
family = socket.AF_INET
else:
@@ -67,7 +86,8 @@ class QEMUQtestProtocol:
@raise socket.error on socket connection errors
"""
- self._sock.connect(self._address)
+ if self._address is not None:
+ self._sock.connect(self._address)
self._sockfile = self._sock.makefile(mode='r')
def accept(self) -> None:
@@ -115,41 +135,49 @@ class QEMUQtestMachine(QEMUMachine):
wrapper: Sequence[str] = (),
name: Optional[str] = None,
base_temp_dir: str = "/var/tmp",
- sock_dir: Optional[str] = None,
qmp_timer: Optional[float] = None):
# pylint: disable=too-many-arguments
if name is None:
name = "qemu-%d" % os.getpid()
- if sock_dir is None:
- sock_dir = base_temp_dir
super().__init__(binary, args, wrapper=wrapper, name=name,
base_temp_dir=base_temp_dir,
- sock_dir=sock_dir, qmp_timer=qmp_timer)
+ qmp_timer=qmp_timer)
self._qtest: Optional[QEMUQtestProtocol] = None
- self._qtest_path = os.path.join(sock_dir, name + "-qtest.sock")
+ self._qtest_sock_pair: Optional[
+ Tuple[socket.socket, socket.socket]] = None
@property
def _base_args(self) -> List[str]:
args = super()._base_args
+ assert self._qtest_sock_pair is not None
+ fd = self._qtest_sock_pair[0].fileno()
args.extend([
- '-qtest', f"unix:path={self._qtest_path}",
+ '-chardev', f"socket,id=qtest,fd={fd}",
+ '-qtest', 'chardev:qtest',
'-accel', 'qtest'
])
return args
def _pre_launch(self) -> None:
+ self._qtest_sock_pair = socket.socketpair()
+ os.set_inheritable(self._qtest_sock_pair[0].fileno(), True)
super()._pre_launch()
- self._qtest = QEMUQtestProtocol(self._qtest_path, server=True)
+ self._qtest = QEMUQtestProtocol(sock=self._qtest_sock_pair[1])
def _post_launch(self) -> None:
assert self._qtest is not None
super()._post_launch()
- self._qtest.accept()
+ if self._qtest_sock_pair:
+ self._qtest_sock_pair[0].close()
+ self._qtest.connect()
def _post_shutdown(self) -> None:
+ if self._qtest_sock_pair:
+ self._qtest_sock_pair[0].close()
+ self._qtest_sock_pair[1].close()
+ self._qtest_sock_pair = None
super()._post_shutdown()
- self._remove_if_exists(self._qtest_path)
def qtest(self, cmd: str) -> str:
"""
diff --git a/python/qemu/qmp/legacy.py b/python/qemu/qmp/legacy.py
index e1e9383978..22a2b5616e 100644
--- a/python/qemu/qmp/legacy.py
+++ b/python/qemu/qmp/legacy.py
@@ -194,24 +194,20 @@ class QEMUMonitorProtocol:
)
)
- def cmd(self, name: str,
- args: Optional[Dict[str, object]] = None,
- cmd_id: Optional[object] = None) -> QMPMessage:
+ def cmd_raw(self, name: str,
+ args: Optional[Dict[str, object]] = None) -> QMPMessage:
"""
Build a QMP command and send it to the QMP Monitor.
:param name: command name (string)
:param args: command arguments (dict)
- :param cmd_id: command id (dict, list, string or int)
"""
qmp_cmd: QMPMessage = {'execute': name}
if args:
qmp_cmd['arguments'] = args
- if cmd_id:
- qmp_cmd['id'] = cmd_id
return self.cmd_obj(qmp_cmd)
- def command(self, cmd: str, **kwds: object) -> QMPReturnValue:
+ def cmd(self, cmd: str, **kwds: object) -> QMPReturnValue:
"""
Build and send a QMP command to the monitor, report errors if any
"""
diff --git a/python/qemu/qmp/protocol.py b/python/qemu/qmp/protocol.py
index 753182131f..a4ffdfad51 100644
--- a/python/qemu/qmp/protocol.py
+++ b/python/qemu/qmp/protocol.py
@@ -495,7 +495,6 @@ class AsyncProtocol(Generic[T]):
try:
self.logger.debug("Stopping server.")
self._server.close()
- await self._server.wait_closed()
self.logger.debug("Server stopped.")
finally:
self._server = None
diff --git a/python/qemu/qmp/qmp_shell.py b/python/qemu/qmp/qmp_shell.py
index 619ab42ced..98e684e9e8 100644
--- a/python/qemu/qmp/qmp_shell.py
+++ b/python/qemu/qmp/qmp_shell.py
@@ -91,14 +91,21 @@ from subprocess import Popen
import sys
from typing import (
IO,
+ Dict,
Iterator,
List,
NoReturn,
Optional,
Sequence,
+ cast,
)
-from qemu.qmp import ConnectError, QMPError, SocketAddrT
+from qemu.qmp import (
+ ConnectError,
+ ExecuteError,
+ QMPError,
+ SocketAddrT,
+)
from qemu.qmp.legacy import (
QEMUMonitorProtocol,
QMPBadPortError,
@@ -194,11 +201,12 @@ class QMPShell(QEMUMonitorProtocol):
super().close()
def _fill_completion(self) -> None:
- cmds = self.cmd('query-commands')
- if 'error' in cmds:
- return
- for cmd in cmds['return']:
- self._completer.append(cmd['name'])
+ try:
+ cmds = cast(List[Dict[str, str]], self.cmd('query-commands'))
+ for cmd in cmds:
+ self._completer.append(cmd['name'])
+ except ExecuteError:
+ pass
def _completer_setup(self) -> None:
self._completer = QMPCompleter()
diff --git a/python/qemu/utils/qemu_ga_client.py b/python/qemu/utils/qemu_ga_client.py
index d8411bb2d0..9a665e6e99 100644
--- a/python/qemu/utils/qemu_ga_client.py
+++ b/python/qemu/utils/qemu_ga_client.py
@@ -64,7 +64,7 @@ from qemu.qmp.legacy import QEMUMonitorProtocol
class QemuGuestAgent(QEMUMonitorProtocol):
def __getattr__(self, name: str) -> Callable[..., Any]:
def wrapper(**kwds: object) -> object:
- return self.command('guest-' + name.replace('_', '-'), **kwds)
+ return self.cmd('guest-' + name.replace('_', '-'), **kwds)
return wrapper
diff --git a/python/qemu/utils/qom.py b/python/qemu/utils/qom.py
index bcf192f477..426a0f245f 100644
--- a/python/qemu/utils/qom.py
+++ b/python/qemu/utils/qom.py
@@ -84,7 +84,7 @@ class QOMSet(QOMCommand):
self.value = args.value
def run(self) -> int:
- rsp = self.qmp.command(
+ rsp = self.qmp.cmd(
'qom-set',
path=self.path,
property=self.prop,
@@ -129,7 +129,7 @@ class QOMGet(QOMCommand):
self.prop = tmp[1]
def run(self) -> int:
- rsp = self.qmp.command(
+ rsp = self.qmp.cmd(
'qom-get',
path=self.path,
property=self.prop
@@ -231,8 +231,8 @@ class QOMTree(QOMCommand):
if item.child:
continue
try:
- rsp = self.qmp.command('qom-get', path=path,
- property=item.name)
+ rsp = self.qmp.cmd('qom-get', path=path,
+ property=item.name)
print(f" {item.name}: {rsp} ({item.type})")
except ExecuteError as err:
print(f" {item.name}: <EXCEPTION: {err!s}> ({item.type})")
diff --git a/python/qemu/utils/qom_common.py b/python/qemu/utils/qom_common.py
index 80da1b2304..dd2c8b1908 100644
--- a/python/qemu/utils/qom_common.py
+++ b/python/qemu/utils/qom_common.py
@@ -140,7 +140,7 @@ class QOMCommand:
"""
:return: a strongly typed list from the 'qom-list' command.
"""
- rsp = self.qmp.command('qom-list', path=path)
+ rsp = self.qmp.cmd('qom-list', path=path)
# qom-list returns List[ObjectPropertyInfo]
assert isinstance(rsp, list)
return [ObjectPropertyInfo.make(x) for x in rsp]
diff --git a/python/qemu/utils/qom_fuse.py b/python/qemu/utils/qom_fuse.py
index 8dcd59fcde..cf7e344bd5 100644
--- a/python/qemu/utils/qom_fuse.py
+++ b/python/qemu/utils/qom_fuse.py
@@ -137,7 +137,7 @@ class QOMFuse(QOMCommand, Operations):
if path == '':
path = '/'
try:
- data = str(self.qmp.command('qom-get', path=path, property=prop))
+ data = str(self.qmp.cmd('qom-get', path=path, property=prop))
data += '\n' # make values shell friendly
except ExecuteError as err:
raise FuseOSError(EPERM) from err
@@ -152,8 +152,8 @@ class QOMFuse(QOMCommand, Operations):
return False
path, prop = path.rsplit('/', 1)
prefix = '/'.join(['..'] * (len(path.split('/')) - 1))
- return prefix + str(self.qmp.command('qom-get', path=path,
- property=prop))
+ return prefix + str(self.qmp.cmd('qom-get', path=path,
+ property=prop))
def getattr(self, path: str,
fh: Optional[IO[bytes]] = None) -> Mapping[str, object]:
diff --git a/python/setup.cfg b/python/setup.cfg
index 8c67dce457..48668609d3 100644
--- a/python/setup.cfg
+++ b/python/setup.cfg
@@ -18,6 +18,7 @@ classifiers =
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
+ Programming Language :: Python :: 3.12
Typing :: Typed
[options]
@@ -182,7 +183,7 @@ multi_line_output=3
# of python available on your system to run this test.
[tox:tox]
-envlist = py38, py39, py310, py311
+envlist = py38, py39, py310, py311, py312
skip_missing_interpreters = true
[testenv]