1""" 2QEMU machine module: 3 4The machine module primarily provides the QEMUMachine class, 5which provides facilities for managing the lifetime of a QEMU VM. 6""" 7 8# Copyright (C) 2015-2016 Red Hat Inc. 9# Copyright (C) 2012 IBM Corp. 10# 11# Authors: 12# Fam Zheng <famz@redhat.com> 13# 14# This work is licensed under the terms of the GNU GPL, version 2. See 15# the COPYING file in the top-level directory. 16# 17# Based on qmp.py. 18# 19 20import errno 21from itertools import chain 22import logging 23import os 24import shutil 25import signal 26import socket 27import subprocess 28import tempfile 29from types import TracebackType 30from typing import ( 31 Any, 32 BinaryIO, 33 Dict, 34 List, 35 Optional, 36 Sequence, 37 Tuple, 38 Type, 39 TypeVar, 40) 41 42from qemu.qmp import ( # pylint: disable=import-error 43 QEMUMonitorProtocol, 44 QMPMessage, 45 QMPReturnValue, 46 SocketAddrT, 47) 48 49from . import console_socket 50 51 52LOG = logging.getLogger(__name__) 53 54 55class QEMUMachineError(Exception): 56 """ 57 Exception called when an error in QEMUMachine happens. 58 """ 59 60 61class QEMUMachineAddDeviceError(QEMUMachineError): 62 """ 63 Exception raised when a request to add a device can not be fulfilled 64 65 The failures are caused by limitations, lack of information or conflicting 66 requests on the QEMUMachine methods. This exception does not represent 67 failures reported by the QEMU binary itself. 68 """ 69 70 71class AbnormalShutdown(QEMUMachineError): 72 """ 73 Exception raised when a graceful shutdown was requested, but not performed. 74 """ 75 76 77_T = TypeVar('_T', bound='QEMUMachine') 78 79 80class QEMUMachine: 81 """ 82 A QEMU VM. 83 84 Use this object as a context manager to ensure 85 the QEMU process terminates:: 86 87 with VM(binary) as vm: 88 ... 89 # vm is guaranteed to be shut down here 90 """ 91 # pylint: disable=too-many-instance-attributes, too-many-public-methods 92 93 def __init__(self, 94 binary: str, 95 args: Sequence[str] = (), 96 wrapper: Sequence[str] = (), 97 name: Optional[str] = None, 98 base_temp_dir: str = "/var/tmp", 99 monitor_address: Optional[SocketAddrT] = None, 100 socket_scm_helper: Optional[str] = None, 101 sock_dir: Optional[str] = None, 102 drain_console: bool = False, 103 console_log: Optional[str] = None, 104 log_dir: Optional[str] = None, 105 qmp_timer: Optional[float] = None): 106 ''' 107 Initialize a QEMUMachine 108 109 @param binary: path to the qemu binary 110 @param args: list of extra arguments 111 @param wrapper: list of arguments used as prefix to qemu binary 112 @param name: prefix for socket and log file names (default: qemu-PID) 113 @param base_temp_dir: default location where temp files are created 114 @param monitor_address: address for QMP monitor 115 @param socket_scm_helper: helper program, required for send_fd_scm() 116 @param sock_dir: where to create socket (defaults to base_temp_dir) 117 @param drain_console: (optional) True to drain console socket to buffer 118 @param console_log: (optional) path to console log file 119 @param log_dir: where to create and keep log files 120 @param qmp_timer: (optional) default QMP socket timeout 121 @note: Qemu process is not started until launch() is used. 122 ''' 123 # pylint: disable=too-many-arguments 124 125 # Direct user configuration 126 127 self._binary = binary 128 self._args = list(args) 129 self._wrapper = wrapper 130 self._qmp_timer = qmp_timer 131 132 self._name = name or "qemu-%d" % os.getpid() 133 self._base_temp_dir = base_temp_dir 134 self._sock_dir = sock_dir or self._base_temp_dir 135 self._log_dir = log_dir 136 self._socket_scm_helper = socket_scm_helper 137 138 if monitor_address is not None: 139 self._monitor_address = monitor_address 140 self._remove_monitor_sockfile = False 141 else: 142 self._monitor_address = os.path.join( 143 self._sock_dir, f"{self._name}-monitor.sock" 144 ) 145 self._remove_monitor_sockfile = True 146 147 self._console_log_path = console_log 148 if self._console_log_path: 149 # In order to log the console, buffering needs to be enabled. 150 self._drain_console = True 151 else: 152 self._drain_console = drain_console 153 154 # Runstate 155 self._qemu_log_path: Optional[str] = None 156 self._qemu_log_file: Optional[BinaryIO] = None 157 self._popen: Optional['subprocess.Popen[bytes]'] = None 158 self._events: List[QMPMessage] = [] 159 self._iolog: Optional[str] = None 160 self._qmp_set = True # Enable QMP monitor by default. 161 self._qmp_connection: Optional[QEMUMonitorProtocol] = None 162 self._qemu_full_args: Tuple[str, ...] = () 163 self._temp_dir: Optional[str] = None 164 self._launched = False 165 self._machine: Optional[str] = None 166 self._console_index = 0 167 self._console_set = False 168 self._console_device_type: Optional[str] = None 169 self._console_address = os.path.join( 170 self._sock_dir, f"{self._name}-console.sock" 171 ) 172 self._console_socket: Optional[socket.socket] = None 173 self._remove_files: List[str] = [] 174 self._user_killed = False 175 176 def __enter__(self: _T) -> _T: 177 return self 178 179 def __exit__(self, 180 exc_type: Optional[Type[BaseException]], 181 exc_val: Optional[BaseException], 182 exc_tb: Optional[TracebackType]) -> None: 183 self.shutdown() 184 185 def add_monitor_null(self) -> None: 186 """ 187 This can be used to add an unused monitor instance. 188 """ 189 self._args.append('-monitor') 190 self._args.append('null') 191 192 def add_fd(self: _T, fd: int, fdset: int, 193 opaque: str, opts: str = '') -> _T: 194 """ 195 Pass a file descriptor to the VM 196 """ 197 options = ['fd=%d' % fd, 198 'set=%d' % fdset, 199 'opaque=%s' % opaque] 200 if opts: 201 options.append(opts) 202 203 # This did not exist before 3.4, but since then it is 204 # mandatory for our purpose 205 if hasattr(os, 'set_inheritable'): 206 os.set_inheritable(fd, True) 207 208 self._args.append('-add-fd') 209 self._args.append(','.join(options)) 210 return self 211 212 def send_fd_scm(self, fd: Optional[int] = None, 213 file_path: Optional[str] = None) -> int: 214 """ 215 Send an fd or file_path to socket_scm_helper. 216 217 Exactly one of fd and file_path must be given. 218 If it is file_path, the helper will open that file and pass its own fd. 219 """ 220 # In iotest.py, the qmp should always use unix socket. 221 assert self._qmp.is_scm_available() 222 if self._socket_scm_helper is None: 223 raise QEMUMachineError("No path to socket_scm_helper set") 224 if not os.path.exists(self._socket_scm_helper): 225 raise QEMUMachineError("%s does not exist" % 226 self._socket_scm_helper) 227 228 # This did not exist before 3.4, but since then it is 229 # mandatory for our purpose 230 if hasattr(os, 'set_inheritable'): 231 os.set_inheritable(self._qmp.get_sock_fd(), True) 232 if fd is not None: 233 os.set_inheritable(fd, True) 234 235 fd_param = ["%s" % self._socket_scm_helper, 236 "%d" % self._qmp.get_sock_fd()] 237 238 if file_path is not None: 239 assert fd is None 240 fd_param.append(file_path) 241 else: 242 assert fd is not None 243 fd_param.append(str(fd)) 244 245 proc = subprocess.run( 246 fd_param, 247 stdin=subprocess.DEVNULL, 248 stdout=subprocess.PIPE, 249 stderr=subprocess.STDOUT, 250 check=False, 251 close_fds=False, 252 ) 253 if proc.stdout: 254 LOG.debug(proc.stdout) 255 256 return proc.returncode 257 258 @staticmethod 259 def _remove_if_exists(path: str) -> None: 260 """ 261 Remove file object at path if it exists 262 """ 263 try: 264 os.remove(path) 265 except OSError as exception: 266 if exception.errno == errno.ENOENT: 267 return 268 raise 269 270 def is_running(self) -> bool: 271 """Returns true if the VM is running.""" 272 return self._popen is not None and self._popen.poll() is None 273 274 @property 275 def _subp(self) -> 'subprocess.Popen[bytes]': 276 if self._popen is None: 277 raise QEMUMachineError('Subprocess pipe not present') 278 return self._popen 279 280 def exitcode(self) -> Optional[int]: 281 """Returns the exit code if possible, or None.""" 282 if self._popen is None: 283 return None 284 return self._popen.poll() 285 286 def get_pid(self) -> Optional[int]: 287 """Returns the PID of the running process, or None.""" 288 if not self.is_running(): 289 return None 290 return self._subp.pid 291 292 def _load_io_log(self) -> None: 293 if self._qemu_log_path is not None: 294 with open(self._qemu_log_path, "r") as iolog: 295 self._iolog = iolog.read() 296 297 @property 298 def _base_args(self) -> List[str]: 299 args = ['-display', 'none', '-vga', 'none'] 300 301 if self._qmp_set: 302 if isinstance(self._monitor_address, tuple): 303 moncdev = "socket,id=mon,host={},port={}".format( 304 *self._monitor_address 305 ) 306 else: 307 moncdev = f"socket,id=mon,path={self._monitor_address}" 308 args.extend(['-chardev', moncdev, '-mon', 309 'chardev=mon,mode=control']) 310 311 if self._machine is not None: 312 args.extend(['-machine', self._machine]) 313 for _ in range(self._console_index): 314 args.extend(['-serial', 'null']) 315 if self._console_set: 316 chardev = ('socket,id=console,path=%s,server=on,wait=off' % 317 self._console_address) 318 args.extend(['-chardev', chardev]) 319 if self._console_device_type is None: 320 args.extend(['-serial', 'chardev:console']) 321 else: 322 device = '%s,chardev=console' % self._console_device_type 323 args.extend(['-device', device]) 324 return args 325 326 @property 327 def args(self) -> List[str]: 328 """Returns the list of arguments given to the QEMU binary.""" 329 return self._args 330 331 def _pre_launch(self) -> None: 332 if self._console_set: 333 self._remove_files.append(self._console_address) 334 335 if self._qmp_set: 336 if self._remove_monitor_sockfile: 337 assert isinstance(self._monitor_address, str) 338 self._remove_files.append(self._monitor_address) 339 self._qmp_connection = QEMUMonitorProtocol( 340 self._monitor_address, 341 server=True, 342 nickname=self._name 343 ) 344 345 # NOTE: Make sure any opened resources are *definitely* freed in 346 # _post_shutdown()! 347 # pylint: disable=consider-using-with 348 self._qemu_log_path = os.path.join(self.log_dir, self._name + ".log") 349 self._qemu_log_file = open(self._qemu_log_path, 'wb') 350 351 def _post_launch(self) -> None: 352 if self._qmp_connection: 353 self._qmp.accept(self._qmp_timer) 354 355 def _close_qemu_log_file(self) -> None: 356 if self._qemu_log_file is not None: 357 self._qemu_log_file.close() 358 self._qemu_log_file = None 359 360 def _post_shutdown(self) -> None: 361 """ 362 Called to cleanup the VM instance after the process has exited. 363 May also be called after a failed launch. 364 """ 365 # Comprehensive reset for the failed launch case: 366 self._early_cleanup() 367 368 if self._qmp_connection: 369 self._qmp.close() 370 self._qmp_connection = None 371 372 self._close_qemu_log_file() 373 374 self._load_io_log() 375 376 self._qemu_log_path = None 377 378 if self._temp_dir is not None: 379 shutil.rmtree(self._temp_dir) 380 self._temp_dir = None 381 382 while len(self._remove_files) > 0: 383 self._remove_if_exists(self._remove_files.pop()) 384 385 exitcode = self.exitcode() 386 if (exitcode is not None and exitcode < 0 387 and not (self._user_killed and exitcode == -signal.SIGKILL)): 388 msg = 'qemu received signal %i; command: "%s"' 389 if self._qemu_full_args: 390 command = ' '.join(self._qemu_full_args) 391 else: 392 command = '' 393 LOG.warning(msg, -int(exitcode), command) 394 395 self._user_killed = False 396 self._launched = False 397 398 def launch(self) -> None: 399 """ 400 Launch the VM and make sure we cleanup and expose the 401 command line/output in case of exception 402 """ 403 404 if self._launched: 405 raise QEMUMachineError('VM already launched') 406 407 self._iolog = None 408 self._qemu_full_args = () 409 try: 410 self._launch() 411 self._launched = True 412 except: 413 self._post_shutdown() 414 415 LOG.debug('Error launching VM') 416 if self._qemu_full_args: 417 LOG.debug('Command: %r', ' '.join(self._qemu_full_args)) 418 if self._iolog: 419 LOG.debug('Output: %r', self._iolog) 420 raise 421 422 def _launch(self) -> None: 423 """ 424 Launch the VM and establish a QMP connection 425 """ 426 self._pre_launch() 427 self._qemu_full_args = tuple( 428 chain(self._wrapper, 429 [self._binary], 430 self._base_args, 431 self._args) 432 ) 433 LOG.debug('VM launch command: %r', ' '.join(self._qemu_full_args)) 434 435 # Cleaning up of this subprocess is guaranteed by _do_shutdown. 436 # pylint: disable=consider-using-with 437 self._popen = subprocess.Popen(self._qemu_full_args, 438 stdin=subprocess.DEVNULL, 439 stdout=self._qemu_log_file, 440 stderr=subprocess.STDOUT, 441 shell=False, 442 close_fds=False) 443 self._post_launch() 444 445 def _early_cleanup(self) -> None: 446 """ 447 Perform any cleanup that needs to happen before the VM exits. 448 449 May be invoked by both soft and hard shutdown in failover scenarios. 450 Called additionally by _post_shutdown for comprehensive cleanup. 451 """ 452 # If we keep the console socket open, we may deadlock waiting 453 # for QEMU to exit, while QEMU is waiting for the socket to 454 # become writeable. 455 if self._console_socket is not None: 456 self._console_socket.close() 457 self._console_socket = None 458 459 def _hard_shutdown(self) -> None: 460 """ 461 Perform early cleanup, kill the VM, and wait for it to terminate. 462 463 :raise subprocess.Timeout: When timeout is exceeds 60 seconds 464 waiting for the QEMU process to terminate. 465 """ 466 self._early_cleanup() 467 self._subp.kill() 468 self._subp.wait(timeout=60) 469 470 def _soft_shutdown(self, timeout: Optional[int], 471 has_quit: bool = False) -> None: 472 """ 473 Perform early cleanup, attempt to gracefully shut down the VM, and wait 474 for it to terminate. 475 476 :param timeout: Timeout in seconds for graceful shutdown. 477 A value of None is an infinite wait. 478 :param has_quit: When True, don't attempt to issue 'quit' QMP command 479 480 :raise ConnectionReset: On QMP communication errors 481 :raise subprocess.TimeoutExpired: When timeout is exceeded waiting for 482 the QEMU process to terminate. 483 """ 484 self._early_cleanup() 485 486 if self._qmp_connection: 487 if not has_quit: 488 # Might raise ConnectionReset 489 self._qmp.cmd('quit') 490 491 # May raise subprocess.TimeoutExpired 492 self._subp.wait(timeout=timeout) 493 494 def _do_shutdown(self, timeout: Optional[int], 495 has_quit: bool = False) -> None: 496 """ 497 Attempt to shutdown the VM gracefully; fallback to a hard shutdown. 498 499 :param timeout: Timeout in seconds for graceful shutdown. 500 A value of None is an infinite wait. 501 :param has_quit: When True, don't attempt to issue 'quit' QMP command 502 503 :raise AbnormalShutdown: When the VM could not be shut down gracefully. 504 The inner exception will likely be ConnectionReset or 505 subprocess.TimeoutExpired. In rare cases, non-graceful termination 506 may result in its own exceptions, likely subprocess.TimeoutExpired. 507 """ 508 try: 509 self._soft_shutdown(timeout, has_quit) 510 except Exception as exc: 511 self._hard_shutdown() 512 raise AbnormalShutdown("Could not perform graceful shutdown") \ 513 from exc 514 515 def shutdown(self, has_quit: bool = False, 516 hard: bool = False, 517 timeout: Optional[int] = 30) -> None: 518 """ 519 Terminate the VM (gracefully if possible) and perform cleanup. 520 Cleanup will always be performed. 521 522 If the VM has not yet been launched, or shutdown(), wait(), or kill() 523 have already been called, this method does nothing. 524 525 :param has_quit: When true, do not attempt to issue 'quit' QMP command. 526 :param hard: When true, do not attempt graceful shutdown, and 527 suppress the SIGKILL warning log message. 528 :param timeout: Optional timeout in seconds for graceful shutdown. 529 Default 30 seconds, A `None` value is an infinite wait. 530 """ 531 if not self._launched: 532 return 533 534 try: 535 if hard: 536 self._user_killed = True 537 self._hard_shutdown() 538 else: 539 self._do_shutdown(timeout, has_quit) 540 finally: 541 self._post_shutdown() 542 543 def kill(self) -> None: 544 """ 545 Terminate the VM forcefully, wait for it to exit, and perform cleanup. 546 """ 547 self.shutdown(hard=True) 548 549 def wait(self, timeout: Optional[int] = 30) -> None: 550 """ 551 Wait for the VM to power off and perform post-shutdown cleanup. 552 553 :param timeout: Optional timeout in seconds. Default 30 seconds. 554 A value of `None` is an infinite wait. 555 """ 556 self.shutdown(has_quit=True, timeout=timeout) 557 558 def set_qmp_monitor(self, enabled: bool = True) -> None: 559 """ 560 Set the QMP monitor. 561 562 @param enabled: if False, qmp monitor options will be removed from 563 the base arguments of the resulting QEMU command 564 line. Default is True. 565 566 .. note:: Call this function before launch(). 567 """ 568 self._qmp_set = enabled 569 570 @property 571 def _qmp(self) -> QEMUMonitorProtocol: 572 if self._qmp_connection is None: 573 raise QEMUMachineError("Attempt to access QMP with no connection") 574 return self._qmp_connection 575 576 @classmethod 577 def _qmp_args(cls, conv_keys: bool, 578 args: Dict[str, Any]) -> Dict[str, object]: 579 if conv_keys: 580 return {k.replace('_', '-'): v for k, v in args.items()} 581 582 return args 583 584 def qmp(self, cmd: str, 585 args_dict: Optional[Dict[str, object]] = None, 586 conv_keys: Optional[bool] = None, 587 **args: Any) -> QMPMessage: 588 """ 589 Invoke a QMP command and return the response dict 590 """ 591 if args_dict is not None: 592 assert not args 593 assert conv_keys is None 594 args = args_dict 595 conv_keys = False 596 597 if conv_keys is None: 598 conv_keys = True 599 600 qmp_args = self._qmp_args(conv_keys, args) 601 return self._qmp.cmd(cmd, args=qmp_args) 602 603 def command(self, cmd: str, 604 conv_keys: bool = True, 605 **args: Any) -> QMPReturnValue: 606 """ 607 Invoke a QMP command. 608 On success return the response dict. 609 On failure raise an exception. 610 """ 611 qmp_args = self._qmp_args(conv_keys, args) 612 return self._qmp.command(cmd, **qmp_args) 613 614 def get_qmp_event(self, wait: bool = False) -> Optional[QMPMessage]: 615 """ 616 Poll for one queued QMP events and return it 617 """ 618 if self._events: 619 return self._events.pop(0) 620 return self._qmp.pull_event(wait=wait) 621 622 def get_qmp_events(self, wait: bool = False) -> List[QMPMessage]: 623 """ 624 Poll for queued QMP events and return a list of dicts 625 """ 626 events = self._qmp.get_events(wait=wait) 627 events.extend(self._events) 628 del self._events[:] 629 self._qmp.clear_events() 630 return events 631 632 @staticmethod 633 def event_match(event: Any, match: Optional[Any]) -> bool: 634 """ 635 Check if an event matches optional match criteria. 636 637 The match criteria takes the form of a matching subdict. The event is 638 checked to be a superset of the subdict, recursively, with matching 639 values whenever the subdict values are not None. 640 641 This has a limitation that you cannot explicitly check for None values. 642 643 Examples, with the subdict queries on the left: 644 - None matches any object. 645 - {"foo": None} matches {"foo": {"bar": 1}} 646 - {"foo": None} matches {"foo": 5} 647 - {"foo": {"abc": None}} does not match {"foo": {"bar": 1}} 648 - {"foo": {"rab": 2}} matches {"foo": {"bar": 1, "rab": 2}} 649 """ 650 if match is None: 651 return True 652 653 try: 654 for key in match: 655 if key in event: 656 if not QEMUMachine.event_match(event[key], match[key]): 657 return False 658 else: 659 return False 660 return True 661 except TypeError: 662 # either match or event wasn't iterable (not a dict) 663 return bool(match == event) 664 665 def event_wait(self, name: str, 666 timeout: float = 60.0, 667 match: Optional[QMPMessage] = None) -> Optional[QMPMessage]: 668 """ 669 event_wait waits for and returns a named event from QMP with a timeout. 670 671 name: The event to wait for. 672 timeout: QEMUMonitorProtocol.pull_event timeout parameter. 673 match: Optional match criteria. See event_match for details. 674 """ 675 return self.events_wait([(name, match)], timeout) 676 677 def events_wait(self, 678 events: Sequence[Tuple[str, Any]], 679 timeout: float = 60.0) -> Optional[QMPMessage]: 680 """ 681 events_wait waits for and returns a single named event from QMP. 682 In the case of multiple qualifying events, this function returns the 683 first one. 684 685 :param events: A sequence of (name, match_criteria) tuples. 686 The match criteria are optional and may be None. 687 See event_match for details. 688 :param timeout: Optional timeout, in seconds. 689 See QEMUMonitorProtocol.pull_event. 690 691 :raise QMPTimeoutError: If timeout was non-zero and no matching events 692 were found. 693 :return: A QMP event matching the filter criteria. 694 If timeout was 0 and no event matched, None. 695 """ 696 def _match(event: QMPMessage) -> bool: 697 for name, match in events: 698 if event['event'] == name and self.event_match(event, match): 699 return True 700 return False 701 702 event: Optional[QMPMessage] 703 704 # Search cached events 705 for event in self._events: 706 if _match(event): 707 self._events.remove(event) 708 return event 709 710 # Poll for new events 711 while True: 712 event = self._qmp.pull_event(wait=timeout) 713 if event is None: 714 # NB: None is only returned when timeout is false-ish. 715 # Timeouts raise QMPTimeoutError instead! 716 break 717 if _match(event): 718 return event 719 self._events.append(event) 720 721 return None 722 723 def get_log(self) -> Optional[str]: 724 """ 725 After self.shutdown or failed qemu execution, this returns the output 726 of the qemu process. 727 """ 728 return self._iolog 729 730 def add_args(self, *args: str) -> None: 731 """ 732 Adds to the list of extra arguments to be given to the QEMU binary 733 """ 734 self._args.extend(args) 735 736 def set_machine(self, machine_type: str) -> None: 737 """ 738 Sets the machine type 739 740 If set, the machine type will be added to the base arguments 741 of the resulting QEMU command line. 742 """ 743 self._machine = machine_type 744 745 def set_console(self, 746 device_type: Optional[str] = None, 747 console_index: int = 0) -> None: 748 """ 749 Sets the device type for a console device 750 751 If set, the console device and a backing character device will 752 be added to the base arguments of the resulting QEMU command 753 line. 754 755 This is a convenience method that will either use the provided 756 device type, or default to a "-serial chardev:console" command 757 line argument. 758 759 The actual setting of command line arguments will be be done at 760 machine launch time, as it depends on the temporary directory 761 to be created. 762 763 @param device_type: the device type, such as "isa-serial". If 764 None is given (the default value) a "-serial 765 chardev:console" command line argument will 766 be used instead, resorting to the machine's 767 default device type. 768 @param console_index: the index of the console device to use. 769 If not zero, the command line will create 770 'index - 1' consoles and connect them to 771 the 'null' backing character device. 772 """ 773 self._console_set = True 774 self._console_device_type = device_type 775 self._console_index = console_index 776 777 @property 778 def console_socket(self) -> socket.socket: 779 """ 780 Returns a socket connected to the console 781 """ 782 if self._console_socket is None: 783 self._console_socket = console_socket.ConsoleSocket( 784 self._console_address, 785 file=self._console_log_path, 786 drain=self._drain_console) 787 return self._console_socket 788 789 @property 790 def temp_dir(self) -> str: 791 """ 792 Returns a temporary directory to be used for this machine 793 """ 794 if self._temp_dir is None: 795 self._temp_dir = tempfile.mkdtemp(prefix="qemu-machine-", 796 dir=self._base_temp_dir) 797 return self._temp_dir 798 799 @property 800 def log_dir(self) -> str: 801 """ 802 Returns a directory to be used for writing logs 803 """ 804 if self._log_dir is None: 805 return self.temp_dir 806 return self._log_dir 807