Merge ~kissiel/checkbox-support:zapper-proxy into checkbox-support:master

Proposed by Maciej Kisielewski
Status: Merged
Approved by: Maciej Kisielewski
Approved revision: 860f77c40fe363d464399461f4f15beab27bb588
Merged at revision: 5a6493c40f8d217a98e5405a846fd756bbaad849
Proposed branch: ~kissiel/checkbox-support:zapper-proxy
Merge into: checkbox-support:master
Diff against target: 281 lines (+258/-0)
3 files modified
checkbox_support/scripts/zapper_proxy.py (+141/-0)
checkbox_support/vendor/auto_argparse.py (+115/-0)
setup.py (+2/-0)
Reviewer Review Type Date Requested Status
Paul Larson Approve
Review via email: mp+418805@code.launchpad.net

Description of the change

Add zapper-proxy that lets us talk to zapper from checkbox[-support].

The AutoArgParse may change in the near future, as I still plan to develop some features over the weekend, but it works here beautifully.

To post a comment you must log in.
Revision history for this message
Paul Larson (pwlars) wrote :

<insert usual grumbling about vendorized packages>, otherwise this looks really nice.
Two minor things below.

review: Needs Fixing
Revision history for this message
Paul Larson (pwlars) wrote :

Looks good and takes care of the few concerns I had. +1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/checkbox_support/scripts/zapper_proxy.py b/checkbox_support/scripts/zapper_proxy.py
0new file mode 1006440new file mode 100644
index 0000000..afd998f
--- /dev/null
+++ b/checkbox_support/scripts/zapper_proxy.py
@@ -0,0 +1,141 @@
1# Copyright 2022 Canonical Ltd.
2# Written by:
3# Maciej Kisielewski <maciej.kisielewski@canonical.com>
4#
5# This is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 3,
7# as published by the Free Software Foundation.
8#
9# This file is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this file. If not, see <http://www.gnu.org/licenses/>.
16"""
17This program acts as a proxy to Zapper Hardware.
18
19It uses internal Zapper Control RPyC API. Should this API change, additional
20ZapperControl classes should be added here.
21"""
22import os
23
24from abc import abstractmethod
25from importlib import import_module
26
27from checkbox_support.vendor.auto_argparse import AutoArgParser
28
29
30# Zapper Control is expected to change its RPC API. Following adapter classes
31# serve the purpose of maintaining funcionality throughout the RPC API version
32# changes.
33
34class IZapperControl:
35 """Interface to the Zapper Control."""
36 @abstractmethod
37 def usb_get_state(self, address):
38 """
39 Get state of USBMUX addon.
40 Note that the address string is used as-is without any checks done on
41 this side. Any validation and use of that param will be done on the
42 remote end.
43
44 :param str address: address of USBMUX to get state from.
45 :return str: state of the USBMUX addon.
46 """
47
48 @abstractmethod
49 def usb_set_state(self, address, state):
50 """
51 Set state of USBMUX addon.
52 Note that both arguments are used as-is without any checks done on
53 this side. Any validation and use of those params will be done on the
54 remote end.
55
56 :param str address: address of USBMUX the state should be set on.
57 :param str state: state to set on the USBMUX addon
58 """
59
60
61class ZapperControlV1(IZapperControl):
62 """Control Zapper via RPyC using v1 of the API."""
63
64 def __init__(self, connection):
65 self._conn = connection
66
67 def usb_get_state(self, address):
68 ret = []
69 success = self._conn.root.zombiemux_get_state(address, ret)
70 if not success:
71 raise SystemExit(
72 "Failed to get state for address {}.".format(address))
73 print("State for address {} is {}".format(address, ret[0]))
74
75 def usb_set_state(self, address, state):
76 success = self._conn.root.zombiemux_get_state(address, state)
77 if not success:
78 raise SystemExit(
79 "Failed to set '{}' state for address {}.".format(
80 state, address))
81 print("State '{}' set for the address {}.".format(state, address))
82
83
84def main():
85 """Entry point."""
86 # to not make checkbox-support dependant on RPyC let's use one available in
87 # the system, and if it's not available let's try loading one provided by
88 # Checkbox. Real world usecase would be to run this program from within
89 # Checkbox, so chances for not finding it are pretty slim.
90 try:
91 rpyc = import_module('rpyc')
92 except ImportError:
93 try:
94 rpyc = import_module('plainbox.vendor.rpyc')
95 except ImportError as exc:
96 raise SystemExit(
97 "RPyC not found. Neither from sys nor from Checkbox") from exc
98
99 # generate argparse from the interface of Zapper Control
100 parser = AutoArgParser(cls=IZapperControl)
101 parser.add_argument(
102 '--host', default=os.environ.get('ZAPPER_ADDRESS'),
103 help=("Address of Zapper to connect to. If not supplied, "
104 "ZAPPER_ADDRESS environment variable will be used.")
105 )
106 # turn Namespace into a normal dict
107 args = parser.parse_args()
108 # popping elements from the dict, so at the end the end the right method
109 # is called with only the item that are expected
110 host = args.host
111 if host is None:
112 raise SystemExit(
113 "You have to provide Zapper host, either via '--host' or via "
114 "ZAPPER_ADDRESS environment variable")
115 # connect and see which protol version should be used
116 conn = rpyc.connect(
117 host, 60000, config={"allow_all_attrs": True})
118 try:
119 version = conn.root.get_api_version()
120 except AttributeError:
121 # there was no "get_api_version" method on Zapper
122 # so this means the oldest version possible - 1
123 version = 1
124 # the following mapping could be replaced by something that generates
125 # a class name and tries looking it up in this module, but using dict
126 # feels simpler due to explicitness and can include classes defined in
127 # some other modules
128 control_cls = {
129 1: ZapperControlV1,
130 }.get(version, None)
131 if control_cls is None:
132 raise SystemExit((
133 "Zapper host returned unknown Zapper Control Version: {ver}\n"
134 "Implement ZapperControlV{ver} in checkbox_support!"
135 ).format(ver=version))
136 zapper_control = control_cls(conn)
137 parser.run(zapper_control)
138
139
140if __name__ == '__main__':
141 main()
diff --git a/checkbox_support/vendor/auto_argparse.py b/checkbox_support/vendor/auto_argparse.py
0new file mode 100644142new file mode 100644
index 0000000..ca6074e
--- /dev/null
+++ b/checkbox_support/vendor/auto_argparse.py
@@ -0,0 +1,115 @@
1# Copyright (C) 2022 Canonical Ltd.
2#
3# Authors:
4# Maciej Kisielewski <maciej.kisielewski@canonical.com>
5#
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License version 3,
8# as published by the Free Software Foundation.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program. If not, see <http://www.gnu.org/licenses/>.
17"""
18An extended ArgParser that automatically creates subparsers.
19"""
20
21import argparse
22import inspect
23import re
24
25def _parse_docstring(doc):
26 # those are the defaults
27 param_descs = {}
28 return_desc = ''
29 info = ''
30 if not doc:
31 return info, param_descs, return_desc
32 lines = doc.strip().splitlines()
33 # let's extract the text preceeding param specification
34 # the first line that explains a param or a return value
35 # means the end of general info
36 info_lines = []
37 for lineno, line in enumerate(lines):
38 if line.startswith((':param', ':return')):
39 # we're at the param description section
40 # let's delete what we already read
41 lines = lines[lineno:]
42 break
43 info_lines.append(line)
44 info = '\n'.join(info_lines)
45 # the rest of lines should now contain information about params or the
46 # return value
47 param_re = re.compile(
48 r":param(\s[A-Za-z_][A-Za-z0-9_]*)?\s([A-Za-z_][A-Za-z0-9_]*):(.*)")
49 return_re = re.compile(
50 r":returns(\s[A-Za-z_][A-Za-z0-9_]*)?:(.*)")
51 for line in lines:
52 param_match = param_re.match(line)
53 if param_match:
54 param_type, param_name, description = param_match.groups()
55 param_type = {
56 'str': str, 'int': int, 'float': float, 'bool': bool
57 }.get(param_type.strip() if param_type else None)
58 param_descs[param_name] = param_type, description.strip()
59 continue
60 return_match = return_re.match(line)
61 if return_match:
62 return_desc = return_match.groups()[1].strip()
63 return info, param_descs, return_desc
64
65class AutoArgParser(argparse.ArgumentParser):
66 def __init__(self, *args, cls=None, **kwargs):
67 if cls is None:
68 super().__init__(*args, **kwargs)
69 return
70 self._cls = cls
71 self._auto_args = []
72 self._args_already_parsed = False
73 cls_doc = (inspect.getdoc(cls) or '').strip()
74 super().__init__(*args, description=cls_doc, **kwargs)
75 subparsers = self.add_subparsers(dest='method')
76 methods = inspect.getmembers(cls, inspect.isroutine)
77 for name, method in methods:
78 if name.startswith('__'):
79 continue
80 doc = inspect.getdoc(method)
81 argspec = inspect.getfullargspec(method)
82 args = argspec.args
83 # if the function is a classmethod or instance method, let's pop
84 # the first arg (the bound object)
85 if args and args[0] in ['self', 'cls']:
86 args = args[1:]
87 info, params, return_desc = _parse_docstring(doc)
88 sub = subparsers.add_parser(method.__name__, help=info)
89 for arg in args:
90 self._auto_args.append(arg)
91 type, help = params.get(arg, (None, None))
92 sub.add_argument(arg, type=type, help=help)
93
94 def parse_args(self, *args, **kwargs):
95 args = super().parse_args(*args, **kwargs)
96 self._args_already_parsed = True
97 self._picked_method = args.method
98 # turn members of the args namespace into a normal dictionary so it's
99 # easier later on to use them as kwargs. But let's take only the one
100 # automatically generated.
101 self._method_args = {
102 a: v for a, v in vars(args).items() if a in self._auto_args}
103 return args
104
105
106 def run(self, obj=None):
107 if not self._args_already_parsed:
108 self.parse_args()
109 if obj is None:
110 obj = self._cls()
111 if not self._picked_method:
112 raise SystemExit(
113 "No sub-command chosen!\n\n{}".format(self.format_help()))
114 print("No subcommand chosen")
115 return getattr(obj, self._picked_method)(**self._method_args)
diff --git a/setup.py b/setup.py
index 0c398e6..2d5ea12 100755
--- a/setup.py
+++ b/setup.py
@@ -94,6 +94,8 @@ setup(
94 ("checkbox-support-lsusb="94 ("checkbox-support-lsusb="
95 "checkbox_support.scripts.lsusb:main"),95 "checkbox_support.scripts.lsusb:main"),
96 ("checkbox-support-parse=checkbox_support.parsers:main"),96 ("checkbox-support-parse=checkbox_support.parsers:main"),
97 ("checkbox-support-zapper-proxy="
98 "checkbox_support.scripts.zapper_proxy:main"),
97 ],99 ],
98 },100 },
99)101)

Subscribers

People subscribed via source and target branches