k3wsjobd 0.1.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.
k3wsjobd-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 张炎泼
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: k3wsjobd
3
+ Version: 0.1.1
4
+ Summary: Gevent-based WebSocket server for async job processing
5
+ Author-email: Zhang Yanpo <drdr.xp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pykit3/k3wsjobd
8
+ Project-URL: Documentation, https://k3wsjobd.readthedocs.io
9
+ Keywords: websocket,gevent,job,async,server
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: gevent-websocket
23
+ Requires-Dist: psutil
24
+ Requires-Dist: k3thread
25
+ Requires-Dist: k3utfjson
26
+ Requires-Dist: k3jobq
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: ruff; extra == "dev"
30
+ Requires-Dist: coverage; extra == "dev"
31
+ Provides-Extra: publish
32
+ Requires-Dist: build; extra == "publish"
33
+ Requires-Dist: twine; extra == "publish"
34
+ Requires-Dist: pk3; extra == "publish"
35
+ Provides-Extra: docs
36
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
37
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
38
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
39
+ Dynamic: license-file
40
+
41
+ # k3wsjobd
42
+
43
+ [![Action-CI](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml)
44
+ [![Build Status](https://travis-ci.com/pykit3/k3wsjobd.svg?branch=master)](https://travis-ci.com/pykit3/k3wsjobd)
45
+ [![Documentation Status](https://readthedocs.org/projects/k3wsjobd/badge/?version=stable)](https://k3wsjobd.readthedocs.io/en/stable/?badge=stable)
46
+ [![Package](https://img.shields.io/pypi/pyversions/k3wsjobd)](https://pypi.org/project/k3wsjobd)
47
+
48
+ This module is a gevent based websocket server. When the server receives a job description from a client, it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
49
+
50
+ k3wsjobd is a component of [pykit3] project: a python3 toolkit set.
51
+
52
+
53
+ This module is a gevent based websocket server. When the server receives a job description from a client,
54
+ it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
55
+
56
+
57
+
58
+
59
+ # Install
60
+
61
+ ```
62
+ pip install k3wsjobd
63
+ ```
64
+
65
+ # Synopsis
66
+
67
+ ```python
68
+
69
+ from geventwebsocket import Resource, WebSocketServer
70
+ import k3wsjobd
71
+ from k3wsjobd import logging
72
+
73
+
74
+ def run():
75
+ k3wsjobd.run(ip='127.0.0.1', port=33445)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ logger = logging.getLogger()
80
+ logger.setLevel(logging.INFO)
81
+
82
+ file_handler = logging.FileHandler('wsjobd.log')
83
+ formatter = logging.Formatter('[%(asctime)s, %(levelname)s] %(message)s')
84
+ file_handler.setFormatter(formatter)
85
+ logger.addHandler(file_handler)
86
+ run()
87
+
88
+ ```
89
+
90
+ # Author
91
+
92
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
93
+
94
+ # Copyright and License
95
+
96
+ The MIT License (MIT)
97
+
98
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
99
+
100
+
101
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,61 @@
1
+ # k3wsjobd
2
+
3
+ [![Action-CI](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml)
4
+ [![Build Status](https://travis-ci.com/pykit3/k3wsjobd.svg?branch=master)](https://travis-ci.com/pykit3/k3wsjobd)
5
+ [![Documentation Status](https://readthedocs.org/projects/k3wsjobd/badge/?version=stable)](https://k3wsjobd.readthedocs.io/en/stable/?badge=stable)
6
+ [![Package](https://img.shields.io/pypi/pyversions/k3wsjobd)](https://pypi.org/project/k3wsjobd)
7
+
8
+ This module is a gevent based websocket server. When the server receives a job description from a client, it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
9
+
10
+ k3wsjobd is a component of [pykit3] project: a python3 toolkit set.
11
+
12
+
13
+ This module is a gevent based websocket server. When the server receives a job description from a client,
14
+ it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
15
+
16
+
17
+
18
+
19
+ # Install
20
+
21
+ ```
22
+ pip install k3wsjobd
23
+ ```
24
+
25
+ # Synopsis
26
+
27
+ ```python
28
+
29
+ from geventwebsocket import Resource, WebSocketServer
30
+ import k3wsjobd
31
+ from k3wsjobd import logging
32
+
33
+
34
+ def run():
35
+ k3wsjobd.run(ip='127.0.0.1', port=33445)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ logger = logging.getLogger()
40
+ logger.setLevel(logging.INFO)
41
+
42
+ file_handler = logging.FileHandler('wsjobd.log')
43
+ formatter = logging.Formatter('[%(asctime)s, %(levelname)s] %(message)s')
44
+ file_handler.setFormatter(formatter)
45
+ logger.addHandler(file_handler)
46
+ run()
47
+
48
+ ```
49
+
50
+ # Author
51
+
52
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
53
+
54
+ # Copyright and License
55
+
56
+ The MIT License (MIT)
57
+
58
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
59
+
60
+
61
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,33 @@
1
+ """
2
+ This module is a gevent based websocket server. When the server receives a job description from a client,
3
+ it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
4
+
5
+ """
6
+
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("k3wsjobd")
10
+
11
+ from .wsjobd import (
12
+ JobdWebSocketApplication,
13
+ Job,
14
+ run,
15
+ SystemOverloadError,
16
+ JobError,
17
+ InvalidMessageError,
18
+ InvalidProgressError,
19
+ LoadingError,
20
+ JobNotInSessionError,
21
+ )
22
+
23
+ __all__ = [
24
+ "JobdWebSocketApplication",
25
+ "Job",
26
+ "run",
27
+ "SystemOverloadError",
28
+ "JobError",
29
+ "InvalidMessageError",
30
+ "InvalidProgressError",
31
+ "LoadingError",
32
+ "JobNotInSessionError",
33
+ ]
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: k3wsjobd
3
+ Version: 0.1.1
4
+ Summary: Gevent-based WebSocket server for async job processing
5
+ Author-email: Zhang Yanpo <drdr.xp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pykit3/k3wsjobd
8
+ Project-URL: Documentation, https://k3wsjobd.readthedocs.io
9
+ Keywords: websocket,gevent,job,async,server
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: gevent-websocket
23
+ Requires-Dist: psutil
24
+ Requires-Dist: k3thread
25
+ Requires-Dist: k3utfjson
26
+ Requires-Dist: k3jobq
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: ruff; extra == "dev"
30
+ Requires-Dist: coverage; extra == "dev"
31
+ Provides-Extra: publish
32
+ Requires-Dist: build; extra == "publish"
33
+ Requires-Dist: twine; extra == "publish"
34
+ Requires-Dist: pk3; extra == "publish"
35
+ Provides-Extra: docs
36
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
37
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
38
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
39
+ Dynamic: license-file
40
+
41
+ # k3wsjobd
42
+
43
+ [![Action-CI](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3wsjobd/actions/workflows/python-package.yml)
44
+ [![Build Status](https://travis-ci.com/pykit3/k3wsjobd.svg?branch=master)](https://travis-ci.com/pykit3/k3wsjobd)
45
+ [![Documentation Status](https://readthedocs.org/projects/k3wsjobd/badge/?version=stable)](https://k3wsjobd.readthedocs.io/en/stable/?badge=stable)
46
+ [![Package](https://img.shields.io/pypi/pyversions/k3wsjobd)](https://pypi.org/project/k3wsjobd)
47
+
48
+ This module is a gevent based websocket server. When the server receives a job description from a client, it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
49
+
50
+ k3wsjobd is a component of [pykit3] project: a python3 toolkit set.
51
+
52
+
53
+ This module is a gevent based websocket server. When the server receives a job description from a client,
54
+ it runs that job asynchronously in a thread, and reports the progress back to the client periodically.
55
+
56
+
57
+
58
+
59
+ # Install
60
+
61
+ ```
62
+ pip install k3wsjobd
63
+ ```
64
+
65
+ # Synopsis
66
+
67
+ ```python
68
+
69
+ from geventwebsocket import Resource, WebSocketServer
70
+ import k3wsjobd
71
+ from k3wsjobd import logging
72
+
73
+
74
+ def run():
75
+ k3wsjobd.run(ip='127.0.0.1', port=33445)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ logger = logging.getLogger()
80
+ logger.setLevel(logging.INFO)
81
+
82
+ file_handler = logging.FileHandler('wsjobd.log')
83
+ formatter = logging.Formatter('[%(asctime)s, %(levelname)s] %(message)s')
84
+ file_handler.setFormatter(formatter)
85
+ logger.addHandler(file_handler)
86
+ run()
87
+
88
+ ```
89
+
90
+ # Author
91
+
92
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
93
+
94
+ # Copyright and License
95
+
96
+ The MIT License (MIT)
97
+
98
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
99
+
100
+
101
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ __init__.py
4
+ pyproject.toml
5
+ synopsis.py
6
+ wsjobd.py
7
+ ./__init__.py
8
+ ./synopsis.py
9
+ ./wsjobd.py
10
+ k3wsjobd.egg-info/PKG-INFO
11
+ k3wsjobd.egg-info/SOURCES.txt
12
+ k3wsjobd.egg-info/dependency_links.txt
13
+ k3wsjobd.egg-info/requires.txt
14
+ k3wsjobd.egg-info/top_level.txt
15
+ test/test_doctest.py
16
+ test/test_k3wsjobd.py
@@ -0,0 +1,20 @@
1
+ gevent-websocket
2
+ psutil
3
+ k3thread
4
+ k3utfjson
5
+ k3jobq
6
+
7
+ [dev]
8
+ pytest>=7.0
9
+ ruff
10
+ coverage
11
+
12
+ [docs]
13
+ mkdocs>=1.5
14
+ mkdocs-material>=9.0
15
+ mkdocstrings[python]>=0.24
16
+
17
+ [publish]
18
+ build
19
+ twine
20
+ pk3
@@ -0,0 +1 @@
1
+ k3wsjobd
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "k3wsjobd"
7
+ version = "0.1.1"
8
+ description = "Gevent-based WebSocket server for async job processing"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Zhang Yanpo", email = "drdr.xp@gmail.com" }
14
+ ]
15
+ keywords = ["websocket", "gevent", "job", "async", "server"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Software Development :: Libraries",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ ]
27
+ dependencies = [
28
+ "gevent-websocket",
29
+ "psutil",
30
+ "k3thread",
31
+ "k3utfjson",
32
+ "k3jobq",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/pykit3/k3wsjobd"
37
+ Documentation = "https://k3wsjobd.readthedocs.io"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=7.0",
42
+ "ruff",
43
+ "coverage",
44
+ ]
45
+ publish = [
46
+ "build",
47
+ "twine",
48
+ "pk3",
49
+ ]
50
+ docs = [
51
+ "mkdocs>=1.5",
52
+ "mkdocs-material>=9.0",
53
+ "mkdocstrings[python]>=0.24",
54
+ ]
55
+
56
+ [tool.setuptools]
57
+ packages = ["k3wsjobd"]
58
+
59
+ [tool.setuptools.package-dir]
60
+ k3wsjobd = "."
61
+
62
+ [tool.ruff]
63
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ import k3wsjobd
2
+ from k3wsjobd import logging
3
+
4
+
5
+ def run():
6
+ k3wsjobd.run(ip="127.0.0.1", port=33445)
7
+
8
+
9
+ if __name__ == "__main__":
10
+ logger = logging.getLogger()
11
+ logger.setLevel(logging.INFO)
12
+
13
+ file_handler = logging.FileHandler("wsjobd.log")
14
+ formatter = logging.Formatter("[%(asctime)s, %(levelname)s] %(message)s")
15
+ file_handler.setFormatter(formatter)
16
+ logger.addHandler(file_handler)
17
+ run()
@@ -0,0 +1,8 @@
1
+ import doctest
2
+
3
+ import k3wsjobd
4
+
5
+
6
+ def load_tests(loader, tests, ignore):
7
+ tests.addTests(doctest.DocTestSuite(k3wsjobd))
8
+ return tests
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+
4
+ import os
5
+ import random
6
+ import time
7
+ import unittest
8
+
9
+ import websocket
10
+
11
+ import k3proc
12
+ import k3utfjson
13
+ import k3ut
14
+ from k3wsjobd import Job
15
+ from k3wsjobd.test.wsjobd_server import PORT
16
+
17
+ dd = k3ut.dd
18
+
19
+ random.seed(time.time())
20
+ this_base = os.path.dirname(__file__)
21
+
22
+
23
+ def subproc(script, env=None):
24
+ if env is None:
25
+ env = dict(
26
+ PYTHONPATH=this_base + "/../..",
27
+ )
28
+
29
+ return k3proc.shell_script(script, env=env)
30
+
31
+
32
+ class TestWsjobd(unittest.TestCase):
33
+ @classmethod
34
+ def _clean(cls):
35
+ try:
36
+ subproc("python {b}/wsjobd_server.py stop".format(b=this_base))
37
+ except Exception as e:
38
+ dd("failed to stop wsjobd server: " + repr(e))
39
+
40
+ time.sleep(0.1)
41
+
42
+ @classmethod
43
+ def setUpClass(cls):
44
+ cls._clean()
45
+ subproc("python {b}/wsjobd_server.py start".format(b=this_base))
46
+ time.sleep(1)
47
+
48
+ @classmethod
49
+ def tearDownClass(cls):
50
+ cls._clean()
51
+
52
+ def _create_client(self):
53
+ ws = websocket.WebSocket()
54
+ ws.connect("ws://127.0.0.1:%d" % PORT)
55
+ ws.timeout = 6
56
+ return ws
57
+
58
+ def setUp(self):
59
+ self.ws = self._create_client()
60
+
61
+ def tearDown(self):
62
+ self.ws.close()
63
+
64
+ def get_random_ident(self):
65
+ return "random_ident_%d" % random.randint(10000, 99999)
66
+
67
+ def _wait_for_result(self, ws):
68
+ # wait for test_job_echo.run to fillin resp['result']
69
+ for _ in range(3):
70
+ resp = k3utfjson.load(ws.recv())
71
+ if "result" in resp:
72
+ break
73
+ time.sleep(0.1)
74
+ return resp
75
+
76
+ def test_invalid_jobdesc(self):
77
+ cases = (
78
+ ("foo", "not json"),
79
+ (k3utfjson.dump("foo"), "not dict"),
80
+ (k3utfjson.dump({}), "no func"),
81
+ (k3utfjson.dump({"func": "foo"}), "no ident"),
82
+ (k3utfjson.dump({"ident": "bar"}), "no func"),
83
+ (k3utfjson.dump({"ident": "bar", "func": {}}), "invalid func"),
84
+ (k3utfjson.dump({"ident": 44, "func": "foo"}), "invalid ident"),
85
+ (k3utfjson.dump({"ident": "foo", "func": "foo", "jobs_dir": {}}), "invalid jobs_dir"),
86
+ )
87
+ for msg, desc in cases:
88
+ ws = self._create_client()
89
+ ws.send(msg)
90
+ resp = k3utfjson.load(ws.recv())
91
+ self.assertIn("err", resp, desc)
92
+ ws.close()
93
+
94
+ def test_normal_job(self):
95
+ job_desc = {
96
+ "func": "test_job_normal.run",
97
+ "ident": self.get_random_ident(),
98
+ "jobs_dir": "k3wsjobd/test/test_jobs",
99
+ }
100
+
101
+ self.ws.send(k3utfjson.dump(job_desc))
102
+
103
+ resp = self._wait_for_result(self.ws)
104
+ dd(resp)
105
+ self.assertEqual("foo", resp["result"], "test get result")
106
+
107
+ def test_report_interval(self):
108
+ job_desc = {
109
+ "func": "test_job_loop_10.run",
110
+ "ident": self.get_random_ident(),
111
+ "progress": {
112
+ "interval": 0.5,
113
+ },
114
+ "jobs_dir": "k3wsjobd/test/test_jobs",
115
+ }
116
+
117
+ self.ws.send(k3utfjson.dump(job_desc))
118
+
119
+ self.ws.recv()
120
+ first_report_time = time.time()
121
+
122
+ self.ws.recv()
123
+ second_report_time = time.time()
124
+
125
+ actual_interval = second_report_time - first_report_time
126
+
127
+ diff = actual_interval - job_desc["progress"]["interval"]
128
+
129
+ # tolerate 0.1 second of difference
130
+ self.assertLess(diff, 0.1)
131
+
132
+ def test_invalid_progress_key(self):
133
+ job_desc = {
134
+ "func": "test_job_progress_key.run",
135
+ "ident": self.get_random_ident(),
136
+ "progress": {
137
+ "key": "inexistent",
138
+ },
139
+ "report_system_load": True,
140
+ "jobs_dir": "k3wsjobd/test/test_jobs",
141
+ }
142
+
143
+ self.ws.send(k3utfjson.dump(job_desc))
144
+
145
+ resp = self.ws.recv()
146
+ dd(repr(resp))
147
+ resp = k3utfjson.load(resp)
148
+ self.assertIsNone(resp)
149
+
150
+ def test_progress_key(self):
151
+ job_desc = {
152
+ "func": "test_job_progress_key.run",
153
+ "ident": self.get_random_ident(),
154
+ "progress": {
155
+ "key": "foo",
156
+ },
157
+ # progress_sender may try to read data["foo"] before job starts.
158
+ # need to preset a value.
159
+ "foo": "0%",
160
+ "report_system_load": True,
161
+ "jobs_dir": "k3wsjobd/test/test_jobs",
162
+ }
163
+
164
+ self.ws.send(k3utfjson.dump(job_desc))
165
+
166
+ resp = self.ws.recv()
167
+ dd(repr(resp))
168
+ resp = k3utfjson.load(resp)
169
+ # progress may be set by job runner or job desc
170
+ self.assertIn(resp, ("80%", "0%"))
171
+
172
+ def test_check_system_load(self):
173
+ job_desc = {
174
+ "func": "test_job_normal.run",
175
+ "ident": self.get_random_ident(),
176
+ "check_load": {
177
+ "mem_low_threshold": 100 * 1024**3,
178
+ "cpu_low_threshold": 0,
179
+ },
180
+ "jobs_dir": "k3wsjobd/test/test_jobs",
181
+ }
182
+
183
+ self.ws.send(k3utfjson.dump(job_desc))
184
+
185
+ resp = self.ws.recv()
186
+ resp = k3utfjson.load(resp)
187
+ self.assertEqual("SystemOverloadError", resp["err"])
188
+
189
+ job_desc = {
190
+ "func": "test_job_normal.run",
191
+ "ident": self.get_random_ident(),
192
+ "check_load": {
193
+ "cpu_low_threshold": 100.1,
194
+ "mem_low_threshold": 0,
195
+ },
196
+ "jobs_dir": "k3wsjobd/test/test_jobs",
197
+ }
198
+
199
+ ws2 = self._create_client()
200
+ ws2.send(k3utfjson.dump(job_desc))
201
+
202
+ resp = ws2.recv()
203
+ resp = k3utfjson.load(resp)
204
+ ws2.close()
205
+ self.assertEqual("SystemOverloadError", resp["err"])
206
+
207
+ def test_max_client_number(self):
208
+ job_desc = {
209
+ "func": "test_job_loop_10.run",
210
+ "ident": self.get_random_ident(),
211
+ "check_load": {
212
+ "max_client_number": 1,
213
+ "cpu_low_threshold": 0,
214
+ "mem_low_threshold": 0,
215
+ },
216
+ "jobs_dir": "k3wsjobd/test/test_jobs",
217
+ }
218
+
219
+ self.ws.send(k3utfjson.dump(job_desc))
220
+
221
+ resp = self.ws.recv()
222
+ resp = k3utfjson.load(resp)
223
+ self.assertNotIn("err", resp)
224
+
225
+ job_desc = {
226
+ "func": "test_job_loop_10.run",
227
+ "ident": self.get_random_ident(),
228
+ "check_load": {
229
+ "max_client_number": 1,
230
+ "cpu_low_threshold": 0,
231
+ "mem_low_threshold": 0,
232
+ },
233
+ "jobs_dir": "k3wsjobd/test/test_jobs",
234
+ }
235
+
236
+ ws2 = self._create_client()
237
+ ws2.send(k3utfjson.dump(job_desc))
238
+
239
+ resp = ws2.recv()
240
+ resp = k3utfjson.load(resp)
241
+ self.assertEqual("SystemOverloadError", resp["err"])
242
+ ws2.close()
243
+
244
+ def test_module_not_exists(self):
245
+ job_desc = {
246
+ "func": "foo.bar",
247
+ "ident": self.get_random_ident(),
248
+ "jobs_dir": "k3wsjobd/test/test_jobs",
249
+ }
250
+
251
+ self.ws.send(k3utfjson.dump(job_desc))
252
+
253
+ resp = k3utfjson.load(self.ws.recv())
254
+ self.assertIn("err", resp)
255
+
256
+ def test_report_system_load(self):
257
+ job_desc = {
258
+ "func": "test_job_normal.run",
259
+ "ident": self.get_random_ident(),
260
+ "report_system_load": True,
261
+ "jobs_dir": "k3wsjobd/test/test_jobs",
262
+ }
263
+
264
+ self.ws.send(k3utfjson.dump(job_desc))
265
+
266
+ resp = k3utfjson.load(self.ws.recv())
267
+ self.assertIn("mem_available", resp["system_load"])
268
+ self.assertIn("cpu_idle_percent", resp["system_load"])
269
+ self.assertIn("client_number", resp["system_load"])
270
+
271
+ def test_same_ident_same_job(self):
272
+ ident = self.get_random_ident()
273
+ job_desc = {
274
+ "func": "test_job_echo.run",
275
+ "ident": ident,
276
+ "echo": "foo",
277
+ "sleep_time": 10,
278
+ "jobs_dir": "k3wsjobd/test/test_jobs",
279
+ }
280
+
281
+ self.ws.send(k3utfjson.dump(job_desc))
282
+
283
+ resp = k3utfjson.load(self.ws.recv())
284
+ self.assertEqual("foo", resp["result"])
285
+
286
+ job_desc = {
287
+ "func": "test_job_echo.run",
288
+ "ident": ident,
289
+ "echo": "bar",
290
+ "jobs_dir": "k3wsjobd/test/test_jobs",
291
+ }
292
+
293
+ ws = self._create_client()
294
+ ws.send(k3utfjson.dump(job_desc))
295
+
296
+ resp = k3utfjson.load(ws.recv())
297
+ ws.close()
298
+ # not bar, because the ident is same as the first job,
299
+ # if job exists, it will not create a new one
300
+ self.assertEqual("foo", resp["result"])
301
+ self.assertEqual("foo", resp["echo"])
302
+
303
+ def test_same_ident_different_job(self):
304
+ ident = self.get_random_ident()
305
+ job_desc = {
306
+ "func": "test_job_echo.run",
307
+ "ident": ident,
308
+ "echo": "foo",
309
+ "sleep_time": 0.1,
310
+ "jobs_dir": "k3wsjobd/test/test_jobs",
311
+ }
312
+
313
+ self.ws.send(k3utfjson.dump(job_desc))
314
+
315
+ resp = self._wait_for_result(self.ws)
316
+ self.assertEqual("foo", resp["result"])
317
+
318
+ time.sleep(0.2)
319
+
320
+ job_desc = {
321
+ "func": "test_job_echo.run",
322
+ "ident": ident,
323
+ "echo": "bar",
324
+ "jobs_dir": "k3wsjobd/test/test_jobs",
325
+ }
326
+
327
+ ws2 = self._create_client()
328
+ ws2.send(k3utfjson.dump(job_desc))
329
+
330
+ resp = self._wait_for_result(ws2)
331
+ ws2.close()
332
+ # old job with the same ident has exit, it will create a new one
333
+ self.assertEqual("bar", resp["result"])
334
+ self.assertEqual("bar", resp["echo"])
335
+
336
+ def test_client_close(self):
337
+ ident = self.get_random_ident()
338
+ job_desc = {
339
+ "func": "test_job_echo.run",
340
+ "ident": ident,
341
+ "echo": "foo",
342
+ "time_sleep": 10,
343
+ "jobs_dir": "k3wsjobd/test/test_jobs",
344
+ }
345
+
346
+ self.ws.send(k3utfjson.dump(job_desc))
347
+ self.ws.close()
348
+ self.ws = self._create_client()
349
+
350
+ job_desc = {
351
+ "func": "test_job_echo.run",
352
+ "ident": ident,
353
+ "echo": "bar",
354
+ "time_sleep": 10,
355
+ "jobs_dir": "k3wsjobd/test/test_jobs",
356
+ }
357
+
358
+ self.ws.send(k3utfjson.dump(job_desc))
359
+
360
+ resp = self.ws.recv()
361
+ resp = k3utfjson.load(resp)
362
+ self.assertEqual("foo", resp["result"])
363
+
364
+ def test_invalid_cpu_sample_interval(self):
365
+ job_desc = {
366
+ "func": "test_job_normal.run",
367
+ "ident": self.get_random_ident(),
368
+ "cpu_sample_interval": "foo",
369
+ "jobs_dir": "k3wsjobd/test/test_jobs",
370
+ }
371
+
372
+ self.ws.send(k3utfjson.dump(job_desc))
373
+
374
+ resp = k3utfjson.load(self.ws.recv())
375
+ self.assertEqual("InvalidMessageError", resp["err"])
376
+
377
+ def test_invalid_check_load_args(self):
378
+ cases = (
379
+ {"check_load": {"mem_low_threshold": "foo", "cpu_low_threshold": 0}},
380
+ {"check_load": {"cpu_low_threshold": None, "mem_low_threshold": 0}},
381
+ {"check_load": {"max_client_number": {}, "cpu_low_threshold": 0, "mem_low_threshold": 0}},
382
+ )
383
+
384
+ job_desc = {
385
+ "func": "test_job_normal.run",
386
+ "jobs_dir": "k3wsjobd/test/test_jobs",
387
+ }
388
+
389
+ for case in cases:
390
+ case.update(job_desc)
391
+ case["ident"] = self.get_random_ident()
392
+
393
+ ws = self._create_client()
394
+ ws.send(k3utfjson.dump(case))
395
+
396
+ resp = k3utfjson.load(ws.recv())
397
+ ws.close()
398
+ self.assertEqual("InvalidMessageError", resp["err"])
399
+
400
+ def test_func_not_exists(self):
401
+ ident = self.get_random_ident()
402
+ job_desc = {
403
+ "func": "test_job_echo.func_not_exists",
404
+ "ident": ident,
405
+ "jobs_dir": "k3wsjobd/test/test_jobs",
406
+ }
407
+
408
+ self.ws.send(k3utfjson.dump(job_desc))
409
+ resp = self.ws.recv()
410
+ resp = k3utfjson.load(resp)
411
+ self.assertEqual("LoadingError", resp["err"])
412
+
413
+ def test_worker_exception(self):
414
+ ident = self.get_random_ident()
415
+ job_desc = {
416
+ "func": "test_job_worker_exception.run",
417
+ "ident": ident,
418
+ "jobs_dir": "k3wsjobd/test/test_jobs",
419
+ "progress": {
420
+ "interval": 0.1,
421
+ },
422
+ }
423
+
424
+ self.ws.send(k3utfjson.dump(job_desc))
425
+
426
+ for i in range(10):
427
+ resp = self.ws.recv()
428
+ self.assertNotIn("err", resp)
429
+
430
+ with self.assertRaises(Exception):
431
+ for i in range(3):
432
+ self.ws.recv()
433
+
434
+ def test_create_job(self):
435
+ self.assertEqual(0, len(Job.sessions))
436
+
437
+ def f(self):
438
+ time.sleep(0.2)
439
+ return
440
+
441
+ Job("channel", {"ident": "a"}, f)
442
+ joba = Job.sessions["a"]
443
+
444
+ self.assertEqual(1, len(Job.sessions))
445
+
446
+ Job("channel", {"ident": "a"}, f)
447
+ joba1 = Job.sessions["a"]
448
+
449
+ # joba already exists
450
+ self.assertEqual(joba1, joba)
451
+ self.assertEqual(1, len(Job.sessions))
452
+
453
+ time.sleep(0.15)
454
+ Job("channel", {"ident": "b"}, f)
455
+
456
+ self.assertEqual(2, len(Job.sessions))
457
+
458
+ time.sleep(0.15)
459
+ self.assertEqual(1, len(Job.sessions))
460
+
461
+ time.sleep(0.1)
462
+ self.assertEqual(0, len(Job.sessions))
463
+
464
+ # test use same ident after first one exit
465
+ Job("channel", {"ident": "a"}, f)
466
+ joba1 = Job.sessions["a"]
467
+
468
+ time.sleep(0.25)
469
+
470
+ Job("channel", {"ident": "a"}, f)
471
+ joba2 = Job.sessions["a"]
472
+ self.assertNotEqual(joba1, joba2)
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+
4
+ import logging
5
+ import threading
6
+ from collections import OrderedDict
7
+
8
+ import psutil
9
+ from geventwebsocket import Resource
10
+ from geventwebsocket import WebSocketApplication
11
+ from geventwebsocket import WebSocketError
12
+ from geventwebsocket import WebSocketServer
13
+
14
+ import k3thread
15
+ import k3utfjson
16
+ import k3jobq
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ MEM_AVAILABLE = "mem_available"
21
+ CPU_IDLE_PERCENT = "cpu_idle_percent"
22
+ CLIENT_NUMBER = "client_number"
23
+ JOBS_DIR = "jobs"
24
+
25
+ # - `mem_low_threshold`
26
+ # set the min size of available memory the system should have, if not satified, return error. the default is 500M
27
+ #
28
+ # - `cpu_low_threshold`
29
+ # set the min idle percent of the system cpu, if not satified, return error. the default is 3
30
+ #
31
+ # - `max_client_number`
32
+ # set the max client number, if this number reached, new connection will be refused. the default is 1000
33
+
34
+
35
+ CHECK_LOAD_PARAMS = {
36
+ "mem_low_threshold": {
37
+ "load_name": MEM_AVAILABLE,
38
+ "default": 500 * 1024**2, # 500M
39
+ "greater": True,
40
+ },
41
+ "cpu_low_threshold": {
42
+ "load_name": CPU_IDLE_PERCENT,
43
+ "default": 3, # 3%
44
+ "greater": True,
45
+ },
46
+ "max_client_number": {
47
+ "load_name": CLIENT_NUMBER,
48
+ "default": 1000,
49
+ "greater": False,
50
+ },
51
+ }
52
+
53
+
54
+ class SystemOverloadError(Exception):
55
+ pass
56
+
57
+
58
+ class JobError(Exception):
59
+ pass
60
+
61
+
62
+ class InvalidMessageError(JobError):
63
+ pass
64
+
65
+
66
+ class InvalidProgressError(InvalidMessageError):
67
+ pass
68
+
69
+
70
+ class LoadingError(JobError):
71
+ pass
72
+
73
+
74
+ class JobNotInSessionError(JobError):
75
+ pass
76
+
77
+
78
+ class Job(object):
79
+ lock = threading.RLock()
80
+ sessions = {}
81
+
82
+ def __init__(self, channel, msg, func):
83
+ """
84
+ Job description is a string formatted in json, it is used to tell wsjobd what to do,
85
+ it can contain the following fields:
86
+ :param channel:
87
+ report_system_load: a boolean to indicate whether to report system load, if set to true and the progress info
88
+ is a dict, then the system load info will be add to progress dict by key `system_load`, the value is also a dict
89
+ , which contains three fields: `mem_available`, `cpu_idle_percent`, `client_number`.
90
+ cpu_sample_interval: set the interval used by psutil.cpu_times_percent, the default is 0.02
91
+ :param msg:
92
+ check_load: a dict to enable system load check, also to set customed thresholds, the can contain the following fields
93
+ ident : required. the identifier of a job, it is used to prevent from creating the same job repeatedly.
94
+ progress: a dict to set progress reporting related settings, it can contain the following fields:
95
+ - `interval`: the interval of progress reporting, default is 5 seconds
96
+ `key`: the sub field in which the progress info located
97
+ :param func: required. the function of job, it contain module name and function name, seperated by a dot,
98
+ the module shoud in the `jobs` directory.
99
+ """
100
+ self.ident = msg["ident"]
101
+ self.channel = channel
102
+ self.data = msg
103
+ self.worker = func
104
+ self.ctx = {}
105
+ self.err = None
106
+ self.progress_available = threading.Event()
107
+
108
+ if self.ident in self.sessions:
109
+ logger.info(
110
+ "job: %s already exists, created by chennel %s" % (self.ident, repr(self.sessions[self.ident].channel))
111
+ )
112
+ return
113
+ else:
114
+ self.sessions[self.ident] = self
115
+ logger.info(
116
+ ("inserted job: %s to sessions by channel %s, " + "there are %d jobs in sessions now")
117
+ % (self.ident, repr(self.channel), len(self.sessions))
118
+ )
119
+
120
+ self.thread = k3thread.start(target=self.work, args=(), daemon=True)
121
+
122
+ def work(self):
123
+ logger.info("job %s started, the data is: %s" % (self.ident, self.data))
124
+
125
+ try:
126
+ self.worker(self)
127
+ except Exception as e:
128
+ logger.exception("job %s got exception: %s" % (self.ident, repr(e)))
129
+ self.err = e
130
+ finally:
131
+ logger.info("job %s ended" % self.ident)
132
+ self.close()
133
+
134
+ def close(self):
135
+ with self.lock:
136
+ del self.sessions[self.ident]
137
+ logger.info(
138
+ ("removed job: %s from sessions, there are %d " + "jobs in sessions now")
139
+ % (self.ident, len(self.sessions))
140
+ )
141
+
142
+
143
+ def get_or_create_job(channel, msg, func):
144
+ with Job.lock:
145
+ Job(channel, msg, func)
146
+
147
+ job = Job.sessions.get(msg["ident"])
148
+
149
+ return job
150
+
151
+
152
+ def progress_sender(job, channel, interval=5, stat=None):
153
+ stat = stat or (lambda data: data)
154
+ data = job.data
155
+
156
+ i = 10
157
+ try:
158
+ while True:
159
+ # if thread died due to some reason, still send 10 stats
160
+ if not job.thread.is_alive():
161
+ logger.info("job %s died: %s" % (job.ident, repr(job.err)))
162
+ if i == 0:
163
+ channel.ws.close()
164
+ break
165
+ i -= 1
166
+
167
+ logger.info("jod %s on channel %s send progress: %s" % (job.ident, repr(channel), repr(stat(data))))
168
+
169
+ to_send = stat(data)
170
+ if channel.report_system_load and isinstance(to_send, dict):
171
+ to_send["system_load"] = channel.get_system_load()
172
+
173
+ channel.ws.send(k3utfjson.dump(to_send))
174
+
175
+ if job.progress_available.wait(interval):
176
+ job.progress_available.clear()
177
+
178
+ except WebSocketError as e:
179
+ if channel.ws.closed:
180
+ logger.info("the client has closed the connection")
181
+ else:
182
+ logger.exception(
183
+ ("got websocket error when sending progress on" + " channel %s: %s") % (repr(channel), repr(e))
184
+ )
185
+
186
+ except Exception as e:
187
+ logger.exception("got exception when sending progress on channel %s: %s" % (repr(channel), repr(e)))
188
+ channel.ws.close()
189
+
190
+
191
+ class JobdWebSocketApplication(WebSocketApplication):
192
+ jobq_mgr = None
193
+
194
+ def on_open(self):
195
+ logger.info("on open, the channel is: " + repr(self))
196
+ self.ignore_message = False
197
+
198
+ def _parse_request(self, message):
199
+ try:
200
+ try:
201
+ msg = k3utfjson.load(message)
202
+ except Exception:
203
+ raise InvalidMessageError("message is not a vaild json string: %s" % message)
204
+
205
+ self._check_msg(msg)
206
+
207
+ self.report_system_load = msg.get("report_system_load") is True
208
+ self.cpu_sample_interval = msg.get("cpu_sample_interval", 0.02)
209
+
210
+ if not isinstance(self.cpu_sample_interval, (int, float)):
211
+ raise InvalidMessageError("cpu_sample_interval is not a number")
212
+
213
+ check_load = msg.get("check_load")
214
+ if isinstance(check_load, dict):
215
+ self._check_system_load(check_load)
216
+
217
+ self.jobs_dir = msg.get("jobs_dir", JOBS_DIR)
218
+
219
+ self._setup_response(msg)
220
+ return
221
+
222
+ except SystemOverloadError as e:
223
+ logger.info("system overload on chennel %s, %s" % (repr(self), repr(e)))
224
+ self._send_err_and_close(e)
225
+
226
+ except JobError as e:
227
+ logger.info("error on channel %s while handling message, %s" % (repr(self), repr(e)))
228
+ self._send_err_and_close(e)
229
+
230
+ except Exception as e:
231
+ logger.exception(("exception on channel %s while handling " + "message, %s") % (repr(self), repr(e)))
232
+ self._send_err_and_close(e)
233
+
234
+ def on_message(self, message):
235
+ logger.info("on message, the channel is: %s, the message is: %s" % (repr(self), message))
236
+ if self.ignore_message:
237
+ return
238
+
239
+ else:
240
+ self.ignore_message = True
241
+
242
+ self.jobq_mgr.put((self, message))
243
+
244
+ def _send_err_and_close(self, err):
245
+ try:
246
+ err_msg = {
247
+ "err": err.__class__.__name__,
248
+ "val": err.args,
249
+ }
250
+ self.ws.send(k3utfjson.dump(err_msg))
251
+ except Exception as e:
252
+ logger.error(("error on channel %s while sending back error " + "message, %s") % (repr(self), repr(e)))
253
+
254
+ def get_system_load(self):
255
+ return {
256
+ MEM_AVAILABLE: psutil.virtual_memory().available,
257
+ CPU_IDLE_PERCENT: psutil.cpu_times_percent(self.cpu_sample_interval).idle,
258
+ CLIENT_NUMBER: len(self.protocol.server.clients),
259
+ }
260
+
261
+ def _check_system_load(self, check_load):
262
+ system_load = self.get_system_load()
263
+
264
+ for param_name, param_attr in CHECK_LOAD_PARAMS.items():
265
+ param_value = check_load.get(param_name, param_attr["default"])
266
+
267
+ if not isinstance(param_value, (int, float)):
268
+ raise InvalidMessageError("%s is not a number" % param_name)
269
+
270
+ load_name = param_attr["load_name"]
271
+ diff = system_load[load_name] - param_value
272
+
273
+ if not param_attr["greater"]:
274
+ diff = 0 - diff
275
+
276
+ if diff < 0:
277
+ raise SystemOverloadError(
278
+ "%s: %d is %s than: %d"
279
+ % (load_name, system_load[load_name], param_attr["greater"] and "less" or "greater", param_value)
280
+ )
281
+
282
+ def _check_msg(self, msg):
283
+ if not isinstance(msg, dict):
284
+ raise InvalidMessageError("message is not dictionary")
285
+
286
+ if "ident" not in msg:
287
+ raise InvalidMessageError("'ident' is not in message")
288
+
289
+ if "func" not in msg:
290
+ raise InvalidMessageError("'func' is not in message")
291
+
292
+ def _setup_response(self, msg):
293
+ func = self._get_func_by_name(msg)
294
+ channel = self
295
+ job = get_or_create_job(channel, msg, func)
296
+
297
+ if job is None:
298
+ raise JobNotInSessionError("job not in sessions: " + repr(Job.sessions))
299
+
300
+ progress = msg.get("progress", {})
301
+
302
+ if progress in (None, False):
303
+ return
304
+
305
+ if not isinstance(progress, dict):
306
+ raise InvalidProgressError("the progress in message is not a dictionary")
307
+
308
+ interval = progress.get("interval", 5)
309
+ progress_key = progress.get("key")
310
+
311
+ def get_progress(r):
312
+ if progress_key is None:
313
+ return r
314
+ return r.get(progress_key)
315
+
316
+ k3thread.start(target=progress_sender, args=(job, channel, interval, get_progress), daemon=True)
317
+
318
+ def _get_func_by_name(self, msg):
319
+ mod_func = self.jobs_dir.split("/") + msg["func"].split(".")
320
+ mod_path = ".".join(mod_func[:-1])
321
+ func_name = mod_func[-1]
322
+
323
+ try:
324
+ mod = __import__(mod_path)
325
+ except (ImportError, SyntaxError) as e:
326
+ raise LoadingError("failed to import %s: %s" % (mod_path, repr(e)))
327
+
328
+ for mod_name in mod_path.split(".")[1:]:
329
+ mod = getattr(mod, mod_name)
330
+
331
+ logger.info("mod imported from: " + repr(mod.__file__))
332
+
333
+ try:
334
+ func = getattr(mod, func_name)
335
+ except AttributeError:
336
+ raise LoadingError("function not found: " + repr(func_name))
337
+
338
+ return func
339
+
340
+ def on_close(self, reason):
341
+ logger.info("on close, the channel is: " + repr(self))
342
+
343
+
344
+ def _parse_request(args):
345
+ app, msg = args
346
+ app._parse_request(msg)
347
+
348
+
349
+ def run(ip="127.0.0.1", port=63482, jobq_thread_count=10):
350
+ JobdWebSocketApplication.jobq_mgr = k3jobq.JobManager([(_parse_request, jobq_thread_count)])
351
+
352
+ WebSocketServer(
353
+ (ip, port),
354
+ Resource(OrderedDict({"/": JobdWebSocketApplication})),
355
+ ).serve_forever()