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.
Files changed (31) hide show
  1. {hypershell-2.5.1 → hypershell-2.6.1}/PKG-INFO +24 -21
  2. {hypershell-2.5.1 → hypershell-2.6.1}/README.rst +12 -12
  3. {hypershell-2.5.1 → hypershell-2.6.1}/pyproject.toml +13 -10
  4. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/__init__.py +3 -2
  5. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/client.py +1 -2
  6. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/local.py +2 -1
  7. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/remote.py +4 -3
  8. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/ssh.py +3 -2
  9. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/config.py +3 -15
  10. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/config.py +2 -0
  11. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/fsm.py +21 -6
  12. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/queue.py +3 -0
  13. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/signal.py +28 -6
  14. hypershell-2.6.1/src/hypershell/core/tag.py +47 -0
  15. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/data/core.py +8 -1
  16. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/data/model.py +56 -12
  17. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/server.py +0 -3
  18. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/submit.py +12 -3
  19. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/task.py +25 -42
  20. {hypershell-2.5.1 → hypershell-2.6.1}/LICENSE +0 -0
  21. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/cluster/__init__.py +0 -0
  22. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/__init__.py +0 -0
  23. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/exceptions.py +0 -0
  24. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/heartbeat.py +0 -0
  25. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/logging.py +0 -0
  26. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/platform.py +0 -0
  27. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/remote.py +0 -0
  28. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/template.py +0 -0
  29. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/thread.py +0 -0
  30. {hypershell-2.5.1 → hypershell-2.6.1}/src/hypershell/core/types.py +0 -0
  31. {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.5.1
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://hyper-shell.readthedocs.io
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,<4.0
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
- Requires-Dist: cmdkit (>=2.7.5,<3.0.0)
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: toml (>=0.10.2,<0.11.0)
27
- Requires-Dist: tomlkit (>=0.12.4,<0.13.0)
28
- Project-URL: Documentation, https://hyper-shell.readthedocs.io
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/pypi/v/hypershell.svg?style=flat&color=blue
40
- :target: https://pypi.org/project/hypershell
41
- :alt: PyPI Version
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/pypi/pyversions/hypershell.svg?logo=python&logoColor=white&style=flat
44
- :target: https://pypi.org/project/hypershell
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://readthedocs.org/projects/hyper-shell/badge/?version=latest&style=flat
48
- :target: https://hyper-shell.readthedocs.io
49
- :alt: Documentation
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://static.pepy.tech/badge/hyper-shell
52
- :target: https://pepy.tech/project/hyper-shell
53
- :alt: Downloads
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 `hyper-shell.readthedocs.io <https://hyper-shell.readthedocs.io>`_.
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/pypi/v/hypershell.svg?style=flat&color=blue
9
- :target: https://pypi.org/project/hypershell
10
- :alt: PyPI Version
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/pypi/pyversions/hypershell.svg?logo=python&logoColor=white&style=flat
13
- :target: https://pypi.org/project/hypershell
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://readthedocs.org/projects/hyper-shell/badge/?version=latest&style=flat
17
- :target: https://hyper-shell.readthedocs.io
18
- :alt: Documentation
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://static.pepy.tech/badge/hyper-shell
21
- :target: https://pepy.tech/project/hyper-shell
22
- :alt: Downloads
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 `hyper-shell.readthedocs.io <https://hyper-shell.readthedocs.io>`_.
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.5.1"
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://hyper-shell.readthedocs.io"
8
- documentation = "https://hyper-shell.readthedocs.io"
9
- repository = "https://github.com/glentner/hypershell"
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.10,<4.0"
39
- cmdkit = "^2.7.5"
40
- toml = "^0.10.2"
41
- tomlkit = "^0.12.4"
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', version=__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
- time.sleep(2) # NOTE: give the server a chance to start
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
- time.sleep(2) # NOTE: give the server a chance to start
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
- time.sleep(2) # NOTE: give the server a chance to start
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.remote.SSHCluster`
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
- time.sleep(2) # NOTE: give the server a chance to start
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 toml
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
- value = toml.dumps(value)
242
+ return tomlkit.dumps(value)
243
243
  else:
244
- value = toml.dumps({self.varpath: value})
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
- log.critical(f'Uncaught exception from {self.__class__}')
52
- write_traceback(error, logger=log, module=__name__)
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
- from signal import SIGUSR1, SIGUSR2, SIGINT, SIGTERM, SIGKILL
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
- def register_handlers() -> None:
55
- """Register signal handlers for client."""
56
- register(SIGUSR1, handler)
57
- register(SIGUSR2, handler)
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(INTEGER, nullable=True)
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(INTEGER, nullable=False)
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=(tag or {}), **other)
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}: {value})')
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'({key}: {value})')
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', 'Tag', ]
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
- task = Task.new(args=' '.join(self.argv),
94
- tag=(None if not self.taglist else Tag.parse_cmdline_list(self.taglist)))
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 self.delete_mode:
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
- log.info(f'Updated {count} tasks')
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