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