Merge lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing into lp:adt-cloud-worker
- enable-testing
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Thomi Richards |
Approved revision: | 13 |
Merged at revision: | 10 |
Proposed branch: | lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing |
Merge into: | lp:adt-cloud-worker |
Diff against target: |
557 lines (+344/-163) 6 files modified
.bzrignore (+1/-0) README.rst (+12/-0) adt-cloud-worker.py (+1/-162) adt_cloud_worker/__init__.py (+207/-0) adt_cloud_worker/tests/test_cloud_worker.py (+122/-0) setup.py (+1/-1) |
To merge this branch: | bzr merge lp:~canonical-ci-engineering/adt-cloud-worker/enable-testing |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Celso Providelo (community) | Approve | ||
Review via email: mp+251686@code.launchpad.net |
This proposal supersedes a proposal from 2015-03-03.
Commit message
Add tests, refactor code.
Description of the change
This branch makes several changes, all with an eye to adding some tests to this service:
* Move the bulk of the code into an adt_cloud_worker package, so it can be imported.
* Create the 'tests' sub-package, and create a file with a few simple tests.
* Refactor code which can easily be written in a functional style out of the main body of the code so it can be tested.
* Update README with instructions on how to run the tests.
While this can land right now, I still want to add some system-level tests to ensure that the service actually does something useful!
- 11. By Thomi Richards
-
Merge code that uses pip to get dependencies.
- 12. By Thomi Richards
-
Merged trunk.
- 13. By Thomi Richards
-
Fix up code from review.
Celso Providelo (cprov) : | # |
Preview Diff
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2015-03-04 19:28:28 +0000 |
3 | +++ .bzrignore 2015-03-04 20:24:52 +0000 |
4 | @@ -1,1 +1,2 @@ |
5 | +adt_cloud_worker.egg-info |
6 | ve |
7 | |
8 | === modified file 'README.rst' |
9 | --- README.rst 2015-03-04 19:38:23 +0000 |
10 | +++ README.rst 2015-03-04 20:24:52 +0000 |
11 | @@ -42,6 +42,18 @@ |
12 | $ python setup.py develop |
13 | |
14 | |
15 | + |
16 | +Run the tests! |
17 | +============== |
18 | + |
19 | +Install dependencies:: |
20 | + |
21 | + $ pip install -r test_requirements.txt |
22 | + |
23 | +Run those tests - with vigour!:: |
24 | + |
25 | + $ python setup.py test |
26 | + |
27 | Run the Service |
28 | =============== |
29 | |
30 | |
31 | === modified file 'adt-cloud-worker.py' |
32 | --- adt-cloud-worker.py 2015-03-03 19:03:34 +0000 |
33 | +++ adt-cloud-worker.py 2015-03-04 20:24:52 +0000 |
34 | @@ -19,168 +19,7 @@ |
35 | |
36 | """Main entry point for adt-cloud-worker.""" |
37 | |
38 | - |
39 | -from __future__ import print_function |
40 | - |
41 | -import argparse |
42 | -import configparser |
43 | -import kombu |
44 | -from kombu.log import get_logger |
45 | -from kombu.mixins import ConsumerMixin |
46 | -from kombu.utils.debug import setup_logging |
47 | -import os |
48 | -import shutil |
49 | -import subprocess |
50 | -from swiftclient import client |
51 | -import tarfile |
52 | -import tempfile |
53 | - |
54 | -logger = get_logger(__name__) |
55 | - |
56 | - |
57 | -class AdtNovaWorker(ConsumerMixin): |
58 | - |
59 | - def __init__(self, connection, config): |
60 | - self.connection = connection |
61 | - self.config = config |
62 | - self.name = self.config.get('adt', 'name') |
63 | - |
64 | - def get_consumers(self, Consumer, channel): |
65 | - """Return consumers instances for all configured queues.""" |
66 | - exchange = kombu.Exchange("adt.exchange", type="topic") |
67 | - queues = [ |
68 | - kombu.Queue( |
69 | - 'adt.requests.{}'.format(tag), exchange, routing_key=tag) |
70 | - for tag in self.config.get('adt', 'tags').split() |
71 | - ] |
72 | - return [Consumer(queues=queues, callbacks=[self.process])] |
73 | - |
74 | - def process(self, body, message): |
75 | - """Process incomming test request. |
76 | - |
77 | - Run requested tests and posts results to the 'adt_results' queue |
78 | - for later checking. |
79 | - """ |
80 | - logger.info('Got: {}'.format(body)) |
81 | - # TODO: body validation |
82 | - body['worker'] = self.name |
83 | - # Run requested tests safely. |
84 | - try: |
85 | - # Create and temporary directory for storing adt results. |
86 | - result_dir = tempfile.mkdtemp( |
87 | - prefix='adt-{}-{}'.format(self.name, body['request_id'])) |
88 | - # Run `adt-run`. |
89 | - adt_kwargs = body.copy() |
90 | - adt_kwargs['result_dir'] = result_dir |
91 | - body['exit_code'] = self.run_adt(**adt_kwargs) |
92 | - # Compress the results directory in a temporary file. |
93 | - _unused, tarball_path = tempfile.mkstemp() |
94 | - with tarfile.open(tarball_path, "w:gz") as tar: |
95 | - for entry in os.listdir(result_dir): |
96 | - tar.add(os.path.join(result_dir, entry), arcname=entry) |
97 | - # Upload the results tarball to swift with a temporary name |
98 | - # 'adt-<req_id>/results.tgz.tmp', result checker will rename |
99 | - # if after verification. |
100 | - swift_client = client.Connection( |
101 | - authurl=self.config.get('nova', 'os_auth_url'), |
102 | - user=self.config.get('nova', 'os_username'), |
103 | - key=self.config.get('nova', 'os_password'), |
104 | - os_options={ |
105 | - 'tenant_name': self.config.get('nova', 'os_tenant_name'), |
106 | - 'region_name': self.config.get('nova', 'os_region_name'), |
107 | - }, auth_version=( |
108 | - '/v1.' in self.config.get('nova','os_auth_url') and '1.0' |
109 | - or '2.0')) |
110 | - container_name = 'adt-{}'.format(body['request_id']) |
111 | - swift_client.put_container(container_name) |
112 | - with open(tarball_path) as fd: |
113 | - swift_client.put_object( |
114 | - container_name, obj='results.tgz.tmp', contents=fd, |
115 | - content_type='application/x-compressed') |
116 | - except Exception as e: |
117 | - # Unexpected failures ... |
118 | - logger.error(e, exc_info=True) |
119 | - body['exit_code'] = '100' |
120 | - finally: |
121 | - # The post results |
122 | - # TODO: send error logging to result-checker in the message |
123 | - queue = self.connection.SimpleQueue('adt.results') |
124 | - queue.put(body) |
125 | - queue.close() |
126 | - # Drop the result directory and result tarball. |
127 | - shutil.rmtree(result_dir) |
128 | - os.remove(tarball_path) |
129 | - message.ack() |
130 | - logger.info('Done!') |
131 | - |
132 | - def run_adt(self, **kwargs): |
133 | - """Run adt-run according to the given request parameters. |
134 | - |
135 | - Always resets the environment with configured nova variables. |
136 | - |
137 | - Returns the exit code from adt-run. |
138 | - """ |
139 | - # TODO: We probably want something more clever here: |
140 | - |
141 | - # what information do we need from the message? |
142 | - adt_run_args = [ |
143 | - '--apt-source', kwargs['package_name'], |
144 | - '--output-dir', kwargs['result_dir'], |
145 | - ] |
146 | - if 'apt_pocket' in kwargs: |
147 | - adt_run_args += [ |
148 | - '--apt-pocket', kwargs['apt_pocket'], |
149 | - '--apt-upgrade', |
150 | - ] |
151 | - adt_ssh_nova_args = [ |
152 | - '--', |
153 | - '--flavor', kwargs['nova_flavor'], |
154 | - '--image', kwargs['nova_image'], |
155 | - ] |
156 | - |
157 | - arguments = ( |
158 | - adt_run_args + |
159 | - ['---', 'ssh', '-s', 'nova'] + |
160 | - adt_ssh_nova_args + |
161 | - self.config.get('nova', 'extra_args').split() |
162 | - ) |
163 | - |
164 | - # Setup nova environment variables based on the configuration file. |
165 | - for k, v in self.config.items('nova'): |
166 | - if not k.startswith('os_'): |
167 | - continue |
168 | - os.environ[k.upper()] = str(v) |
169 | - |
170 | - try: |
171 | - subprocess.check_call(['adt-run'] + arguments) |
172 | - except subprocess.CalledProcessError as e: |
173 | - # log? |
174 | - # TODO: filter log content to avoid leaking cloud credentials. |
175 | - return e.returncode |
176 | - return 0 |
177 | - |
178 | - |
179 | -def main(): |
180 | - setup_logging(loglevel='DEBUG', loggers=['']) |
181 | - |
182 | - parser = argparse.ArgumentParser( |
183 | - description='ADT cloud worker ...') |
184 | - parser.add_argument('-c', '--conf', default='.adt-service.conf', |
185 | - help='Configuration file path') |
186 | - args = parser.parse_args() |
187 | - |
188 | - # Load configuration options. |
189 | - config = configparser.ConfigParser() |
190 | - config.read(args.conf) |
191 | - amqp_uris = config.get('amqp', 'uris').split() |
192 | - |
193 | - with kombu.Connection(amqp_uris) as conn: |
194 | - try: |
195 | - worker = AdtNovaWorker(conn, config) |
196 | - worker.run() |
197 | - except KeyboardInterrupt: |
198 | - print('Bye!') |
199 | - |
200 | +from adt_cloud_worker import main |
201 | |
202 | if __name__ == '__main__': |
203 | main() |
204 | |
205 | === added directory 'adt_cloud_worker' |
206 | === added file 'adt_cloud_worker/__init__.py' |
207 | --- adt_cloud_worker/__init__.py 1970-01-01 00:00:00 +0000 |
208 | +++ adt_cloud_worker/__init__.py 2015-03-04 20:24:52 +0000 |
209 | @@ -0,0 +1,207 @@ |
210 | +# adt-cloud-worker |
211 | +# Copyright (C) 2015 Canonical |
212 | +# |
213 | +# This program is free software: you can redistribute it and/or modify |
214 | +# it under the terms of the GNU General Public License as published by |
215 | +# the Free Software Foundation, either version 3 of the License, or |
216 | +# (at your option) any later version. |
217 | +# |
218 | +# This program is distributed in the hope that it will be useful, |
219 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
220 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
221 | +# GNU General Public License for more details. |
222 | +# |
223 | +# You should have received a copy of the GNU General Public License |
224 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
225 | +# |
226 | + |
227 | +"""ADT Cloud worker functional code.""" |
228 | + |
229 | + |
230 | +from __future__ import print_function |
231 | + |
232 | +import argparse |
233 | +import configparser |
234 | +import kombu |
235 | +from kombu.log import get_logger |
236 | +from kombu.mixins import ConsumerMixin |
237 | +from kombu.utils.debug import setup_logging |
238 | +import os |
239 | +import shutil |
240 | +import subprocess |
241 | +from swiftclient import client |
242 | +import tarfile |
243 | +import tempfile |
244 | + |
245 | +logger = get_logger(__name__) |
246 | + |
247 | + |
248 | +class AdtNovaWorker(ConsumerMixin): |
249 | + |
250 | + def __init__(self, connection, config): |
251 | + self.connection = connection |
252 | + self.config = config |
253 | + # Set the nova configuration env vars: |
254 | + _set_nova_environment_variables(self.config.items('nova')) |
255 | + self.name = self.config.get('adt', 'name') |
256 | + |
257 | + def get_consumers(self, Consumer, channel): |
258 | + """Return consumers instances for all configured queues.""" |
259 | + exchange = kombu.Exchange("adt.exchange", type="topic") |
260 | + queues = [ |
261 | + kombu.Queue( |
262 | + 'adt.requests.{}'.format(tag), exchange, routing_key=tag) |
263 | + for tag in self.config.get('adt', 'tags').split() |
264 | + ] |
265 | + return [Consumer(queues=queues, callbacks=[self.process])] |
266 | + |
267 | + def process(self, body, message): |
268 | + """Process incomming test request. |
269 | + |
270 | + Run requested tests and posts results to the 'adt_results' queue |
271 | + for later checking. |
272 | + """ |
273 | + logger.info('Got: {}'.format(body)) |
274 | + # TODO: body validation |
275 | + body['worker'] = self.name |
276 | + # Run requested tests safely. |
277 | + try: |
278 | + # Create and temporary directory for storing adt results. |
279 | + result_dir = tempfile.mkdtemp( |
280 | + prefix='adt-{}-{}'.format(self.name, body['request_id'])) |
281 | + # Run `adt-run`. |
282 | + adt_kwargs = body.copy() |
283 | + adt_kwargs['result_dir'] = result_dir |
284 | + adt_kwargs['nova_extra_args'] = self.config.get('nova', 'extra_args').split() |
285 | + arguments = _make_adt_argument_list(adt_kwargs) |
286 | + |
287 | + body['exit_code'] = run_adt(arguments) |
288 | + # Compress the results directory in a temporary file. |
289 | + _unused, tarball_path = tempfile.mkstemp() |
290 | + with tarfile.open(tarball_path, "w:gz") as tar: |
291 | + for entry in os.listdir(result_dir): |
292 | + tar.add(os.path.join(result_dir, entry), arcname=entry) |
293 | + # Upload the results tarball to swift with a temporary name |
294 | + # 'adt-<req_id>/results.tgz.tmp', result checker will rename |
295 | + # if after verification. |
296 | + swift_client = client.Connection( |
297 | + authurl=self.config.get('nova', 'os_auth_url'), |
298 | + user=self.config.get('nova', 'os_username'), |
299 | + key=self.config.get('nova', 'os_password'), |
300 | + os_options={ |
301 | + 'tenant_name': self.config.get('nova', 'os_tenant_name'), |
302 | + 'region_name': self.config.get('nova', 'os_region_name'), |
303 | + }, auth_version=( |
304 | + '/v1.' in self.config.get('nova','os_auth_url') and '1.0' |
305 | + or '2.0')) |
306 | + container_name = 'adt-{}'.format(body['request_id']) |
307 | + swift_client.put_container(container_name) |
308 | + with open(tarball_path) as fd: |
309 | + swift_client.put_object( |
310 | + container_name, obj='results.tgz.tmp', contents=fd, |
311 | + content_type='application/x-compressed') |
312 | + except Exception as e: |
313 | + # Unexpected failures ... |
314 | + logger.error(e, exc_info=True) |
315 | + body['exit_code'] = '100' |
316 | + finally: |
317 | + # The post results |
318 | + # TODO: send error logging to result-checker in the message |
319 | + queue = self.connection.SimpleQueue('adt.results') |
320 | + queue.put(body) |
321 | + queue.close() |
322 | + # Drop the result directory and result tarball. |
323 | + shutil.rmtree(result_dir) |
324 | + os.remove(tarball_path) |
325 | + message.ack() |
326 | + logger.info('Done!') |
327 | + |
328 | + |
329 | +def run_adt(arguments): |
330 | + """Run adt-run according to the given request parameters. |
331 | + |
332 | + Always resets the environment with configured nova variables. |
333 | + |
334 | + Returns the exit code from adt-run. |
335 | + """ |
336 | + # TODO: We probably want something more clever here: |
337 | + try: |
338 | + subprocess.check_call(['adt-run'] + arguments) |
339 | + except subprocess.CalledProcessError as e: |
340 | + # log? |
341 | + # TODO: filter log content to avoid leaking cloud credentials. |
342 | + return e.returncode |
343 | + return 0 |
344 | + |
345 | + |
346 | +def _make_adt_argument_list(request_configuration): |
347 | + """Generate the argument list to adt-run, given the |
348 | + requestion_configuration dictionary. |
349 | + |
350 | + :returns: A tuple of arguments. |
351 | + """ |
352 | + adt_run_args = [ |
353 | + '--apt-source', request_configuration['package_name'], |
354 | + '--output-dir', request_configuration['result_dir'], |
355 | + ] |
356 | + if 'apt_pocket' in request_configuration: |
357 | + adt_run_args += [ |
358 | + '--apt-pocket', request_configuration['apt_pocket'], |
359 | + '--apt-upgrade', |
360 | + ] |
361 | + adt_ssh_nova_args = [ |
362 | + '--', |
363 | + '--flavor', request_configuration['nova_flavor'], |
364 | + '--image', request_configuration['nova_image'], |
365 | + ] |
366 | + if 'nova_extra_args' in request_configuration: |
367 | + adt_ssh_nova_args += request_configuration['nova_extra_args'] |
368 | + |
369 | + |
370 | + return ( |
371 | + adt_run_args + |
372 | + ['---', 'ssh', '-s', 'nova'] + |
373 | + adt_ssh_nova_args |
374 | + ) |
375 | + |
376 | + |
377 | +def _set_nova_environment_variables(nova_config): |
378 | + """Set environment variables for nova configuration. |
379 | + |
380 | + 'nova_config' will be read, and any keys that start with 'os_' will be |
381 | + set as environment variables. |
382 | + |
383 | + 'nova_config' must be an iterable of (key, value) pairs. |
384 | + |
385 | + """ |
386 | + # Setup nova environment variables based on the configuration file. |
387 | + for k, v in nova_config: |
388 | + if not k.startswith('os_'): |
389 | + continue |
390 | + os.environ[k.upper()] = str(v) |
391 | + |
392 | + |
393 | +def main(): |
394 | + setup_logging(loglevel='DEBUG', loggers=['']) |
395 | + |
396 | + parser = argparse.ArgumentParser( |
397 | + description='ADT cloud worker ...') |
398 | + parser.add_argument('-c', '--conf', default='.adt-service.conf', |
399 | + help='Configuration file path') |
400 | + args = parser.parse_args() |
401 | + |
402 | + # Load configuration options. |
403 | + config = configparser.ConfigParser() |
404 | + config.read(args.conf) |
405 | + amqp_uris = config.get('amqp', 'uris').split() |
406 | + |
407 | + with kombu.Connection(amqp_uris) as conn: |
408 | + try: |
409 | + worker = AdtNovaWorker(conn, config) |
410 | + worker.run() |
411 | + except KeyboardInterrupt: |
412 | + print('Bye!') |
413 | + |
414 | + |
415 | +if __name__ == '__main__': |
416 | + main() |
417 | |
418 | === added directory 'adt_cloud_worker/tests' |
419 | === added file 'adt_cloud_worker/tests/__init__.py' |
420 | === added file 'adt_cloud_worker/tests/test_cloud_worker.py' |
421 | --- adt_cloud_worker/tests/test_cloud_worker.py 1970-01-01 00:00:00 +0000 |
422 | +++ adt_cloud_worker/tests/test_cloud_worker.py 2015-03-04 20:24:52 +0000 |
423 | @@ -0,0 +1,122 @@ |
424 | +# adt-cloud-worker |
425 | +# Copyright (C) 2015 Canonical |
426 | +# |
427 | +# This program is free software: you can redistribute it and/or modify |
428 | +# it under the terms of the GNU General Public License as published by |
429 | +# the Free Software Foundation, either version 3 of the License, or |
430 | +# (at your option) any later version. |
431 | +# |
432 | +# This program is distributed in the hope that it will be useful, |
433 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
434 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
435 | +# GNU General Public License for more details. |
436 | +# |
437 | +# You should have received a copy of the GNU General Public License |
438 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
439 | +# |
440 | + |
441 | +import os |
442 | +from fixtures import Fixture |
443 | +import testtools |
444 | + |
445 | +from adt_cloud_worker import ( |
446 | + _make_adt_argument_list, |
447 | + _set_nova_environment_variables, |
448 | +) |
449 | + |
450 | +class TestADTArgumentGeneration(testtools.TestCase): |
451 | + |
452 | + def createRequiredAdtRequestArgumets(self, **kwargs): |
453 | + """Generate a dict that contains all the required parts of the |
454 | + adt-cloud-worker request. |
455 | + |
456 | + You may pass in additional arguments to customise the contents of the |
457 | + dictionary. |
458 | + """ |
459 | + defaults = { |
460 | + 'package_name': "some_package_name", |
461 | + 'result_dir': '/tmp/some/dir', |
462 | + 'nova_flavor': 'some-nova-flavour', |
463 | + 'nova_image': 'some-nova-image', |
464 | + } |
465 | + defaults.update(kwargs) |
466 | + return defaults |
467 | + |
468 | + def test_required_arguments(self): |
469 | + observed = _make_adt_argument_list( |
470 | + self.createRequiredAdtRequestArgumets() |
471 | + ) |
472 | + self.assertEqual( |
473 | + [ |
474 | + '--apt-source', 'some_package_name', |
475 | + '--output-dir', '/tmp/some/dir', |
476 | + '---', |
477 | + 'ssh', |
478 | + '-s', 'nova', |
479 | + '--', |
480 | + '--flavor', 'some-nova-flavour', |
481 | + '--image', 'some-nova-image', |
482 | + ], |
483 | + observed) |
484 | + |
485 | + def test_includes_pocket_args(self): |
486 | + observed = _make_adt_argument_list( |
487 | + self.createRequiredAdtRequestArgumets(apt_pocket='foo') |
488 | + ) |
489 | + self.assertEqual( |
490 | + [ |
491 | + '--apt-source', 'some_package_name', |
492 | + '--output-dir', '/tmp/some/dir', |
493 | + '--apt-pocket', 'foo', '--apt-upgrade', |
494 | + '---', |
495 | + 'ssh', |
496 | + '-s', 'nova', |
497 | + '--', |
498 | + '--flavor', 'some-nova-flavour', |
499 | + '--image', 'some-nova-image', |
500 | + ], |
501 | + observed) |
502 | + |
503 | + |
504 | +class RestoreEnvironmentVariableFixture(Fixture): |
505 | + |
506 | + def __init__(self, env_var_name): |
507 | + self.name = env_var_name |
508 | + |
509 | + def setUp(self): |
510 | + # TODO: Make this python3-ised |
511 | + super(RestoreEnvironmentVariableFixture, self).setUp() |
512 | + old_value = os.environ.get(self.name) |
513 | + self.addCleanup(self._restore_value, old_value) |
514 | + |
515 | + def _restore_value(self, value): |
516 | + if value is None: |
517 | + try: |
518 | + del os.environ[self.name] |
519 | + except KeyError: |
520 | + # key was never set, nbothing to do: |
521 | + pass |
522 | + else: |
523 | + os.environ[self.name] = value |
524 | + |
525 | + |
526 | +class NovaEnvironmentVariableTests(testtools.TestCase): |
527 | + |
528 | + def test_does_nothing_with_empty_dict(self): |
529 | + _set_nova_environment_variables({}) |
530 | + |
531 | + def test_sets__os_keys(self): |
532 | + with RestoreEnvironmentVariableFixture('OS_FOO'): |
533 | + _set_nova_environment_variables([('os_foo', 'bar')]) |
534 | + self.assertEqual('bar', os.getenv('OS_FOO')) |
535 | + |
536 | + def test_ignores_non_os_keys(self): |
537 | + with RestoreEnvironmentVariableFixture('FOO'): |
538 | + os.environ['FOO'] = 'bar' |
539 | + _set_nova_environment_variables([('foo', 'baz')]) |
540 | + self.assertEqual('bar', os.getenv('FOO')) |
541 | + |
542 | + def test_stringifies_values(self): |
543 | + with RestoreEnvironmentVariableFixture('OS_FOO'): |
544 | + _set_nova_environment_variables([('os_foo', 123)]) |
545 | + self.assertEqual('123', os.getenv('OS_FOO')) |
546 | |
547 | === modified file 'setup.py' |
548 | --- setup.py 2015-03-03 00:30:40 +0000 |
549 | +++ setup.py 2015-03-04 20:24:52 +0000 |
550 | @@ -37,6 +37,6 @@ |
551 | url='https://launchpad.net/adt-cloud-worker', |
552 | license='GPLv3', |
553 | packages=find_packages(), |
554 | - # test_suite='autopilot.tests', |
555 | + test_suite='adt_cloud_worker.tests', |
556 | scripts=['adt-cloud-worker.py'] |
557 | ) |
Thomi,
Thanks for working on tests for the project. I have no objections to the code refactoring, it is still small e reads nicely.
There is the py3 deps discussion on going, but this change does really affects it, so my vote is for landing once you consider my inline comments (not blocking, though).