atex 0.5__py3-none-any.whl → 0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atex/__init__.py +2 -12
- atex/cli/__init__.py +13 -13
- atex/cli/fmf.py +93 -0
- atex/cli/testingfarm.py +71 -61
- atex/connection/__init__.py +117 -0
- atex/connection/ssh.py +390 -0
- atex/executor/__init__.py +2 -0
- atex/executor/duration.py +60 -0
- atex/executor/executor.py +378 -0
- atex/executor/reporter.py +106 -0
- atex/executor/scripts.py +155 -0
- atex/executor/testcontrol.py +353 -0
- atex/fmf.py +217 -0
- atex/orchestrator/__init__.py +2 -0
- atex/orchestrator/aggregator.py +106 -0
- atex/orchestrator/orchestrator.py +324 -0
- atex/provision/__init__.py +101 -90
- atex/provision/libvirt/VM_PROVISION +8 -0
- atex/provision/libvirt/__init__.py +4 -4
- atex/provision/podman/README +59 -0
- atex/provision/podman/host_container.sh +74 -0
- atex/provision/testingfarm/__init__.py +2 -0
- atex/{testingfarm.py → provision/testingfarm/api.py} +170 -132
- atex/provision/testingfarm/testingfarm.py +236 -0
- atex/util/__init__.py +5 -10
- atex/util/dedent.py +1 -1
- atex/util/log.py +20 -12
- atex/util/path.py +16 -0
- atex/util/ssh_keygen.py +14 -0
- atex/util/subprocess.py +14 -13
- atex/util/threads.py +55 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/METADATA +97 -2
- atex-0.8.dist-info/RECORD +37 -0
- atex/cli/minitmt.py +0 -82
- atex/minitmt/__init__.py +0 -115
- atex/minitmt/fmf.py +0 -168
- atex/minitmt/report.py +0 -174
- atex/minitmt/scripts.py +0 -51
- atex/minitmt/testme.py +0 -3
- atex/orchestrator.py +0 -38
- atex/ssh.py +0 -320
- atex/util/lockable_class.py +0 -38
- atex-0.5.dist-info/RECORD +0 -26
- {atex-0.5.dist-info → atex-0.8.dist-info}/WHEEL +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/entry_points.txt +0 -0
- {atex-0.5.dist-info → atex-0.8.dist-info}/licenses/COPYING.txt +0 -0
atex/connection/ssh.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection API implementation using the OpenSSH ssh(1) client.
|
|
3
|
+
|
|
4
|
+
Any SSH options are passed via dictionaries of options, and later translated
|
|
5
|
+
to '-o' client CLI options, incl. Hostname, User, Port, IdentityFile, etc.
|
|
6
|
+
No "typical" ssh CLI switches are used.
|
|
7
|
+
|
|
8
|
+
This allows for a nice flexibility from Python code - this module provides
|
|
9
|
+
some sensible option defaults (for scripted use), but you are free to
|
|
10
|
+
overwrite any options via class or function arguments (where appropriate).
|
|
11
|
+
|
|
12
|
+
Note that .cmd() quotes arguments to really execute individual arguments
|
|
13
|
+
as individual arguments in the remote shell, so you need to give it a proper
|
|
14
|
+
iterable (like for other Connections), not a single string with spaces.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import shlex
|
|
20
|
+
import tempfile
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .. import util
|
|
25
|
+
from . import Connection
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_OPTIONS = {
|
|
29
|
+
"LogLevel": "ERROR",
|
|
30
|
+
"StrictHostKeyChecking": "no",
|
|
31
|
+
"UserKnownHostsFile": "/dev/null",
|
|
32
|
+
"ConnectionAttempts": "3",
|
|
33
|
+
"ServerAliveCountMax": "4",
|
|
34
|
+
"ServerAliveInterval": "5",
|
|
35
|
+
"TCPKeepAlive": "no",
|
|
36
|
+
"EscapeChar": "none",
|
|
37
|
+
"ExitOnForwardFailure": "yes",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SSHError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DisconnectedError(SSHError):
|
|
46
|
+
"""
|
|
47
|
+
Raised when an already-connected ssh session goes away (breaks connection).
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotConnectedError(SSHError):
|
|
53
|
+
"""
|
|
54
|
+
Raised when an operation on ssh connection is requested, but the connection
|
|
55
|
+
is not yet open (or has been closed/disconnected).
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConnectError(SSHError):
|
|
61
|
+
"""
|
|
62
|
+
Raised when a to-be-opened ssh connection fails to open.
|
|
63
|
+
"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _shell_cmd(command, sudo=None):
|
|
68
|
+
"""
|
|
69
|
+
Make a command line for running 'command' on the target system.
|
|
70
|
+
"""
|
|
71
|
+
quoted_args = (shlex.quote(str(arg)) for arg in command)
|
|
72
|
+
if sudo:
|
|
73
|
+
return " ".join((
|
|
74
|
+
"exec", "sudo", "--no-update", "--non-interactive", "--user", sudo, "--", *quoted_args,
|
|
75
|
+
))
|
|
76
|
+
else:
|
|
77
|
+
return " ".join(("exec", *quoted_args))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _options_to_cli(options):
|
|
81
|
+
"""
|
|
82
|
+
Assemble an ssh(1) or sshpass(1) command line with -o options.
|
|
83
|
+
"""
|
|
84
|
+
list_opts = []
|
|
85
|
+
for key, value in options.items():
|
|
86
|
+
if isinstance(value, (list, tuple, set)):
|
|
87
|
+
list_opts += (f"-o{key}={v}" for v in value)
|
|
88
|
+
else:
|
|
89
|
+
list_opts.append(f"-o{key}={value}")
|
|
90
|
+
return list_opts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _options_to_ssh(options, password=None, extra_cli_flags=()):
|
|
94
|
+
"""
|
|
95
|
+
Assemble an ssh(1) or sshpass(1) command line with -o options.
|
|
96
|
+
"""
|
|
97
|
+
cli_opts = _options_to_cli(options)
|
|
98
|
+
if password:
|
|
99
|
+
return (
|
|
100
|
+
"sshpass", "-p", password,
|
|
101
|
+
"ssh", *extra_cli_flags, "-oBatchMode=no", *cli_opts,
|
|
102
|
+
"ignored_arg",
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
# let cli_opts override BatchMode if specified
|
|
106
|
+
return ("ssh", *extra_cli_flags, *cli_opts, "-oBatchMode=yes", "ignored_arg")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# return a string usable for rsync -e
|
|
110
|
+
def _options_to_rsync_e(options, password=None):
|
|
111
|
+
"""
|
|
112
|
+
Return a string usable for the rsync -e argument.
|
|
113
|
+
"""
|
|
114
|
+
cli_opts = _options_to_cli(options)
|
|
115
|
+
batch_mode = "-oBatchMode=no" if password else "-oBatchMode=yes"
|
|
116
|
+
return " ".join(("ssh", *cli_opts, batch_mode)) # no ignored_arg inside -e
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rsync_host_cmd(*args, options, password=None, sudo=None):
|
|
120
|
+
"""
|
|
121
|
+
Assemble a rsync command line, noting that
|
|
122
|
+
- 'sshpass' must be before 'rsync', not inside the '-e' argument
|
|
123
|
+
- 'ignored_arg' must be passed by user as destination, not inside '-e'
|
|
124
|
+
- 'sudo' is part of '--rsync-path', yet another argument
|
|
125
|
+
"""
|
|
126
|
+
return (
|
|
127
|
+
*(("sshpass", "-p", password) if password else ()),
|
|
128
|
+
"rsync",
|
|
129
|
+
"-e", _options_to_rsync_e(options, password=password),
|
|
130
|
+
"--rsync-path", _shell_cmd(("rsync",), sudo=sudo),
|
|
131
|
+
*args,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class StatelessSSHConn(Connection):
|
|
136
|
+
"""
|
|
137
|
+
Implements the Connection API using a ssh(1) client using "standalone"
|
|
138
|
+
(stateless) logic - connect() and disconnect() are no-op, .cmd() simply
|
|
139
|
+
executes the ssh client and .rsync() executes 'rsync -e ssh'.
|
|
140
|
+
|
|
141
|
+
Compared to ManagedSSHConn, this may be slow for many .cmd() calls,
|
|
142
|
+
but every call is stateless, there is no persistent connection.
|
|
143
|
+
|
|
144
|
+
If you need only one .cmd(), this will be faster than ManagedSSHConn.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, options, *, password=None, sudo=None):
|
|
148
|
+
"""
|
|
149
|
+
Prepare to connect to an SSH server specified in 'options'.
|
|
150
|
+
|
|
151
|
+
If 'password' is given, spawn the ssh(1) command via 'sshpass' and
|
|
152
|
+
pass the password to it.
|
|
153
|
+
|
|
154
|
+
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
155
|
+
to run under a different user on the remote host.
|
|
156
|
+
"""
|
|
157
|
+
super().__init__()
|
|
158
|
+
self.options = DEFAULT_OPTIONS.copy()
|
|
159
|
+
self.options.update(options)
|
|
160
|
+
self.password = password
|
|
161
|
+
self.sudo = sudo
|
|
162
|
+
self._tmpdir = None
|
|
163
|
+
self._master_proc = None
|
|
164
|
+
|
|
165
|
+
def connect(self):
|
|
166
|
+
"""
|
|
167
|
+
Optional, .cmd() and .rsync() work without it, but it is provided here
|
|
168
|
+
for compatibility with the Connection API.
|
|
169
|
+
"""
|
|
170
|
+
# TODO: just wait until .cmd(['true']) starts responding ?
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def disconnect(self):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# have options as kwarg to be compatible with other functions here
|
|
177
|
+
def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
|
|
178
|
+
unified_options = self.options.copy()
|
|
179
|
+
if options:
|
|
180
|
+
unified_options.update(options)
|
|
181
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
182
|
+
return func(
|
|
183
|
+
_options_to_ssh(unified_options, password=self.password),
|
|
184
|
+
skip_frames=1,
|
|
185
|
+
**func_args,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def rsync(self, *args, options=None, func=util.subprocess_run, **func_args):
|
|
189
|
+
unified_options = self.options.copy()
|
|
190
|
+
if options:
|
|
191
|
+
unified_options.update(options)
|
|
192
|
+
return func(
|
|
193
|
+
_rsync_host_cmd(
|
|
194
|
+
*args,
|
|
195
|
+
options=unified_options,
|
|
196
|
+
password=self.password,
|
|
197
|
+
sudo=self.sudo,
|
|
198
|
+
),
|
|
199
|
+
skip_frames=1,
|
|
200
|
+
check=True,
|
|
201
|
+
stdin=subprocess.DEVNULL,
|
|
202
|
+
**func_args,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Note that when ControlMaster goes away (connection breaks), any ssh clients
|
|
207
|
+
# connected through it will time out after a combination of
|
|
208
|
+
# ServerAliveCountMax + ServerAliveInterval + ConnectionAttempts
|
|
209
|
+
# identical to the ControlMaster process.
|
|
210
|
+
# Specifying different values for the clients, to make them exit faster when
|
|
211
|
+
# the ControlMaster dies, has no effect. They seem to ignore the options.
|
|
212
|
+
#
|
|
213
|
+
# If you need to kill the clients quickly after ControlMaster disconnects,
|
|
214
|
+
# you need to set up an independent polling logic (ie. every 0.1sec) that
|
|
215
|
+
# checks .assert_master() and manually signals the running clients
|
|
216
|
+
# when it gets DisconnectedError from it.
|
|
217
|
+
|
|
218
|
+
class ManagedSSHConn(Connection):
|
|
219
|
+
"""
|
|
220
|
+
Implements the Connection API using one persistently-running ssh(1) client
|
|
221
|
+
started in a 'ControlMaster' mode, with additional ssh clients using that
|
|
222
|
+
session to execute remote commands. Similarly, .rsync() uses it too.
|
|
223
|
+
|
|
224
|
+
This is much faster than StatelessSSHConn when executing multiple commands,
|
|
225
|
+
but contains a complex internal state (what if ControlMaster disconnects?).
|
|
226
|
+
|
|
227
|
+
Hence why this implementation provides extra non-standard-Connection methods
|
|
228
|
+
to manage this complexity.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
# TODO: thread safety and locking via self.lock ?
|
|
232
|
+
|
|
233
|
+
def __init__(self, options, *, password=None, sudo=None):
|
|
234
|
+
"""
|
|
235
|
+
Prepare to connect to an SSH server specified in 'options'.
|
|
236
|
+
|
|
237
|
+
If 'password' is given, spawn the ssh(1) command via 'sshpass' and
|
|
238
|
+
pass the password to it.
|
|
239
|
+
|
|
240
|
+
If 'sudo' specifies a username, call sudo(8) on the remote shell
|
|
241
|
+
to run under a different user on the remote host.
|
|
242
|
+
"""
|
|
243
|
+
super().__init__()
|
|
244
|
+
self.options = DEFAULT_OPTIONS.copy()
|
|
245
|
+
self.options.update(options)
|
|
246
|
+
self.password = password
|
|
247
|
+
self.sudo = sudo
|
|
248
|
+
self._tmpdir = None
|
|
249
|
+
self._master_proc = None
|
|
250
|
+
|
|
251
|
+
def assert_master(self):
|
|
252
|
+
proc = self._master_proc
|
|
253
|
+
if not proc:
|
|
254
|
+
raise NotConnectedError("SSH ControlMaster is not running")
|
|
255
|
+
# we need to consume any potential proc output for the process to
|
|
256
|
+
# actually terminate (stop being a zombie) if it crashes
|
|
257
|
+
out = proc.stdout.read()
|
|
258
|
+
code = proc.poll()
|
|
259
|
+
if code is not None:
|
|
260
|
+
self._master_proc = None
|
|
261
|
+
out = f":\n{out.decode()}" if out else ""
|
|
262
|
+
raise DisconnectedError(
|
|
263
|
+
f"SSH ControlMaster on {self._tmpdir} exited with {code}{out}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def disconnect(self):
|
|
267
|
+
proc = self._master_proc
|
|
268
|
+
if not proc:
|
|
269
|
+
return
|
|
270
|
+
proc.kill()
|
|
271
|
+
# don"t zombie forever, return EPIPE on any attempts to write to us
|
|
272
|
+
proc.stdout.close()
|
|
273
|
+
proc.wait()
|
|
274
|
+
(self._tmpdir / "control.sock").unlink(missing_ok=True)
|
|
275
|
+
self._master_proc = None
|
|
276
|
+
|
|
277
|
+
def connect(self, block=True):
|
|
278
|
+
if not self._tmpdir:
|
|
279
|
+
# _tmpdir_handle just prevents the TemporaryDirectory instance
|
|
280
|
+
# from being garbage collected (and removed on disk)
|
|
281
|
+
# TODO: create/remove it explicitly in connect/disconnect
|
|
282
|
+
# so the removal happens immediately, even if GC delays cleaning
|
|
283
|
+
self._tmpdir_handle = tempfile.TemporaryDirectory(prefix="atex-ssh-")
|
|
284
|
+
self._tmpdir = Path(self._tmpdir_handle.name)
|
|
285
|
+
|
|
286
|
+
sock = self._tmpdir / "control.sock"
|
|
287
|
+
|
|
288
|
+
if not self._master_proc:
|
|
289
|
+
options = self.options.copy()
|
|
290
|
+
options["SessionType"] = "none"
|
|
291
|
+
options["ControlMaster"] = "yes"
|
|
292
|
+
options["ControlPath"] = sock
|
|
293
|
+
self._master_proc = util.subprocess_Popen(
|
|
294
|
+
_options_to_ssh(options),
|
|
295
|
+
stdin=subprocess.DEVNULL,
|
|
296
|
+
stdout=subprocess.PIPE,
|
|
297
|
+
stderr=subprocess.STDOUT,
|
|
298
|
+
cwd=str(self._tmpdir),
|
|
299
|
+
)
|
|
300
|
+
os.set_blocking(self._master_proc.stdout.fileno(), False)
|
|
301
|
+
|
|
302
|
+
proc = self._master_proc
|
|
303
|
+
if block:
|
|
304
|
+
while proc.poll() is None:
|
|
305
|
+
if sock.exists():
|
|
306
|
+
break
|
|
307
|
+
time.sleep(0.1)
|
|
308
|
+
else:
|
|
309
|
+
code = proc.poll()
|
|
310
|
+
out = proc.stdout.read()
|
|
311
|
+
self._master_proc = None
|
|
312
|
+
# TODO: ConnectError should probably be generalized for Connection
|
|
313
|
+
raise ConnectError(
|
|
314
|
+
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
code = proc.poll()
|
|
318
|
+
if code is not None:
|
|
319
|
+
out = proc.stdout.read()
|
|
320
|
+
self._master_proc = None
|
|
321
|
+
# TODO: ConnectError should probably be generalized for Connection
|
|
322
|
+
raise ConnectError(
|
|
323
|
+
f"SSH ControlMaster failed to start on {self._tmpdir} with {code}:\n{out}",
|
|
324
|
+
)
|
|
325
|
+
elif not sock.exists():
|
|
326
|
+
raise BlockingIOError("SSH ControlMaster not yet ready")
|
|
327
|
+
|
|
328
|
+
def add_local_forward(self, *spec):
|
|
329
|
+
"""
|
|
330
|
+
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
331
|
+
already-connected instance. Each specification has to follow the
|
|
332
|
+
format of ssh client's LocalForward option (see ssh_config(5)).
|
|
333
|
+
"""
|
|
334
|
+
self.assert_master()
|
|
335
|
+
options = self.options.copy()
|
|
336
|
+
options["LocalForward"] = spec
|
|
337
|
+
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
338
|
+
util.subprocess_run(
|
|
339
|
+
_options_to_ssh(options, extra_cli_flags=("-O", "forward")),
|
|
340
|
+
skip_frames=1,
|
|
341
|
+
check=True,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def add_remote_forward(self, *spec):
|
|
345
|
+
"""
|
|
346
|
+
Add (one or more) ssh forwarding specifications as 'spec' to an
|
|
347
|
+
already-connected instance. Each specification has to follow the
|
|
348
|
+
format of ssh client's RemoteForward option (see ssh_config(5)).
|
|
349
|
+
"""
|
|
350
|
+
self.assert_master()
|
|
351
|
+
options = self.options.copy()
|
|
352
|
+
options["RemoteForward"] = spec
|
|
353
|
+
options["ControlPath"] = self._tmpdir / "control.sock"
|
|
354
|
+
util.subprocess_run(
|
|
355
|
+
_options_to_ssh(options, extra_cli_flags=("-O", "forward")),
|
|
356
|
+
skip_frames=1,
|
|
357
|
+
check=True,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def cmd(self, command, options=None, func=util.subprocess_run, **func_args):
|
|
361
|
+
self.assert_master()
|
|
362
|
+
unified_options = self.options.copy()
|
|
363
|
+
if options:
|
|
364
|
+
unified_options.update(options)
|
|
365
|
+
unified_options["RemoteCommand"] = _shell_cmd(command, sudo=self.sudo)
|
|
366
|
+
unified_options["ControlPath"] = self._tmpdir / "control.sock"
|
|
367
|
+
return func(
|
|
368
|
+
_options_to_ssh(unified_options, password=self.password),
|
|
369
|
+
skip_frames=1,
|
|
370
|
+
**func_args,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def rsync(self, *args, options=None, func=util.subprocess_run, **func_args):
|
|
374
|
+
self.assert_master()
|
|
375
|
+
unified_options = self.options.copy()
|
|
376
|
+
if options:
|
|
377
|
+
unified_options.update(options)
|
|
378
|
+
unified_options["ControlPath"] = self._tmpdir / "control.sock"
|
|
379
|
+
return func(
|
|
380
|
+
_rsync_host_cmd(
|
|
381
|
+
*args,
|
|
382
|
+
options=unified_options,
|
|
383
|
+
password=self.password,
|
|
384
|
+
sudo=self.sudo,
|
|
385
|
+
),
|
|
386
|
+
skip_frames=1,
|
|
387
|
+
check=True,
|
|
388
|
+
stdin=subprocess.DEVNULL,
|
|
389
|
+
**func_args,
|
|
390
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Duration:
|
|
6
|
+
"""
|
|
7
|
+
A helper for parsing, keeping and manipulating test run time based on
|
|
8
|
+
FMF-defined 'duration' attribute.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, fmf_duration):
|
|
12
|
+
"""
|
|
13
|
+
'fmf_duration' is the string specified as 'duration' in FMF metadata.
|
|
14
|
+
"""
|
|
15
|
+
duration = self._fmf_to_seconds(fmf_duration)
|
|
16
|
+
self.end = time.monotonic() + duration
|
|
17
|
+
# keep track of only the first 'save' and the last 'restore',
|
|
18
|
+
# ignore any nested ones (as tracked by '_count')
|
|
19
|
+
self.saved = None
|
|
20
|
+
self.saved_count = 0
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _fmf_to_seconds(string):
|
|
24
|
+
match = re.fullmatch(r"([0-9]+)([a-z]*)", string)
|
|
25
|
+
if not match:
|
|
26
|
+
raise RuntimeError(f"'duration' has invalid format: {string}")
|
|
27
|
+
length, unit = match.groups()
|
|
28
|
+
if unit == "m":
|
|
29
|
+
return int(length)*60
|
|
30
|
+
elif unit == "h":
|
|
31
|
+
return int(length)*60*60
|
|
32
|
+
elif unit == "d":
|
|
33
|
+
return int(length)*60*60*24
|
|
34
|
+
else:
|
|
35
|
+
return int(length)
|
|
36
|
+
|
|
37
|
+
def set(self, to):
|
|
38
|
+
self.end = time.monotonic() + self._fmf_to_seconds(to)
|
|
39
|
+
|
|
40
|
+
def increment(self, by):
|
|
41
|
+
self.end += self._fmf_to_seconds(by)
|
|
42
|
+
|
|
43
|
+
def decrement(self, by):
|
|
44
|
+
self.end -= self._fmf_to_seconds(by)
|
|
45
|
+
|
|
46
|
+
def save(self):
|
|
47
|
+
if self.saved_count == 0:
|
|
48
|
+
self.saved = self.end - time.monotonic()
|
|
49
|
+
self.saved_count += 1
|
|
50
|
+
|
|
51
|
+
def restore(self):
|
|
52
|
+
if self.saved_count > 1:
|
|
53
|
+
self.saved_count -= 1
|
|
54
|
+
elif self.saved_count == 1:
|
|
55
|
+
self.end = time.monotonic() + self.saved
|
|
56
|
+
self.saved_count = 0
|
|
57
|
+
self.saved = None
|
|
58
|
+
|
|
59
|
+
def out_of_time(self):
|
|
60
|
+
return time.monotonic() > self.end
|