ztl 0.0.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.
ztl-0.0.1/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2023, UH Robot House / User Projects
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
ztl-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.1
2
+ Name: ztl
3
+ Version: 0.0.1
4
+ Summary: A thin library relying on zmq to dispatch tasks
5
+ Home-page: https://gitlab.com/robothouse/rh-user/ztl/
6
+ Author: Patrick Holthaus
7
+ Author-email: Patrick Holthaus <patrick.holthaus@googlemail.com>
8
+ License: BSD 2-Clause License
9
+
10
+ Copyright (c) 2023, UH Robot House / User Projects
11
+ All rights reserved.
12
+
13
+ Redistribution and use in source and binary forms, with or without
14
+ modification, are permitted provided that the following conditions are met:
15
+
16
+ 1. Redistributions of source code must retain the above copyright notice, this
17
+ list of conditions and the following disclaimer.
18
+
19
+ 2. Redistributions in binary form must reproduce the above copyright notice,
20
+ this list of conditions and the following disclaimer in the documentation
21
+ and/or other materials provided with the distribution.
22
+
23
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
24
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
25
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ Project-URL: Homepage, https://gitlab.com/robothouse/rh-user/ztl/
35
+ Project-URL: Bug Tracker, https://gitlab.com/robothouse/rh-user/ztl/issues/
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: License :: OSI Approved :: BSD License
38
+ Classifier: Operating System :: OS Independent
39
+ Requires-Python: >=3.7
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Requires-Dist: oyaml
43
+ Requires-Dist: pyzmq
44
+ Requires-Dist: pytest
45
+ Requires-Dist: pytest-xprocess
46
+
47
+ This project contains the `ZTL` library enabling light-weight and widely compatible **remote task execution** using [ZeroMQ](https://zeromq.org/).
48
+
49
+ The basic communication principle is as follows:
50
+
51
+ ![communication overview](res/overview.png)
52
+
53
+ Thereby, each task has the following lifecycle:
54
+
55
+ ![task lifecycle](res/task%20lifecycle.png)
56
+
57
+ An example communication could look like this:
58
+
59
+ Request:
60
+
61
+ ![communication overview](res/protocol.png)
62
+
63
+ Reply:
64
+
65
+ ![communication overview](res/protocol%20reply.png)
ztl-0.0.1/README.md ADDED
@@ -0,0 +1,19 @@
1
+ This project contains the `ZTL` library enabling light-weight and widely compatible **remote task execution** using [ZeroMQ](https://zeromq.org/).
2
+
3
+ The basic communication principle is as follows:
4
+
5
+ ![communication overview](res/overview.png)
6
+
7
+ Thereby, each task has the following lifecycle:
8
+
9
+ ![task lifecycle](res/task%20lifecycle.png)
10
+
11
+ An example communication could look like this:
12
+
13
+ Request:
14
+
15
+ ![communication overview](res/protocol.png)
16
+
17
+ Reply:
18
+
19
+ ![communication overview](res/protocol%20reply.png)
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "ztl"
3
+ version = "0.0.1"
4
+ authors = [
5
+ { name="Patrick Holthaus", email="patrick.holthaus@googlemail.com" },
6
+ ]
7
+ description = "A thin library relying on zmq to dispatch tasks"
8
+ readme = "README.md"
9
+ license = {file = "LICENSE"}
10
+ requires-python = ">=3.7"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: BSD License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "oyaml",
18
+ "pyzmq",
19
+ "pytest",
20
+ "pytest-xprocess"
21
+ ]
22
+
23
+ [project.scripts]
24
+ ztl_simple_client = "ztl.example.simple_client:main_cli"
25
+ ztl_simple_server = "ztl.example.simple_server:main_cli"
26
+ ztl_run_script = "ztl.script.run_script:main_cli"
27
+
28
+ [project.urls]
29
+ "Homepage" = "https://gitlab.com/robothouse/rh-user/ztl/"
30
+ "Bug Tracker" = "https://gitlab.com/robothouse/rh-user/ztl/issues/"
ztl-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
ztl-0.0.1/setup.py ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python
2
+
3
+ from distutils.core import setup
4
+
5
+ setup(name='ztl',
6
+ version='0.1',
7
+ description='A thin library relying on zmq to dispatch tasks',
8
+ author='Patrick Holthaus',
9
+ author_email='patrick.holthaus@googlemail.com',
10
+ url='https://gitlab.com/robothouse/rh-user/ztl/',
11
+ package_dir={'':'src'},
12
+ packages=['ztl', 'ztl.core', 'ztl.example', 'ztl.script'],
13
+ scripts=['src/ztl/example/simple_client.py',
14
+ 'src/ztl/example/simple_server.py',
15
+ 'src/ztl/script/run_script.py'
16
+ ]
17
+ )
File without changes
File without changes
@@ -0,0 +1,109 @@
1
+ import zmq
2
+ import time
3
+ import sys
4
+ import logging
5
+ logging.basicConfig(level=logging.INFO)
6
+
7
+ from ztl.core.protocol import Message, Request, State
8
+
9
+ class RemoteTask(object):
10
+
11
+ def __init__(self, host, port, scope, timeout=2000):
12
+
13
+ self.logger = logging.getLogger('remote-task')
14
+ self.context = zmq.Context()
15
+ self.socket = self.context.socket(zmq.REQ)
16
+ self.socket.setsockopt(zmq.RCVTIMEO, timeout)
17
+ address = "tcp://" + str(host) + ":" + str(port)
18
+ self.socket.connect(address)
19
+ self.scope = scope
20
+
21
+ self.logger.info("Remote task interface initialised at '%s'.", address)
22
+
23
+ def trigger(self, payload):
24
+ """
25
+ Trigger a task at the remote interface.
26
+
27
+ Parameters
28
+ ----------
29
+ payload: The payload containing the task description.
30
+
31
+ Returns
32
+ -------
33
+ id: An ID for the task assigned by the server if accepted, -1 if rejected or server not reachable or communication error.
34
+ """
35
+ msg = Message.encode(self.scope, Request.INIT, -1, payload)
36
+ try:
37
+ self.socket.send(msg)
38
+ reply = Message.decode(self.socket.recv())
39
+ return int(reply["id"])
40
+ except Exception as e:
41
+ self.logger.error(e)
42
+ return -1
43
+
44
+ def abort(self, mid, payload="abort command"):
45
+ """
46
+ Aborts a task at the remote interface.
47
+
48
+ Parameters
49
+ ----------
50
+ mid: The ID of the task to abort.
51
+ payload: An optional payload containing an updated task description. Default: "abort command"
52
+
53
+ Returns
54
+ -------
55
+ status: The updated status after attempting to abort. Task might not be aborted. -1 if server not reachable or communication error.
56
+ """
57
+ try:
58
+ msg = Message.encode(self.scope, Request.ABORT, mid, payload)
59
+ self.socket.send(msg)
60
+ reply = Message.decode(self.socket.recv())
61
+ return int(reply["state"])
62
+ except Exception as e:
63
+ self.logger.error(e)
64
+ return -1
65
+
66
+ def status(self, mid, payload="status update"):
67
+ """
68
+ Query the status of a given task at the remote interface.
69
+
70
+ Parameters
71
+ ----------
72
+ mid: The ID of the task to query about.
73
+ payload: An optional payload containing an updated task description. Default: "status update"
74
+
75
+ Returns
76
+ -------
77
+ id: The current status of the task at the server. -1 if server not reachable or communication error.
78
+ """
79
+ try:
80
+ msg = Message.encode(self.scope, Request.STATUS, mid, payload)
81
+ self.socket.send(msg)
82
+ reply = Message.decode(self.socket.recv())
83
+ return int(reply["state"])
84
+ except Exception as e:
85
+ self.logger.error(e)
86
+ return -1
87
+
88
+ def wait(self, mid, payload = "waiting poll", timeout = 5.0):
89
+ """
90
+ Wait for a task to complete at the remote interface.
91
+
92
+ Parameters
93
+ ----------
94
+ mid: The ID of the task to wait for.
95
+ payload: An optional payload containing an updated task description. Default: "waiting poll"
96
+ timeout: Maximum time to wait. Default: 5.0 secs.
97
+
98
+ Returns
99
+ -------
100
+ id: The last task status after waiting complete. -1 if server not reachable or communication error.
101
+ """
102
+ start = time.time()
103
+ state = -1
104
+ while (time.time() - start) < timeout and mid > 0:
105
+ state = self.status(mid, payload)
106
+ if state > State.ACCEPTED:
107
+ return state
108
+ time.sleep(.1)
109
+ return state
@@ -0,0 +1,69 @@
1
+ import base64
2
+
3
+ class Request:
4
+
5
+ INIT = 1
6
+ STATUS = 2
7
+ ABORT = 3
8
+
9
+ @staticmethod
10
+ def name(code):
11
+ if code == Request.INIT: return "INIT"
12
+ if code == Request.STATUS: return "STATUS"
13
+ if code == Request.ABORT: return "ABORT"
14
+ return None
15
+
16
+ class State:
17
+
18
+ INITIATED = 0
19
+ ACCEPTED = 1
20
+ REJECTED = 2
21
+ FAILED = 3
22
+ ABORTED = 4
23
+ COMPLETED = 5
24
+
25
+ @staticmethod
26
+ def name(code):
27
+ if code == State.INITIATED: return "INITIATED"
28
+ if code == State.ACCEPTED: return "ACCEPTED"
29
+ if code == State.REJECTED: return "REJECTED"
30
+ if code == State.FAILED: return "FAILED"
31
+ if code == State.ABORTED: return "ABORTED"
32
+ if code == State.COMPLETED: return "COMPLETED"
33
+ return None
34
+
35
+ class Message:
36
+
37
+ SEPARATOR = ";"
38
+ FIELDS = ["scope", "state", "id", "payload"]
39
+
40
+ @staticmethod
41
+ def encode(scope, state, mid, payload):
42
+ msg = {"scope": str(scope),
43
+ "state": str(state),
44
+ "id": str(mid),
45
+ "payload": str(payload)}
46
+ return (msg["scope"] + Message.SEPARATOR + msg["state"] + Message.SEPARATOR + msg["id"] + Message.SEPARATOR + msg["payload"]).encode('utf-8')
47
+
48
+ @staticmethod
49
+ def decode(message):
50
+ split = message.decode("utf-8").split(Message.SEPARATOR)
51
+ unfolded = dict(zip(Message.FIELDS, split))
52
+ return unfolded
53
+
54
+
55
+ class Task:
56
+
57
+ SEPARATOR = "^"
58
+ FIELDS = ["handler", "component", "goal"]
59
+
60
+ @staticmethod
61
+ def decode(message):
62
+ cmd = base64.b64decode(bytes(str(message).encode("utf-8"))).split(Task.SEPARATOR)
63
+ return dict(zip(Task.FIELDS, cmd))
64
+
65
+ @staticmethod
66
+ def encode(handler, component, goal):
67
+ joined = str(handler) + Task.SEPARATOR + str(component) + Task.SEPARATOR + str(goal)
68
+ code = base64.b64encode(joined.encode('utf-8'))
69
+ return code.decode("utf-8")
@@ -0,0 +1,102 @@
1
+ import zmq
2
+ import time
3
+ import sys
4
+ import logging
5
+ logging.basicConfig(level=logging.INFO)
6
+
7
+ from ztl.core.protocol import Message, Request, State
8
+
9
+ class TaskServer(object):
10
+
11
+ def __init__(self, port):
12
+ self.logger = logging.getLogger('remote-task')
13
+ context = zmq.Context()
14
+ self.socket = context.socket(zmq.REP)
15
+ address = "tcp://*:" + str(port)
16
+ self.socket.bind(address)
17
+ self.controllers = {}
18
+ self.logger.info("Task Server listening at '%s'" % address)
19
+
20
+
21
+ def send_message(self, scope, mid, state, payload):
22
+ self.socket.send(Message.encode(scope, mid, state, payload))
23
+
24
+
25
+ def register(self, scope, controller):
26
+ """
27
+ Register a new controller for requests on a specific scope.
28
+
29
+ Parameters
30
+ ----------
31
+ scope: The scope for which the server should dispatch tasks to the controller. Will replace any existing controller for this scope.
32
+ controller: A controller object that will be called with requests (init, status, abort)
33
+ """
34
+
35
+ self.logger.info("Registering controller for scope '%s'." % scope)
36
+ self.controllers[scope] = controller
37
+
38
+
39
+ def unregister(self, scope):
40
+ """
41
+ Unregister any controller on a given scope.
42
+
43
+ Parameters
44
+ ----------
45
+ scope: The scope for which the server should not dispatch tasks any longer.
46
+ """
47
+ self.controllers[scope] = None
48
+ self.logger.info("Controller for scope '%s' removed." % scope)
49
+
50
+
51
+ def listen(self):
52
+ """
53
+ Begin listening to requests and dispatching them to any controllers if available. This method blocks until interrupted.
54
+
55
+ """
56
+ while True:
57
+ try:
58
+ message = self.socket.recv()
59
+ request = Message.decode(message)
60
+
61
+ if all(field in request for field in Message.FIELDS):
62
+
63
+ scope = request["scope"]
64
+ if scope in self.controllers:
65
+ controller = self.controllers[scope]
66
+
67
+ state = int(request["state"])
68
+ mid = int(request["id"])
69
+ payload = request["payload"]
70
+
71
+ try:
72
+ if state == Request.INIT:
73
+ ticket, response = controller.init(payload)
74
+ if ticket > 0:
75
+ self.send_message(scope, State.ACCEPTED, ticket, response)
76
+ else:
77
+ self.send_message(scope, State.REJECTED, ticket, response)
78
+ elif state == Request.STATUS:
79
+ status, response = controller.status(mid, payload)
80
+ self.send_message(scope, status, mid, response)
81
+ elif state == Request.ABORT:
82
+ status, response = controller.abort(mid, payload)
83
+ self.send_message(scope, status, mid, response)
84
+ else:
85
+ self.send_message(scope, State.REJECTED, mid, "Invalid request")
86
+ except Exception as e:
87
+ logging.error(e)
88
+ self.send_message(scope, State.FAILED, mid, "Controller threw exception: " + str(e))
89
+ else:
90
+ self.send_message(scope, State.REJECTED, -1, "No controller for scope: " + scope)
91
+ self.logger.warning("No controller for scope '%s', ignoring." % scope)
92
+
93
+ else:
94
+ self.send_message(scope, State.REJECTED, -1, "Unknown protocol")
95
+ self.logger.warning("Unknown command received '%s', ignoring." % message)
96
+
97
+ except Exception as e:
98
+ logging.error(e)
99
+ time.sleep(1)
100
+ except KeyboardInterrupt:
101
+ logging.error("Interrupted, exiting.")
102
+ sys.exit(1)
@@ -0,0 +1,95 @@
1
+ import logging
2
+ logging.basicConfig(level=logging.INFO)
3
+
4
+ from threading import Thread
5
+ from ztl.core.protocol import State
6
+
7
+
8
+ class ExecutableTask(object):
9
+
10
+ def initialise(self):
11
+ return True
12
+
13
+
14
+ def execute(self):
15
+ return True
16
+
17
+
18
+ def abort(self):
19
+ return True
20
+
21
+
22
+ class TaskController(object):
23
+
24
+ def init(self, payload):
25
+ return -1, "Not implemented"
26
+
27
+
28
+ def status(self, mid, payload):
29
+ return State.REJECTED, "Not implemented"
30
+
31
+
32
+ def abort(self, mid, payload):
33
+ return State.REJECTED, "Not implemented"
34
+
35
+
36
+ class TaskExecutor(Thread):
37
+
38
+ def __init__(self, cls, *parameters):
39
+ Thread.__init__(self)
40
+ self.logger = logging.getLogger(type(cls).__name__)
41
+ self.cls = cls
42
+ self.parameters = parameters
43
+ self.task = None
44
+ self._state = State.INITIATED
45
+ self._prevent = False
46
+ self.start()
47
+
48
+
49
+ def run(self):
50
+ self.logger.debug("Initiating task with parameters '%s'...", self.parameters)
51
+ self.task = self.cls(*self.parameters)
52
+ success = self.task.initialise()
53
+ if success:
54
+ if not self._prevent:
55
+ self.logger.debug("Accepting and executing task...")
56
+ self._state = State.ACCEPTED
57
+ success = self.task.execute()
58
+ if success:
59
+ self.logger.debug("Task execution completed successfully.")
60
+ self._state = State.COMPLETED
61
+ else:
62
+ self.logger.debug("Task execution failed.")
63
+ self._state = State.FAILED
64
+ else:
65
+ self._state = State.FAILED
66
+ logging.warn("Task execution prevented during initialising.")
67
+ else:
68
+ self.logger.debug("Task initialising failed, rejecting task.")
69
+ self._state = State.REJECTED
70
+
71
+
72
+ def stop(self):
73
+ if self.task is None:
74
+ self.logger.debug("Preventing task from executing...")
75
+ self._prevent = True
76
+ self._state = State.ABORTED
77
+ else:
78
+ self.logger.debug("Aborting task execution...")
79
+ success = self.task.abort()
80
+ if success:
81
+ print("Task aborted successfully.")
82
+ self._state = State.ABORTED
83
+ else:
84
+ self.logger.debug("Task could not be aborted.")
85
+ return self._state
86
+
87
+
88
+ def abort(self):
89
+ print("Aborting immediately as requested...")
90
+ self._state = State.ABORTED
91
+ super().abort()
92
+
93
+
94
+ def state(self):
95
+ return self._state
File without changes
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python
2
+
3
+ import sys
4
+
5
+ from ztl.core.client import RemoteTask
6
+ from ztl.core.protocol import State
7
+
8
+ def main_cli():
9
+ host = sys.argv[1]
10
+ scope = sys.argv[2]
11
+ payload = sys.argv[3]
12
+
13
+ print("Connecting to host '%s' at scope '%s'..." % (host, scope))
14
+ task = RemoteTask(str(host), 5555, scope)
15
+ print("Triggering task with payload '%s'..." % payload)
16
+ mid = task.trigger(payload)
17
+
18
+ if mid > 0:
19
+ print("Waiting 5s for task with ID '%s'..." % mid)
20
+ state = task.wait(mid, 5)
21
+ if state < 0:
22
+ print("Could not wait for task with ID '%s'." % mid)
23
+ elif state <= State.ACCEPTED:
24
+ print("Aborting task with ID '%s'..." % mid)
25
+ state = task.abort(mid)
26
+ if state == State.ABORTED:
27
+ print("Task with ID '%s' aborted." % mid)
28
+ elif state <= State.ACCEPTED:
29
+ print("Could not abort Task with ID '%s', waiting for completion." % mid)
30
+ task.wait(mid)
31
+ print("Task with ID '%s' finished after unsuccessful abort signal." % mid)
32
+ else:
33
+ print("Task with ID '%s' finished while waiting." % mid)
34
+ else:
35
+ print("Task '%s' could not be triggered.")
36
+
37
+ if __name__ == "__main__":
38
+
39
+ main_cli()
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python
2
+
3
+ import sys
4
+ import time
5
+
6
+ from ztl.core.server import TaskServer
7
+ from ztl.core.protocol import State
8
+ from ztl.core.task import ExecutableTask, TaskExecutor, TaskController
9
+
10
+
11
+ class DummyTask(ExecutableTask):
12
+
13
+ def __init__(self, duration):
14
+ self.active = True
15
+ self.duration = duration
16
+
17
+ def initialise(self):
18
+ return True
19
+
20
+ def execute(self):
21
+ start = time.time()
22
+ while self.active and time.time() - start < self.duration:
23
+ time.sleep(.1)
24
+ return self.active
25
+
26
+ def abort(self):
27
+ self.active = False
28
+ return True
29
+
30
+
31
+ class SimpleTaskController(TaskController):
32
+
33
+ def __init__(self):
34
+ self.current_id = 0
35
+ self.running = {}
36
+
37
+ def init(self, payload):
38
+ self.current_id += 1
39
+ print("Initialising Task ID '%s' (%s)..." % (self.current_id, payload))
40
+ self.running[self.current_id] = TaskExecutor(DummyTask, int(payload))
41
+ return self.current_id, ""
42
+
43
+ def status(self, mid, payload):
44
+ if mid in self.running:
45
+ print("Status Task ID '%s' (%s)..." % (mid, payload))
46
+ return self.running[mid].state(), mid
47
+ else:
48
+ return State.REJECTED, "Invalid ID"
49
+
50
+
51
+ def abort(self, mid, payload):
52
+ if mid in self.running:
53
+ print("Aborting Task ID '%s' (%s)..." % (mid, payload))
54
+ return self.running[mid].stop(), mid
55
+ else:
56
+ return State.REJECTED, "Invalid ID"
57
+
58
+
59
+ def main_cli():
60
+ scope = sys.argv[1]
61
+ server = TaskServer(5555)
62
+ server.register(scope, SimpleTaskController())
63
+ server.listen()
64
+
65
+
66
+ if __name__ == "__main__":
67
+
68
+ main_cli()
File without changes
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python
2
+
3
+ import time
4
+ import oyaml as yaml
5
+ import argparse
6
+ import os
7
+ import logging
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+
11
+ from sys import stdin, exit
12
+ from ztl.core.protocol import State, Task
13
+ from ztl.core.client import RemoteTask
14
+
15
+ class _Getch:
16
+ """Gets a single character from standard input. Does not echo to the
17
+ screen."""
18
+ def __init__(self):
19
+ try:
20
+ self.impl = _GetchWindows()
21
+ except ImportError:
22
+ self.impl = _GetchUnix()
23
+
24
+ def __call__(self): return self.impl()
25
+
26
+ class _GetchUnix:
27
+ def __init__(self):
28
+ import tty, sys
29
+
30
+ def __call__(self):
31
+ import sys, tty, termios
32
+ fd = sys.stdin.fileno()
33
+ old_settings = termios.tcgetattr(fd)
34
+ try:
35
+ tty.setraw(sys.stdin.fileno())
36
+ ch = sys.stdin.read(1)
37
+ finally:
38
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
39
+ return ch
40
+
41
+ class _GetchWindows:
42
+ def __init__(self):
43
+ import msvcrt
44
+
45
+ def __call__(self):
46
+ import msvcrt
47
+ return msvcrt.getch()
48
+
49
+ class ScriptExecutor(object):
50
+
51
+ tasks = {}
52
+
53
+ def __init__(self, configfile, scriptfile):
54
+ self.logger = logging.getLogger('script-exec')
55
+
56
+ self.getch = _Getch()
57
+
58
+ self.lastScene = None
59
+
60
+ with open(configfile) as f:
61
+ self.config = yaml.safe_load(f)
62
+
63
+ rs = self.config["remotes"]
64
+ for r in rs.keys():
65
+ self.logger.info("Initialising remote task interface '%s'..." % r)
66
+ self.tasks[r] = RemoteTask(rs[r]["host"], rs[r]["port"], rs[r]["scope"])
67
+
68
+ with open(scriptfile) as f:
69
+ self.script = yaml.safe_load(f)
70
+
71
+ def parse_stage(self, stage):
72
+ name = str(stage)
73
+ delay = 0
74
+ wait = True
75
+
76
+ o = name.find("(")
77
+ c = name.find(")")
78
+ if o > 0 and c > 0:
79
+ params = name[o+1:c]
80
+ name = name[0:o]
81
+ for param in params.split(","):
82
+ pp = param.split("=")
83
+ if len(pp) is 2:
84
+ key = pp[0].strip()
85
+ value = pp[1].strip()
86
+ if key == "delay":
87
+ delay = int(value)
88
+ if key == "wait":
89
+ wait = value.lower() in ['true', '1', 't', 'y', 'yes', 'on']
90
+ return name, delay, wait
91
+
92
+ def execute_scene(self, scene):
93
+
94
+ scene_name, scene_delay, scene_wait = self.parse_stage(scene)
95
+ steps = list(self.script[scene].keys())
96
+
97
+ print(("EXECUTING SCENE '%s'" % scene_name) + (" WITH DELAY %ss" % scene_delay if scene_delay > 0 else "") + "...")
98
+
99
+ if scene_delay > 0:
100
+ time.sleep(scene_delay)
101
+
102
+ for step in steps:
103
+ step_name, step_delay, step_wait = self.parse_stage(step)
104
+ print(("STARTING STEP '%s'" % step_name) + (" IN BACKGROUND" if not step_wait else "") + (" WITH DELAY %ss" % step_delay if step_delay > 0 else "") + "...")
105
+
106
+ if step_delay > 0:
107
+ time.sleep(step_delay)
108
+
109
+ task_ids = []
110
+ handlers = self.script[scene][step].keys()
111
+ for handler in handlers:
112
+ if handler in self.tasks:
113
+ components = self.script[scene][step][handler].keys()
114
+ for component in components:
115
+ component_name, component_delay, component_wait = self.parse_stage(component)
116
+ goal = self.script[scene][step][handler][component]
117
+
118
+ if component_delay > 0:
119
+ time.sleep(component_delay)
120
+
121
+ remote_id = self.tasks[handler].trigger(Task.encode(handler, component_name, goal))
122
+ if remote_id > 0:
123
+ if step_wait and component_wait:
124
+ task_ids.append(str(remote_id) + ":" + str(handler) + ":" + str(component_name) + ":" + str(goal))
125
+ else:
126
+ # MOVE TO DEBUG AFTER FINISHING THIS FEATURE
127
+ self.logger.info("Component '%s' on handler '%s' for step '%s' TRIGGERED TO RUN IN BACKGROUND." % (component_name, handler, step_name))
128
+ else:
129
+ self.logger.error("Component '%s' on handler '%s' for step '%s' COULD NOT BE TRIGGERED." % (component_name, handler, step_name))
130
+ else:
131
+ self.logger.error("No remote for handler '%s'. Step '%s' COULD NOT BE TRIGGERED." % (handler, step_name))
132
+
133
+ running = True
134
+ while running:
135
+ running = False
136
+ for task_id in task_ids:
137
+ components = task_id.split(":")
138
+ remote_id = int(components[0])
139
+ rid = components[1]
140
+ status = self.tasks[rid].wait(remote_id, task_id, timeout=100)
141
+ running = running or status <= State.ACCEPTED
142
+ time.sleep(.1)
143
+
144
+
145
+ def confirm_scene(self, scene):
146
+ print("\n----------------------------")
147
+ print("ABOUT TO EXECUTE SCENE '%s'" % scene)
148
+
149
+ steps = list(self.script[scene].keys())
150
+
151
+ for step in steps:
152
+ print("STEP: %s" % step)
153
+ handlers = self.script[scene][step].keys()
154
+ for handler in handlers:
155
+ components = self.script[scene][step][handler].keys()
156
+ for component in components:
157
+ goal = self.script[scene][step][handler][component]
158
+ print("\t%s [%s]: -> %s" % (handler, component, goal))
159
+
160
+ if self.lastScene == None:
161
+ print("PRESS <ENTER> TO CONFIRM or ANY OTHER KEY TO SKIP")
162
+ return self.get_key()
163
+ else:
164
+ print("PRESS <ENTER> TO CONFIRM, PRESS <R> TO REPLAY LAST SCENE OR ANY OTHER KEY TO SKIP")
165
+ return self.get_key()
166
+
167
+ def get_key(self):
168
+ first_char = self.getch()
169
+ # The idea would be to allow further decomposition of the getch e.g. if arrows keys are pressed
170
+ return first_char
171
+
172
+ def execute(self):
173
+ try:
174
+ for scene in self.script.keys():
175
+ repeat = True
176
+ while repeat:
177
+ keyPressed = self.confirm_scene(scene)
178
+ if keyPressed == "\r" or keyPressed == b"\r":
179
+ self.execute_scene(scene)
180
+ self.lastScene = scene
181
+ repeat = False
182
+ elif self.lastScene != None and (keyPressed == "r" or keyPressed == "R" or keyPressed == b"r" or keyPressed == b"R"):
183
+ print("\n Repeating Scene '%s" % (self.lastScene))
184
+ self.execute_scene(self.lastScene)
185
+ else:
186
+ print("\nSKIPPING SCENE '%s'" % (scene))
187
+ repeat = False
188
+
189
+ except Exception as e:
190
+ self.logger.error(e)
191
+ exit(1)
192
+ except KeyboardInterrupt:
193
+ self.logger.error("Interrupted, exiting.")
194
+ exit(1)
195
+
196
+ def main_cli():
197
+
198
+ cfg_file = os.environ.get('XDG_CONFIG_HOME', os.environ.get('HOME', '/home/demo') + '/.config') + '/zmq-remotes.yaml'
199
+
200
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
201
+ parser.add_argument("-c", "--config", type=str,
202
+ help="Configuration file location.", default=cfg_file)
203
+ parser.add_argument("-s", "--script", type=str,
204
+ help="Script file to execute.", required=True)
205
+
206
+ args, unknown = parser.parse_known_args()
207
+ run = ScriptExecutor(args.config, args.script)
208
+ run.execute()
209
+
210
+
211
+ if __name__ == "__main__":
212
+
213
+ main_cli()
214
+
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.1
2
+ Name: ztl
3
+ Version: 0.0.1
4
+ Summary: A thin library relying on zmq to dispatch tasks
5
+ Home-page: https://gitlab.com/robothouse/rh-user/ztl/
6
+ Author: Patrick Holthaus
7
+ Author-email: Patrick Holthaus <patrick.holthaus@googlemail.com>
8
+ License: BSD 2-Clause License
9
+
10
+ Copyright (c) 2023, UH Robot House / User Projects
11
+ All rights reserved.
12
+
13
+ Redistribution and use in source and binary forms, with or without
14
+ modification, are permitted provided that the following conditions are met:
15
+
16
+ 1. Redistributions of source code must retain the above copyright notice, this
17
+ list of conditions and the following disclaimer.
18
+
19
+ 2. Redistributions in binary form must reproduce the above copyright notice,
20
+ this list of conditions and the following disclaimer in the documentation
21
+ and/or other materials provided with the distribution.
22
+
23
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
24
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
25
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ Project-URL: Homepage, https://gitlab.com/robothouse/rh-user/ztl/
35
+ Project-URL: Bug Tracker, https://gitlab.com/robothouse/rh-user/ztl/issues/
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: License :: OSI Approved :: BSD License
38
+ Classifier: Operating System :: OS Independent
39
+ Requires-Python: >=3.7
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Requires-Dist: oyaml
43
+ Requires-Dist: pyzmq
44
+ Requires-Dist: pytest
45
+ Requires-Dist: pytest-xprocess
46
+
47
+ This project contains the `ZTL` library enabling light-weight and widely compatible **remote task execution** using [ZeroMQ](https://zeromq.org/).
48
+
49
+ The basic communication principle is as follows:
50
+
51
+ ![communication overview](res/overview.png)
52
+
53
+ Thereby, each task has the following lifecycle:
54
+
55
+ ![task lifecycle](res/task%20lifecycle.png)
56
+
57
+ An example communication could look like this:
58
+
59
+ Request:
60
+
61
+ ![communication overview](res/protocol.png)
62
+
63
+ Reply:
64
+
65
+ ![communication overview](res/protocol%20reply.png)
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ src/ztl/__init__.py
6
+ src/ztl.egg-info/PKG-INFO
7
+ src/ztl.egg-info/SOURCES.txt
8
+ src/ztl.egg-info/dependency_links.txt
9
+ src/ztl.egg-info/entry_points.txt
10
+ src/ztl.egg-info/requires.txt
11
+ src/ztl.egg-info/top_level.txt
12
+ src/ztl/core/__init__.py
13
+ src/ztl/core/client.py
14
+ src/ztl/core/protocol.py
15
+ src/ztl/core/server.py
16
+ src/ztl/core/task.py
17
+ src/ztl/example/__init__.py
18
+ src/ztl/example/simple_client.py
19
+ src/ztl/example/simple_server.py
20
+ src/ztl/script/__init__.py
21
+ src/ztl/script/run_script.py
22
+ test/test_installation.py
23
+ test/test_instances.py
24
+ test/test_lifecycle.py
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ ztl_run_script = ztl.script.run_script:main_cli
3
+ ztl_simple_client = ztl.example.simple_client:main_cli
4
+ ztl_simple_server = ztl.example.simple_server:main_cli
@@ -0,0 +1,4 @@
1
+ oyaml
2
+ pyzmq
3
+ pytest
4
+ pytest-xprocess
@@ -0,0 +1 @@
1
+ ztl
@@ -0,0 +1,23 @@
1
+ from unittest import TestCase
2
+
3
+ class TestUnitTests(TestCase):
4
+
5
+ def test_unit_testing(self):
6
+ self.assertTrue(True)
7
+
8
+ class TestImports(TestCase):
9
+
10
+ def test_zmq_import(self):
11
+ import zmq
12
+
13
+ def test_ztl_protocol_imports(self):
14
+ from ztl.core.protocol import Message, Request, State, Task
15
+
16
+ def test_ztl_server_imports(self):
17
+ from ztl.core.server import TaskServer
18
+
19
+ def test_ztl_client_imports(self):
20
+ from ztl.core.client import RemoteTask
21
+
22
+ def test_ztl_task_imports(self):
23
+ from ztl.core.task import ExecutableTask, TaskController, TaskExecutor
@@ -0,0 +1,15 @@
1
+ import pytest
2
+
3
+ from unittest import TestCase
4
+
5
+ from ztl.core.client import RemoteTask
6
+ from ztl.core.server import TaskServer
7
+
8
+ class TestConstruction(TestCase):
9
+
10
+ def test_server_construction(self):
11
+ server = TaskServer(6666)
12
+ server.register("/dummy", None)
13
+
14
+ def test_client_construction(self):
15
+ RemoteTask("localhost", 6666, "/dummy")
@@ -0,0 +1,75 @@
1
+ import pytest
2
+
3
+ from unittest import TestCase
4
+
5
+ from ztl.core.client import RemoteTask
6
+ from ztl.core.server import TaskServer
7
+ from ztl.core.protocol import State
8
+
9
+ @pytest.mark.usefixtures("ztl_server")
10
+ class TestLocalLife(TestCase):
11
+
12
+ def test_no_controller(self):
13
+ host = "localhost"
14
+ scope = "/no"
15
+ payload = "does not matter"
16
+
17
+ task = RemoteTask("localhost", 7777, scope)
18
+ mid = task.trigger(payload)
19
+
20
+ assert mid < 0
21
+
22
+ def test_none_controller(self):
23
+ host = "localhost"
24
+ scope = "/none"
25
+ payload = "does not matter"
26
+
27
+ task = RemoteTask("localhost", 7777, scope)
28
+ mid = task.trigger(payload)
29
+
30
+ assert mid < 0
31
+
32
+ @pytest.mark.usefixtures("ztl_simple_server")
33
+ class TestLifeCycle(TestCase):
34
+
35
+ def test_reject(self):
36
+ host = "localhost"
37
+ scope = "/test"
38
+ payload = "illegal payload"
39
+
40
+ task = RemoteTask("localhost", 5555, scope)
41
+ mid = task.trigger(payload)
42
+
43
+ assert mid < 0
44
+
45
+ def test_abort(self):
46
+ host = "localhost"
47
+ scope = "/test"
48
+ payload = 5
49
+
50
+ task = RemoteTask("localhost", 5555, scope)
51
+ mid = task.trigger(payload)
52
+
53
+ assert mid > 0
54
+
55
+ state = task.wait(mid, .1)
56
+ assert state == State.ACCEPTED
57
+
58
+ state = task.abort(mid)
59
+ assert state == State.ABORTED
60
+
61
+ def test_completion(self):
62
+ host = "localhost"
63
+ scope = "/test"
64
+ payload = 5
65
+
66
+ task = RemoteTask("localhost", 5555, scope)
67
+ mid = task.trigger(payload)
68
+
69
+ assert mid > 0
70
+
71
+ state = task.wait(mid, .1)
72
+ assert state == State.ACCEPTED
73
+
74
+ state = task.wait(mid, 3.1)
75
+ assert state == State.COMPLETED