hypershell 2.5.1__tar.gz → 2.6.1__tar.gz
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.
- {hypershell-2.5.1 → hypershell-2.6.1}/PKG-INFO +24 -21
- {hypershell-2.5.1 → hypershell-2.6.1}/README.rst +12 -12
- {hypershell-2.5.1 → hypershell-2.6.1}/pyproject.toml +13 -10
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/__init__.py +3 -2
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/client.py +1 -2
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/local.py +2 -1
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/remote.py +4 -3
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/ssh.py +3 -2
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/config.py +3 -15
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/config.py +2 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/fsm.py +21 -6
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/queue.py +3 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/signal.py +28 -6
- hypershell-2.6.1/src/hypershell/core/tag.py +47 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/data/core.py +8 -1
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/data/model.py +56 -12
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/server.py +0 -3
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/submit.py +12 -3
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/task.py +25 -42
- {hypershell-2.5.1 → hypershell-2.6.1}/LICENSE +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/__init__.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/__init__.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/exceptions.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/heartbeat.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/logging.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/platform.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/remote.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/template.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/thread.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/types.py +0 -0
- {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/data/__init__.py +0 -0
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: hypershell
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: A cross-platform, high-throughput computing utility for processing shell commands over a distributed, asynchronous queue.
|
|
5
|
-
Home-page: https://
|
|
5
|
+
Home-page: https://hypershell.org
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Keywords: distributed-computing,command-line-tool,shell-scripting,high-performance-computing,high-throughput-computing
|
|
8
8
|
Author: Geoffrey Lentner
|
|
9
9
|
Author-email: glentner@purdue.edu
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
11
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
13
|
Classifier: Operating System :: MacOS
|
|
14
14
|
Classifier: Operating System :: Microsoft :: Windows
|
|
15
15
|
Classifier: Operating System :: POSIX :: Linux
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
22
|
Classifier: Topic :: Utilities
|
|
21
|
-
|
|
23
|
+
Provides-Extra: postgres
|
|
24
|
+
Requires-Dist: cmdkit[toml] (>=2.7.7,<3.0.0)
|
|
22
25
|
Requires-Dist: paramiko (>=3.4.0,<4.0.0)
|
|
23
|
-
Requires-Dist: psycopg2 (>=2.9.9,<3.0.0)
|
|
26
|
+
Requires-Dist: psycopg2 (>=2.9.9,<3.0.0) ; extra == "postgres"
|
|
27
|
+
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
|
24
28
|
Requires-Dist: rich (>=13.7.1,<14.0.0)
|
|
25
29
|
Requires-Dist: sqlalchemy (>=2.0.29,<3.0.0)
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
|
|
28
|
-
Project-URL:
|
|
29
|
-
Project-URL: Repository, https://github.com/glentner/hypershell
|
|
30
|
+
Requires-Dist: tomlkit (>=0.13.2,<0.14.0)
|
|
31
|
+
Project-URL: Documentation, https://hypershell.readthedocs.io
|
|
32
|
+
Project-URL: Repository, https://github.com/hypershell/hypershell
|
|
30
33
|
Description-Content-Type: text/x-rst
|
|
31
34
|
|
|
32
35
|
HyperShell v2: Distributed Task Execution for HPC
|
|
@@ -36,21 +39,21 @@ HyperShell v2: Distributed Task Execution for HPC
|
|
|
36
39
|
:target: https://www.apache.org/licenses/LICENSE-2.0
|
|
37
40
|
:alt: License
|
|
38
41
|
|
|
39
|
-
.. image:: https://img.shields.io/
|
|
40
|
-
:target: https://
|
|
41
|
-
:alt:
|
|
42
|
+
.. image:: https://img.shields.io/github/v/release/hypershell/hypershell?sort=semver
|
|
43
|
+
:target: https://github.com/hypershell/hypershell/releases
|
|
44
|
+
:alt: Github Release
|
|
42
45
|
|
|
43
|
-
.. image:: https://img.shields.io/
|
|
44
|
-
:target: https://
|
|
46
|
+
.. image:: https://img.shields.io/badge/Python-3.10+-blue.svg
|
|
47
|
+
:target: https://www.python.org/downloads
|
|
45
48
|
:alt: Python Versions
|
|
46
49
|
|
|
47
|
-
.. image:: https://
|
|
48
|
-
:target: https://
|
|
49
|
-
:alt:
|
|
50
|
+
.. image:: https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg
|
|
51
|
+
:target: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
|
|
52
|
+
:alt: Code of Conduct
|
|
50
53
|
|
|
51
|
-
.. image:: https://
|
|
52
|
-
:target: https://
|
|
53
|
-
:alt:
|
|
54
|
+
.. image:: https://readthedocs.org/projects/hypershell/badge/?version=latest
|
|
55
|
+
:target: https://hypershell.readthedocs.io/en/latest/?badge=latest
|
|
56
|
+
:alt: Documentation Status
|
|
54
57
|
|
|
55
58
|
|
|
|
56
59
|
|
|
@@ -77,7 +80,7 @@ the user ergonomics we provide. Novel design elements include but are not limite
|
|
|
77
80
|
Documentation
|
|
78
81
|
-------------
|
|
79
82
|
|
|
80
|
-
Documentation is available at `
|
|
83
|
+
Documentation is available at `hypershell.readthedocs.io <https://hypershell.readthedocs.io>`_.
|
|
81
84
|
For basic usage information on the command line use: ``hs --help``. For a more
|
|
82
85
|
comprehensive usage guide on the command line you can view the manual page with
|
|
83
86
|
``man hs``.
|
|
@@ -5,21 +5,21 @@ HyperShell v2: Distributed Task Execution for HPC
|
|
|
5
5
|
:target: https://www.apache.org/licenses/LICENSE-2.0
|
|
6
6
|
:alt: License
|
|
7
7
|
|
|
8
|
-
.. image:: https://img.shields.io/
|
|
9
|
-
:target: https://
|
|
10
|
-
:alt:
|
|
8
|
+
.. image:: https://img.shields.io/github/v/release/hypershell/hypershell?sort=semver
|
|
9
|
+
:target: https://github.com/hypershell/hypershell/releases
|
|
10
|
+
:alt: Github Release
|
|
11
11
|
|
|
12
|
-
.. image:: https://img.shields.io/
|
|
13
|
-
:target: https://
|
|
12
|
+
.. image:: https://img.shields.io/badge/Python-3.10+-blue.svg
|
|
13
|
+
:target: https://www.python.org/downloads
|
|
14
14
|
:alt: Python Versions
|
|
15
15
|
|
|
16
|
-
.. image:: https://
|
|
17
|
-
:target: https://
|
|
18
|
-
:alt:
|
|
16
|
+
.. image:: https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg
|
|
17
|
+
:target: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
|
|
18
|
+
:alt: Code of Conduct
|
|
19
19
|
|
|
20
|
-
.. image:: https://
|
|
21
|
-
:target: https://
|
|
22
|
-
:alt:
|
|
20
|
+
.. image:: https://readthedocs.org/projects/hypershell/badge/?version=latest
|
|
21
|
+
:target: https://hypershell.readthedocs.io/en/latest/?badge=latest
|
|
22
|
+
:alt: Documentation Status
|
|
23
23
|
|
|
24
24
|
|
|
|
25
25
|
|
|
@@ -46,7 +46,7 @@ the user ergonomics we provide. Novel design elements include but are not limite
|
|
|
46
46
|
Documentation
|
|
47
47
|
-------------
|
|
48
48
|
|
|
49
|
-
Documentation is available at `
|
|
49
|
+
Documentation is available at `hypershell.readthedocs.io <https://hypershell.readthedocs.io>`_.
|
|
50
50
|
For basic usage information on the command line use: ``hs --help``. For a more
|
|
51
51
|
comprehensive usage guide on the command line you can view the manual page with
|
|
52
52
|
``man hs``.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "hypershell"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.6.1"
|
|
4
4
|
description = "A cross-platform, high-throughput computing utility for processing shell commands over a distributed, asynchronous queue."
|
|
5
5
|
readme = "README.rst"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
|
-
homepage = "https://
|
|
8
|
-
documentation = "https://
|
|
9
|
-
repository = "https://github.com/
|
|
7
|
+
homepage = "https://hypershell.org"
|
|
8
|
+
documentation = "https://hypershell.readthedocs.io"
|
|
9
|
+
repository = "https://github.com/hypershell/hypershell"
|
|
10
10
|
authors = [
|
|
11
11
|
"Geoffrey Lentner <glentner@purdue.edu>"
|
|
12
12
|
]
|
|
@@ -20,9 +20,11 @@ keywords = [
|
|
|
20
20
|
classifiers = [
|
|
21
21
|
"Development Status :: 5 - Production/Stable",
|
|
22
22
|
"Topic :: Utilities",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
23
24
|
"Programming Language :: Python :: 3.10",
|
|
24
25
|
"Programming Language :: Python :: 3.11",
|
|
25
26
|
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
26
28
|
"Operating System :: POSIX :: Linux",
|
|
27
29
|
"Operating System :: MacOS",
|
|
28
30
|
"Operating System :: Microsoft :: Windows",
|
|
@@ -35,15 +37,18 @@ hyper-shell = "hypershell:main" # NOTE: do not remove this
|
|
|
35
37
|
hs = "hypershell:main"
|
|
36
38
|
|
|
37
39
|
[tool.poetry.dependencies]
|
|
38
|
-
python = ">=3.
|
|
39
|
-
cmdkit = "^2.7.
|
|
40
|
-
|
|
41
|
-
tomlkit = "^0.
|
|
40
|
+
python = ">=3.9,<4.0"
|
|
41
|
+
cmdkit = {version = "^2.7.7", extras = ["toml"]}
|
|
42
|
+
pyyaml = "^6.0.2"
|
|
43
|
+
tomlkit = "^0.13.2"
|
|
42
44
|
sqlalchemy = "^2.0.29"
|
|
43
45
|
rich = "^13.7.1"
|
|
44
46
|
paramiko = "^3.4.0"
|
|
45
47
|
psycopg2 = {version = "^2.9.9", optional = true}
|
|
46
48
|
|
|
49
|
+
[tool.poetry.extras]
|
|
50
|
+
postgres = ["psycopg2"]
|
|
51
|
+
|
|
47
52
|
[tool.poetry.group.docs.dependencies]
|
|
48
53
|
sphinx = "^7.2.6"
|
|
49
54
|
sphinx-sitemap = "^2.5.1"
|
|
@@ -59,9 +64,7 @@ furo = "^2024.1.29"
|
|
|
59
64
|
[tool.poetry.group.dev.dependencies]
|
|
60
65
|
psycopg2 = "^2.9.9"
|
|
61
66
|
pytest = "^8.1.1"
|
|
62
|
-
pytest-cov = "^5.0.0"
|
|
63
67
|
hypothesis = "^6.100.0"
|
|
64
|
-
ipython = "^8.23.0"
|
|
65
68
|
sphinx-autobuild = "^2024.2.4"
|
|
66
69
|
|
|
67
70
|
[build-system]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# standard libs
|
|
8
8
|
import sys
|
|
9
9
|
from importlib.metadata import version as get_version
|
|
10
|
+
from platform import python_version
|
|
10
11
|
|
|
11
12
|
# external libs
|
|
12
13
|
from cmdkit.app import Application, ApplicationGroup
|
|
@@ -91,11 +92,11 @@ class HyperShellApp(ApplicationGroup):
|
|
|
91
92
|
"""Top-level application class for console application."""
|
|
92
93
|
|
|
93
94
|
interface = Interface(APP_NAME, APP_USAGE, APP_HELP)
|
|
94
|
-
interface.add_argument('-v', '--version', action='version',
|
|
95
|
+
interface.add_argument('-v', '--version', action='version',
|
|
96
|
+
version=f'HyperShell v{__version__} (Python {python_version()})')
|
|
95
97
|
interface.add_argument('--citation', action='version', version=__citation__)
|
|
96
98
|
interface.add_argument('command')
|
|
97
99
|
|
|
98
|
-
command = None
|
|
99
100
|
commands = {
|
|
100
101
|
'submit': SubmitApp,
|
|
101
102
|
'server': ServerApp,
|
|
@@ -563,8 +563,7 @@ class TaskExecutor(StateMachine):
|
|
|
563
563
|
stdout=self.redirect_output, stderr=self.redirect_errors,
|
|
564
564
|
cwd=config.task.cwd, env=env)
|
|
565
565
|
log.info(f'Running task ({self.task.id})')
|
|
566
|
-
log.debug(f'Running task ({self.task.id}: {self.task.command}
|
|
567
|
-
log.trace(f'Running task ({self.task.id}: pid={self.process.pid}, argv={self.task.command})')
|
|
566
|
+
log.debug(f'Running task ({self.task.id})[{self.process.pid}]: {self.task.command}')
|
|
568
567
|
return TaskState.WAIT_TASK
|
|
569
568
|
|
|
570
569
|
def wait_task(self: TaskExecutor) -> TaskState:
|
|
@@ -197,7 +197,8 @@ class LocalCluster(Thread):
|
|
|
197
197
|
"""Start child threads, wait."""
|
|
198
198
|
set_client_standalone(False)
|
|
199
199
|
self.server.start()
|
|
200
|
-
|
|
200
|
+
while not self.server.queue.ready:
|
|
201
|
+
time.sleep(0.1)
|
|
201
202
|
self.client.start()
|
|
202
203
|
self.client.join()
|
|
203
204
|
self.server.join()
|
|
@@ -180,7 +180,6 @@ class RemoteCluster(Thread):
|
|
|
180
180
|
|
|
181
181
|
See Also:
|
|
182
182
|
- :class:`~hypershell.server.ServerThread`
|
|
183
|
-
- :class:`~hypershell.client.ClientThread`
|
|
184
183
|
- :meth:`~hypershell.cluster.remote.run_cluster`
|
|
185
184
|
"""
|
|
186
185
|
|
|
@@ -248,7 +247,8 @@ class RemoteCluster(Thread):
|
|
|
248
247
|
def run_with_exceptions(self: RemoteCluster) -> None:
|
|
249
248
|
"""Start child threads, wait."""
|
|
250
249
|
self.server.start()
|
|
251
|
-
|
|
250
|
+
while not self.server.queue.ready:
|
|
251
|
+
time.sleep(0.1)
|
|
252
252
|
log.debug(f'Launching clients: {self.client_argv}')
|
|
253
253
|
self.clients = Popen(self.client_argv,
|
|
254
254
|
stdout=sys.stdout, stderr=sys.stderr,
|
|
@@ -708,7 +708,8 @@ class AutoScalingCluster(Thread):
|
|
|
708
708
|
def run_with_exceptions(self: AutoScalingCluster) -> None:
|
|
709
709
|
"""Start child threads, wait."""
|
|
710
710
|
self.server.start()
|
|
711
|
-
|
|
711
|
+
while not self.server.queue.ready:
|
|
712
|
+
time.sleep(0.1)
|
|
712
713
|
self.autoscaler.start()
|
|
713
714
|
self.autoscaler.join()
|
|
714
715
|
self.server.join()
|
|
@@ -56,7 +56,7 @@ def run_ssh(**options) -> None:
|
|
|
56
56
|
... )
|
|
57
57
|
|
|
58
58
|
See Also:
|
|
59
|
-
- :class:`~hypershell.cluster.
|
|
59
|
+
- :class:`~hypershell.cluster.ssh.SSHCluster`
|
|
60
60
|
"""
|
|
61
61
|
thread = SSHCluster.new(**options)
|
|
62
62
|
try:
|
|
@@ -244,7 +244,8 @@ class SSHCluster(Thread):
|
|
|
244
244
|
def run_with_exceptions(self: SSHCluster) -> None:
|
|
245
245
|
"""Start child threads, wait."""
|
|
246
246
|
self.server.start()
|
|
247
|
-
|
|
247
|
+
while not self.server.queue.ready:
|
|
248
|
+
time.sleep(0.1)
|
|
248
249
|
self.clients = []
|
|
249
250
|
for argv in self.client_argv:
|
|
250
251
|
log.debug(f'Launching client: {argv}')
|
|
@@ -17,7 +17,7 @@ import contextlib
|
|
|
17
17
|
import subprocess
|
|
18
18
|
|
|
19
19
|
# external libs
|
|
20
|
-
import
|
|
20
|
+
import tomlkit
|
|
21
21
|
from pygments.styles import STYLE_MAP as CONSOLE_THEMES
|
|
22
22
|
from cmdkit.app import Application, ApplicationGroup
|
|
23
23
|
from cmdkit.cli import Interface, ArgumentError
|
|
@@ -239,19 +239,9 @@ class ConfigGetApp(Application):
|
|
|
239
239
|
def format_section(self: ConfigGetApp, value: dict) -> str:
|
|
240
240
|
"""Format an entire section for output."""
|
|
241
241
|
if self.varpath == '.':
|
|
242
|
-
|
|
242
|
+
return tomlkit.dumps(value)
|
|
243
243
|
else:
|
|
244
|
-
|
|
245
|
-
# NOTE: Fix weird formatting of section headings.
|
|
246
|
-
# The `toml.dumps` output has unnecessary quoting.
|
|
247
|
-
lines = []
|
|
248
|
-
for line in value.strip().split('\n'):
|
|
249
|
-
if not line.startswith('['):
|
|
250
|
-
lines.append(line)
|
|
251
|
-
else:
|
|
252
|
-
lines.append(line.replace('"', ''))
|
|
253
|
-
value = '\n'.join(lines)
|
|
254
|
-
return value
|
|
244
|
+
return tomlkit.dumps({self.varpath: value})
|
|
255
245
|
|
|
256
246
|
|
|
257
247
|
SET_PROGRAM = 'hs config set'
|
|
@@ -442,10 +432,8 @@ class ConfigApp(ApplicationGroup):
|
|
|
442
432
|
"""Manage configuration."""
|
|
443
433
|
|
|
444
434
|
interface = Interface(PROGRAM, USAGE, HELP)
|
|
445
|
-
|
|
446
435
|
interface.add_argument('command')
|
|
447
436
|
|
|
448
|
-
command = None
|
|
449
437
|
commands = {'get': ConfigGetApp,
|
|
450
438
|
'set': ConfigSetApp,
|
|
451
439
|
'edit': ConfigEditApp,
|
|
@@ -335,6 +335,8 @@ else:
|
|
|
335
335
|
|
|
336
336
|
|
|
337
337
|
T = TypeVar('T')
|
|
338
|
+
|
|
339
|
+
|
|
338
340
|
def __collapse_if_list_impl(value: Union[T, List[str]]) -> Union[T, str]:
|
|
339
341
|
"""If `value` is a list, collapse it to a path-like list (with ':' or ';')."""
|
|
340
342
|
return value if not isinstance(value, list) else PATH_DELIMITER.join([str(member) for member in value])
|
|
@@ -9,10 +9,12 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import Dict, Callable, Type
|
|
10
10
|
|
|
11
11
|
# standard libs
|
|
12
|
-
# import time # NOTE: commented section below
|
|
13
|
-
# import random
|
|
14
12
|
from enum import Enum
|
|
15
13
|
from abc import ABC
|
|
14
|
+
# FUZZ: import time
|
|
15
|
+
# FUZZ: import random
|
|
16
|
+
# PERF: from collections import defaultdict
|
|
17
|
+
# PERF: from time import perf_counter
|
|
16
18
|
|
|
17
19
|
# internal libs
|
|
18
20
|
from hypershell.core.exceptions import write_traceback
|
|
@@ -38,6 +40,10 @@ class StateMachine(ABC):
|
|
|
38
40
|
|
|
39
41
|
__should_halt: bool = False
|
|
40
42
|
|
|
43
|
+
# NOTE: Only needed during performance profiling
|
|
44
|
+
# PERF: __perf_counter: float = 0
|
|
45
|
+
# PERF: __perf_data: Dict[int, float]
|
|
46
|
+
|
|
41
47
|
def next(self) -> State:
|
|
42
48
|
"""Return next state (halt if necessary)."""
|
|
43
49
|
previous_state = self.state
|
|
@@ -46,21 +52,30 @@ class StateMachine(ABC):
|
|
|
46
52
|
return self.states.HALT # noqa: HALT defined in implemented State enums
|
|
47
53
|
else:
|
|
48
54
|
action = self.actions.get(previous_state)
|
|
55
|
+
# PERF: self.__perf_counter = perf_counter()
|
|
49
56
|
next_state = action()
|
|
57
|
+
# PERF: self.__perf_data[previous_state.value] += perf_counter() - self.__perf_counter
|
|
50
58
|
except Exception as error:
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
# NOTE: Only non-RuntimeError instances are "unexpected"
|
|
60
|
+
if not isinstance(error, RuntimeError):
|
|
61
|
+
log.critical(f'Uncaught exception from {self.__class__}')
|
|
62
|
+
write_traceback(error, logger=log, module=__name__)
|
|
53
63
|
raise
|
|
54
64
|
else:
|
|
55
65
|
# NOTE: Development aids not typically engaged
|
|
56
|
-
# time.sleep(random.uniform(0, 5)) # FUZZ
|
|
57
|
-
# log.devel(f'{self.__class__.__name__}: {previous_state} -> {next_state}')
|
|
66
|
+
# FUZZ: time.sleep(random.uniform(0, 5)) # FUZZ
|
|
67
|
+
# FUZZ: log.devel(f'{self.__class__.__name__}: {previous_state} -> {next_state}')
|
|
58
68
|
return next_state
|
|
59
69
|
|
|
60
70
|
def run(self) -> None:
|
|
61
71
|
"""Run machine until state is set to `HALT`."""
|
|
72
|
+
# PERF: self.__perf_data = defaultdict(lambda: 0)
|
|
62
73
|
while self.state is not self.states.HALT: # noqa: HALT defined in implemented State enums
|
|
63
74
|
self.state = self.next()
|
|
75
|
+
# PERF: time_total = sum(self.__perf_data.values())
|
|
76
|
+
# PERF: for key, value in self.__perf_data.items():
|
|
77
|
+
# PERF: t = 100 * value / time_total
|
|
78
|
+
# PERF: log.trace(f'Profiler[{self.__class__.__name__}] {self.states(key).name}: {t:.3f}')
|
|
64
79
|
|
|
65
80
|
def halt(self) -> None:
|
|
66
81
|
"""Set flag to signal for termination."""
|
|
@@ -55,6 +55,7 @@ class QueueInterface(BaseManager, ABC):
|
|
|
55
55
|
completed: JoinableQueue[Optional[List[bytes]]]
|
|
56
56
|
heartbeat: JoinableQueue[Optional[bytes]]
|
|
57
57
|
confirmed: JoinableQueue[Optional[bytes]]
|
|
58
|
+
ready: bool = False
|
|
58
59
|
|
|
59
60
|
def __init__(self: QueueInterface, config: QueueConfig) -> None:
|
|
60
61
|
"""Initialize queue interface."""
|
|
@@ -94,6 +95,7 @@ class QueueServer(QueueInterface):
|
|
|
94
95
|
self.register('_get_heartbeat', callable=self._get_heartbeat)
|
|
95
96
|
self.register('_get_confirmed', callable=self._get_confirmed)
|
|
96
97
|
super().start()
|
|
98
|
+
self.ready = True
|
|
97
99
|
|
|
98
100
|
def _get_scheduled(self: QueueServer) -> JoinableQueue[Optional[List[bytes]]]:
|
|
99
101
|
return self.scheduled
|
|
@@ -139,6 +141,7 @@ class QueueClient(QueueInterface):
|
|
|
139
141
|
self.completed = self._get_completed()
|
|
140
142
|
self.heartbeat = self._get_heartbeat()
|
|
141
143
|
self.confirmed = self._get_confirmed()
|
|
144
|
+
self.ready = True
|
|
142
145
|
|
|
143
146
|
def __enter__(self: QueueClient) -> QueueClient:
|
|
144
147
|
"""Connect to server."""
|
|
@@ -10,8 +10,9 @@ from typing import Optional, Final, Dict
|
|
|
10
10
|
from types import FrameType
|
|
11
11
|
|
|
12
12
|
# standard libs
|
|
13
|
+
import platform
|
|
13
14
|
from signal import signal as register
|
|
14
|
-
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
# internal libs
|
|
17
18
|
from hypershell.core.logging import Logger
|
|
@@ -22,6 +23,19 @@ __all__ = ['check_signal', 'RECEIVED', 'SIGNAL_MAP',
|
|
|
22
23
|
'SIGUSR1', 'SIGUSR2', 'SIGINT', 'SIGTERM', 'SIGKILL']
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
if platform.system() != 'Windows':
|
|
27
|
+
from signal import SIGUSR1, SIGUSR2, SIGINT, SIGTERM, SIGKILL
|
|
28
|
+
else:
|
|
29
|
+
# NOTE:
|
|
30
|
+
# Windows does not provide the signal facility
|
|
31
|
+
# While valid, these stubs have no effect because on Windows we never signal
|
|
32
|
+
SIGUSR1: Final[int] = 30
|
|
33
|
+
SIGUSR2: Final[int] = 31
|
|
34
|
+
SIGINT: Final[int] = 2
|
|
35
|
+
SIGTERM: Final[int] = 15
|
|
36
|
+
SIGKILL: Final[int] = 9
|
|
37
|
+
|
|
38
|
+
|
|
25
39
|
# initialize logger
|
|
26
40
|
log = Logger.with_name(__name__)
|
|
27
41
|
|
|
@@ -44,14 +58,22 @@ SIGNAL_MAP: Final[Dict[int, str]] = {
|
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
|
|
47
|
-
def handler(signum: int, frame: Optional[FrameType]) -> None:
|
|
61
|
+
def handler(signum: int, frame: Optional[FrameType]) -> None: # noqa: unused frame
|
|
48
62
|
"""Generic handler assigns `signum` to global variable."""
|
|
49
63
|
log.debug(f'Received signal {signum}: {SIGNAL_MAP.get(signum, "???")}')
|
|
50
64
|
global RECEIVED
|
|
51
65
|
RECEIVED = signum
|
|
52
66
|
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
if platform.system() == 'Windows':
|
|
69
|
+
|
|
70
|
+
def register_handlers() -> None:
|
|
71
|
+
"""Empty function does nothing on Windows."""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
|
|
76
|
+
def register_handlers() -> None:
|
|
77
|
+
"""Register signal handlers for client."""
|
|
78
|
+
register(SIGUSR1, handler)
|
|
79
|
+
register(SIGUSR2, handler)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Tag interface and parsing."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, List, Optional, Type
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
# internal libs
|
|
15
|
+
from hypershell.core.types import JSONValue, smart_coerce
|
|
16
|
+
|
|
17
|
+
# public interface
|
|
18
|
+
__all__ = ['Tag', ]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Tag:
|
|
23
|
+
"""Tag specification."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
value: JSONValue = ''
|
|
27
|
+
|
|
28
|
+
def to_dict(self: Tag) -> Dict[str, JSONValue]:
|
|
29
|
+
"""Format tag specification as dictionary."""
|
|
30
|
+
return {self.name: self.value, }
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_cmdline(cls: Type[Tag], arg: str) -> Tag:
|
|
34
|
+
"""Construct from command-line `arg`."""
|
|
35
|
+
tag_part = arg.strip().split(':', 1)
|
|
36
|
+
if len(tag_part) == 1:
|
|
37
|
+
return cls(name=tag_part[0].strip())
|
|
38
|
+
else:
|
|
39
|
+
name, value = tag_part[0].strip(), smart_coerce(tag_part[1].strip())
|
|
40
|
+
# Task.ensure_valid_tag({name: value})
|
|
41
|
+
return cls(name, value)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def parse_cmdline_list(cls: Type[Tag], args: List[str]) -> Dict[str, Optional[JSONValue]]:
|
|
45
|
+
"""Parse command-line list of tags."""
|
|
46
|
+
return {tag.name: tag.value for tag in map(cls.from_cmdline, args)}
|
|
47
|
+
|
|
@@ -24,7 +24,7 @@ from sqlalchemy.exc import ArgumentError
|
|
|
24
24
|
# internal libs
|
|
25
25
|
from hypershell.core.config import config
|
|
26
26
|
from hypershell.core.logging import handler
|
|
27
|
-
from hypershell.core.exceptions import write_traceback
|
|
27
|
+
from hypershell.core.exceptions import display_critical, write_traceback
|
|
28
28
|
|
|
29
29
|
# public interface
|
|
30
30
|
__all__ = ['DatabaseURL', 'engine', 'Session', 'config', 'in_memory', 'schema', ]
|
|
@@ -220,6 +220,13 @@ try:
|
|
|
220
220
|
engine = get_engine()
|
|
221
221
|
factory = sessionmaker(bind=engine)
|
|
222
222
|
Session = scoped_session(factory)
|
|
223
|
+
except ModuleNotFoundError as error:
|
|
224
|
+
if 'psycopg2' in error.args[0]:
|
|
225
|
+
display_critical(f'Missing optional dependency "psycopg2" needed for PostgreSQL', module=__name__)
|
|
226
|
+
sys.exit(exit_status.runtime_error)
|
|
227
|
+
else:
|
|
228
|
+
write_traceback(error, module=__name__)
|
|
229
|
+
sys.exit(exit_status.bad_config)
|
|
223
230
|
except Exception as error:
|
|
224
231
|
write_traceback(error, module=__name__)
|
|
225
232
|
sys.exit(exit_status.bad_config)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
# type annotations
|
|
8
8
|
from __future__ import annotations
|
|
9
|
-
from typing import List, Dict, Any, Type, TypeVar, Union, Optional
|
|
9
|
+
from typing import List, Dict, Tuple, Any, Type, TypeVar, Union, Optional
|
|
10
10
|
|
|
11
11
|
# standard libs
|
|
12
12
|
import re
|
|
@@ -20,12 +20,13 @@ from sqlalchemy.orm import Query, DeclarativeBase, Mapped, mapped_column
|
|
|
20
20
|
from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
|
|
21
21
|
from sqlalchemy.ext.declarative import declared_attr
|
|
22
22
|
from sqlalchemy.types import Integer, DateTime, Text, Boolean, JSON as _JSON
|
|
23
|
-
from sqlalchemy.dialects.postgresql import UUID as POSTGRES_UUID, JSONB as POSTGRES_JSON
|
|
23
|
+
from sqlalchemy.dialects.postgresql import SMALLINT, UUID as POSTGRES_UUID, JSONB as POSTGRES_JSON
|
|
24
24
|
|
|
25
25
|
# internal libs
|
|
26
26
|
from hypershell.core.logging import Logger, HOSTNAME, INSTANCE
|
|
27
27
|
from hypershell.core.heartbeat import Heartbeat
|
|
28
28
|
from hypershell.core.types import JSONValue
|
|
29
|
+
from hypershell.core.tag import Tag
|
|
29
30
|
from hypershell.data.core import schema, Session
|
|
30
31
|
|
|
31
32
|
# public interface
|
|
@@ -77,6 +78,7 @@ def from_json_type(value: JSONValue) -> Union[JSONValue, VT]:
|
|
|
77
78
|
UUID = Text().with_variant(POSTGRES_UUID(as_uuid=False), 'postgresql')
|
|
78
79
|
TEXT = Text()
|
|
79
80
|
INTEGER = Integer()
|
|
81
|
+
SMALL_INTEGER = Integer().with_variant(SMALLINT, 'postgresql')
|
|
80
82
|
DATETIME = DateTime(timezone=True)
|
|
81
83
|
BOOLEAN = Boolean()
|
|
82
84
|
JSON = _JSON().with_variant(POSTGRES_JSON(), 'postgresql')
|
|
@@ -180,6 +182,26 @@ class Entity(DeclarativeBase):
|
|
|
180
182
|
"""Update by `id` with `changes`."""
|
|
181
183
|
cls.update_all([{'id': id, **changes}, ])
|
|
182
184
|
|
|
185
|
+
@classmethod
|
|
186
|
+
def delete_all(cls: Type[Entity], items: List[Entity]) -> List[Entity]:
|
|
187
|
+
"""Delete records from database."""
|
|
188
|
+
try:
|
|
189
|
+
for item in items:
|
|
190
|
+
Session.delete(item)
|
|
191
|
+
Session.commit()
|
|
192
|
+
except Exception:
|
|
193
|
+
Session.rollback()
|
|
194
|
+
raise
|
|
195
|
+
else:
|
|
196
|
+
for item in items:
|
|
197
|
+
log.trace(f'Deleted {cls.__tablename__} ({item.id})') # noqa: id not defined on base
|
|
198
|
+
return items
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def delete(cls: Type[Entity], item: Entity) -> None:
|
|
202
|
+
"""Delete single item from database."""
|
|
203
|
+
cls.delete_all([item, ])
|
|
204
|
+
|
|
183
205
|
@classmethod
|
|
184
206
|
def from_id(cls: Type[Entity], id: str) -> Entity:
|
|
185
207
|
"""Load by unique `id`."""
|
|
@@ -211,12 +233,12 @@ class Task(Entity):
|
|
|
211
233
|
command: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
212
234
|
start_time: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
213
235
|
completion_time: Mapped[Optional[datetime]] = mapped_column(DATETIME, nullable=True)
|
|
214
|
-
exit_status: Mapped[Optional[int]] = mapped_column(
|
|
236
|
+
exit_status: Mapped[Optional[int]] = mapped_column(SMALL_INTEGER, nullable=True)
|
|
215
237
|
|
|
216
238
|
outpath: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
217
239
|
errpath: Mapped[Optional[str]] = mapped_column(TEXT, nullable=True)
|
|
218
240
|
|
|
219
|
-
attempt: Mapped[int] = mapped_column(
|
|
241
|
+
attempt: Mapped[int] = mapped_column(SMALL_INTEGER, nullable=False)
|
|
220
242
|
retried: Mapped[bool] = mapped_column(BOOLEAN, nullable=False)
|
|
221
243
|
|
|
222
244
|
waited: Mapped[Optional[int]] = mapped_column(INTEGER, nullable=True)
|
|
@@ -277,30 +299,53 @@ class Task(Entity):
|
|
|
277
299
|
tag: Dict[str, JSONValue] = None, **other) -> Task:
|
|
278
300
|
"""Create a new Task."""
|
|
279
301
|
cls.ensure_valid_tag(tag)
|
|
302
|
+
args, inline_tags = cls.split_argline(args)
|
|
303
|
+
tag = {**(tag or {}), **inline_tags}
|
|
280
304
|
return Task(id=str(gen_uuid()), args=str(args).strip(),
|
|
281
305
|
submit_id=INSTANCE, submit_host=HOSTNAME, submit_time=datetime.now().astimezone(),
|
|
282
|
-
attempt=attempt, retried=retried, tag=
|
|
306
|
+
attempt=attempt, retried=retried, tag=tag, **other)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def split_argline(cls: Type[Task], args: str) -> Tuple[str, Dict[str, JSONValue]]:
|
|
310
|
+
"""Separate input args from possible inline tag comment."""
|
|
311
|
+
if match := re.search(r'#\s*HYPERSHELL:?', args):
|
|
312
|
+
try:
|
|
313
|
+
tags = Tag.parse_cmdline_list(args[match.end():].strip().split())
|
|
314
|
+
cls.ensure_valid_tag(tags)
|
|
315
|
+
except (ValueError, TypeError) as error:
|
|
316
|
+
raise RuntimeError(f'Failed to parse inline tags ({error}, from: "{args}")') from error
|
|
317
|
+
args = args[:match.start()]
|
|
318
|
+
return args, tags
|
|
319
|
+
else:
|
|
320
|
+
return args, {}
|
|
283
321
|
|
|
284
322
|
@staticmethod
|
|
285
|
-
def ensure_valid_tag(tag: Dict[str, JSONValue]) -> None:
|
|
323
|
+
def ensure_valid_tag(tag: Optional[Dict[str, JSONValue]]) -> None:
|
|
286
324
|
"""Check tag dictionary and raise if invalid."""
|
|
287
|
-
if not isinstance(tag, (dict, type(None))):
|
|
288
|
-
raise TypeError('Expected dict or None for tag data')
|
|
289
325
|
if tag is None:
|
|
290
326
|
return
|
|
327
|
+
if not isinstance(tag, dict):
|
|
328
|
+
raise TypeError('Expected dict for tag data')
|
|
291
329
|
for key, value in tag.items():
|
|
292
330
|
if not isinstance(key, str):
|
|
293
331
|
raise TypeError(f'Tag key, {key} ({type(key)}) is not string')
|
|
332
|
+
if len(key.strip()) == 0:
|
|
333
|
+
raise ValueError(f'Tag key was empty, "{key}:{value}"')
|
|
334
|
+
if len(key.strip()) > 120:
|
|
335
|
+
raise ValueError(f'Tag key size ({len(value)}) exceeds 120 characters ({key}:{value})')
|
|
336
|
+
if not re.match(r'^[A-Za-z0-9_.+-]+$', key):
|
|
337
|
+
raise ValueError(f'Tag key must only contain alphanumerics and basic symbols [+._-]: '
|
|
338
|
+
f'"{key}:{value}"')
|
|
294
339
|
if not isinstance(value, (str, int, float, bool, type(None))):
|
|
295
340
|
raise TypeError(f'Invalid type for tag value, {type(value)})')
|
|
296
341
|
if isinstance(value, str):
|
|
297
342
|
if not value.strip():
|
|
298
343
|
return # Empty value is a naked tag (no value).
|
|
299
344
|
if len(value) > 120:
|
|
300
|
-
raise ValueError(f'Tag size ({len(value)}) exceeds 120 characters ({key}:
|
|
345
|
+
raise ValueError(f'Tag value size ({len(value)}) exceeds 120 characters ({key}:{value})')
|
|
301
346
|
if not re.match(r'^[A-Za-z0-9_.+-]+$', value):
|
|
302
|
-
raise ValueError(f'Tag must only contain alphanumerics and basic symbols [+._-]: '
|
|
303
|
-
f'
|
|
347
|
+
raise ValueError(f'Tag value must only contain alphanumerics and basic symbols [+._-]: '
|
|
348
|
+
f'"{key}:{value}"')
|
|
304
349
|
|
|
305
350
|
@classmethod
|
|
306
351
|
def select_new(cls: Type[Task], limit: int) -> List[Task]:
|
|
@@ -556,7 +601,6 @@ class Task(Entity):
|
|
|
556
601
|
# Indices for efficient queries
|
|
557
602
|
index_scheduled = Index('task_scheduled_index', Task.schedule_time)
|
|
558
603
|
index_retried = Index('task_retries_index', Task.exit_status, Task.retried)
|
|
559
|
-
index_client_completed = Index('task_client_completed_index', Task.client_id, Task.completion_time)
|
|
560
604
|
|
|
561
605
|
|
|
562
606
|
class Client(Entity):
|
|
@@ -92,9 +92,6 @@ class SchedulerState(State, Enum):
|
|
|
92
92
|
HALT = 5
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
# Note:
|
|
96
|
-
# Unless specified otherwise for larger problems, a bundle of size one allows
|
|
97
|
-
# for greater concurrency on smaller workloads.
|
|
98
95
|
DEFAULT_BUNDLESIZE: Final[int] = default.server.bundlesize
|
|
99
96
|
"""Default size for task bundles."""
|
|
100
97
|
|
|
@@ -53,7 +53,7 @@ from queue import Queue, Empty as QueueEmpty, Full as QueueFull
|
|
|
53
53
|
# external libs
|
|
54
54
|
from cmdkit.config import ConfigurationError
|
|
55
55
|
from cmdkit.app import Application
|
|
56
|
-
from cmdkit.cli import Interface
|
|
56
|
+
from cmdkit.cli import Interface, ArgumentError
|
|
57
57
|
|
|
58
58
|
# internal libs
|
|
59
59
|
from hypershell.core.logging import Logger
|
|
@@ -760,6 +760,7 @@ class SubmitApp(Application):
|
|
|
760
760
|
auto_initdb: bool = False
|
|
761
761
|
interface.add_argument('--initdb', action='store_true', dest='auto_initdb')
|
|
762
762
|
|
|
763
|
+
tags: Dict[str, JSONValue] = {}
|
|
763
764
|
taglist: List[str] = None
|
|
764
765
|
interface.add_argument('--tag', nargs='*', default=[], dest='taglist')
|
|
765
766
|
|
|
@@ -777,8 +778,7 @@ class SubmitApp(Application):
|
|
|
777
778
|
def submit_all(self: SubmitApp) -> None:
|
|
778
779
|
"""Submit all tasks from source."""
|
|
779
780
|
self.count = submit_from(self.source, template=self.template,
|
|
780
|
-
bundlesize=self.bundlesize, bundlewait=self.bundlewait,
|
|
781
|
-
tags=Tag.parse_cmdline_list(self.taglist))
|
|
781
|
+
bundlesize=self.bundlesize, bundlewait=self.bundlewait, tags=self.tags)
|
|
782
782
|
|
|
783
783
|
@staticmethod
|
|
784
784
|
def check_config():
|
|
@@ -787,10 +787,19 @@ class SubmitApp(Application):
|
|
|
787
787
|
if config.database.provider == 'sqlite' and db in ('', ':memory:', None):
|
|
788
788
|
raise ConfigurationError('Submitting tasks to in-memory database has no effect')
|
|
789
789
|
|
|
790
|
+
def check_tags(self: SubmitApp) -> None:
|
|
791
|
+
"""Ensure valid tags."""
|
|
792
|
+
self.tags = {} if not self.taglist else Tag.parse_cmdline_list(self.taglist)
|
|
793
|
+
try:
|
|
794
|
+
Task.ensure_valid_tag(self.tags)
|
|
795
|
+
except (ValueError, TypeError) as error:
|
|
796
|
+
raise ArgumentError(str(error)) from error
|
|
797
|
+
|
|
790
798
|
def __enter__(self: SubmitApp) -> SubmitApp:
|
|
791
799
|
"""Open file if not stdin."""
|
|
792
800
|
self.source = sys.stdin if self.filepath == '-' else open(self.filepath, mode='r')
|
|
793
801
|
self.check_config()
|
|
802
|
+
self.check_tags()
|
|
794
803
|
if config.database.provider == 'sqlite' or self.auto_initdb:
|
|
795
804
|
initdb() # Auto-initialize if local sqlite provider
|
|
796
805
|
else:
|
|
@@ -41,12 +41,13 @@ from hypershell.core.exceptions import handle_exception, handle_exception_silent
|
|
|
41
41
|
from hypershell.core.logging import Logger, HOSTNAME
|
|
42
42
|
from hypershell.core.remote import SSHConnection
|
|
43
43
|
from hypershell.core.types import smart_coerce, JSONValue
|
|
44
|
+
from hypershell.core.tag import Tag
|
|
44
45
|
from hypershell.data.core import Session
|
|
45
46
|
from hypershell.data.model import Task, to_json_type
|
|
46
47
|
from hypershell.data import ensuredb
|
|
47
48
|
|
|
48
49
|
# public interface
|
|
49
|
-
__all__ = ['TaskGroupApp',
|
|
50
|
+
__all__ = ['TaskGroupApp', ]
|
|
50
51
|
|
|
51
52
|
# initialize logger
|
|
52
53
|
log = Logger.with_name(__name__)
|
|
@@ -80,6 +81,7 @@ class TaskSubmitApp(Application):
|
|
|
80
81
|
argv: List[str] = []
|
|
81
82
|
interface.add_argument('argv', nargs='+')
|
|
82
83
|
|
|
84
|
+
tags: Dict[str, JSONValue] = {}
|
|
83
85
|
taglist: List[str] = []
|
|
84
86
|
interface.add_argument('-t', '--tag', nargs='*', dest='taglist')
|
|
85
87
|
|
|
@@ -90,11 +92,19 @@ class TaskSubmitApp(Application):
|
|
|
90
92
|
def run(self: TaskSubmitApp) -> None:
|
|
91
93
|
"""Submit task to database."""
|
|
92
94
|
ensuredb()
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
self.check_tags()
|
|
96
|
+
task = Task.new(args=' '.join(self.argv), tag=self.tags)
|
|
95
97
|
Task.add(task)
|
|
96
98
|
print(task.id)
|
|
97
99
|
|
|
100
|
+
def check_tags(self: TaskSubmitApp) -> None:
|
|
101
|
+
"""Ensure valid tags."""
|
|
102
|
+
self.tags = {} if not self.taglist else Tag.parse_cmdline_list(self.taglist)
|
|
103
|
+
try:
|
|
104
|
+
Task.ensure_valid_tag(self.tags)
|
|
105
|
+
except (ValueError, TypeError) as error:
|
|
106
|
+
raise ArgumentError(str(error)) from error
|
|
107
|
+
|
|
98
108
|
|
|
99
109
|
# Catch bad UUID before we touch the database
|
|
100
110
|
UUID_PATTERN: re.Pattern = re.compile(
|
|
@@ -860,7 +870,8 @@ class TaskUpdateApp(Application, SearchableMixin):
|
|
|
860
870
|
print(f'Stopping (invalid response: "{response}")')
|
|
861
871
|
return
|
|
862
872
|
|
|
863
|
-
if
|
|
873
|
+
# Delete is handled later if a --limit is used with search
|
|
874
|
+
if self.delete_mode and self.limit is None:
|
|
864
875
|
query.delete()
|
|
865
876
|
Session.commit()
|
|
866
877
|
log.info(f'Deleted {count} tasks')
|
|
@@ -895,25 +906,27 @@ class TaskUpdateApp(Application, SearchableMixin):
|
|
|
895
906
|
# We cannot apply an UPDATE query with a LIMIT field
|
|
896
907
|
# The alternative is to pull the data and batch the update
|
|
897
908
|
# While terribly inefficient at least it has a LIMIT
|
|
909
|
+
tasks = query.all()
|
|
910
|
+
tasks_it = iter(tasks)
|
|
911
|
+
if self.delete_mode:
|
|
912
|
+
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
913
|
+
Task.delete_all(list(batch))
|
|
898
914
|
if field_updates:
|
|
899
|
-
tasks = query.all()
|
|
900
|
-
tasks_it = iter(tasks)
|
|
901
915
|
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
902
916
|
Task.update_all([{'id': task.id, **field_updates} for task in batch])
|
|
903
917
|
if tag_updates:
|
|
904
|
-
tasks = query.all()
|
|
905
|
-
tasks_it = iter(tasks)
|
|
906
918
|
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
907
919
|
Task.update_all([{'id': task.id, 'tag': {**task.tag, **tag_updates}} for task in batch])
|
|
908
920
|
if self.remove_tag:
|
|
909
|
-
tasks = query.all()
|
|
910
|
-
tasks_it = iter(tasks)
|
|
911
921
|
while batch := tuple(itertools.islice(tasks_it, 100)):
|
|
912
922
|
Task.update_all([
|
|
913
923
|
{'id': task.id, 'tag': self.drop_items(task.tag, *self.remove_tag)}
|
|
914
924
|
for task in batch
|
|
915
925
|
])
|
|
916
|
-
|
|
926
|
+
if self.delete_mode:
|
|
927
|
+
log.info(f'Deleted {count} tasks')
|
|
928
|
+
else:
|
|
929
|
+
log.info(f'Updated {count} tasks')
|
|
917
930
|
return
|
|
918
931
|
|
|
919
932
|
if field_updates:
|
|
@@ -1034,11 +1047,9 @@ class TaskGroupApp(ApplicationGroup):
|
|
|
1034
1047
|
"""Search, submit, track, and manage individual tasks."""
|
|
1035
1048
|
|
|
1036
1049
|
interface = Interface(TASK_PROGRAM, TASK_USAGE, TASK_HELP)
|
|
1037
|
-
|
|
1038
|
-
interface.add_argument('command')
|
|
1039
1050
|
interface.add_argument('--list-columns', action='version', version=' '.join(Task.columns))
|
|
1051
|
+
interface.add_argument('command')
|
|
1040
1052
|
|
|
1041
|
-
command = None
|
|
1042
1053
|
commands = {
|
|
1043
1054
|
'submit': TaskSubmitApp,
|
|
1044
1055
|
'info': TaskInfoApp,
|
|
@@ -1096,34 +1107,6 @@ class WhereClause:
|
|
|
1096
1107
|
raise ArgumentError(f'Where clause not understood ({argument})')
|
|
1097
1108
|
|
|
1098
1109
|
|
|
1099
|
-
@dataclass
|
|
1100
|
-
class Tag:
|
|
1101
|
-
"""Tag specification."""
|
|
1102
|
-
|
|
1103
|
-
name: str
|
|
1104
|
-
value: JSONValue = ''
|
|
1105
|
-
|
|
1106
|
-
def to_dict(self: Tag) -> Dict[str, JSONValue]:
|
|
1107
|
-
"""Format tag specification as dictionary."""
|
|
1108
|
-
return {self.name: self.value, }
|
|
1109
|
-
|
|
1110
|
-
@classmethod
|
|
1111
|
-
def from_cmdline(cls: Type[Tag], arg: str) -> Tag:
|
|
1112
|
-
"""Construct from command-line `arg`."""
|
|
1113
|
-
tag_part = arg.strip().split(':', 1)
|
|
1114
|
-
if len(tag_part) == 1:
|
|
1115
|
-
return cls(name=tag_part[0].strip())
|
|
1116
|
-
else:
|
|
1117
|
-
name, value = tag_part[0].strip(), smart_coerce(tag_part[1].strip())
|
|
1118
|
-
Task.ensure_valid_tag({name: value})
|
|
1119
|
-
return cls(name, value)
|
|
1120
|
-
|
|
1121
|
-
@classmethod
|
|
1122
|
-
def parse_cmdline_list(cls: Type[Tag], args: List[str]) -> Dict[str, Optional[JSONValue]]:
|
|
1123
|
-
"""Parse command-line list of tags."""
|
|
1124
|
-
return {tag.name: tag.value for tag in map(cls.from_cmdline, args)}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
1110
|
def print_normal(task: Task) -> None:
|
|
1128
1111
|
"""Print semi-structured task metadata with all field names."""
|
|
1129
1112
|
task_data = {k: json.dumps(to_json_type(v)).strip('"') for k, v in task.to_dict().items()}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|