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
1diff --git a/checkbox_support/scripts/zapper_proxy.py b/checkbox_support/scripts/zapper_proxy.py
2new file mode 100644
3index 0000000..afd998f
4--- /dev/null
5+++ b/checkbox_support/scripts/zapper_proxy.py
6@@ -0,0 +1,141 @@
7+# Copyright 2022 Canonical Ltd.
8+# Written by:
9+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
10+#
11+# This is free software: you can redistribute it and/or modify
12+# it under the terms of the GNU General Public License version 3,
13+# as published by the Free Software Foundation.
14+#
15+# This file is distributed in the hope that it will be useful,
16+# but WITHOUT ANY WARRANTY; without even the implied warranty of
17+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+# GNU General Public License for more details.
19+#
20+# You should have received a copy of the GNU General Public License
21+# along with this file. If not, see <http://www.gnu.org/licenses/>.
22+"""
23+This program acts as a proxy to Zapper Hardware.
24+
25+It uses internal Zapper Control RPyC API. Should this API change, additional
26+ZapperControl classes should be added here.
27+"""
28+import os
29+
30+from abc import abstractmethod
31+from importlib import import_module
32+
33+from checkbox_support.vendor.auto_argparse import AutoArgParser
34+
35+
36+# Zapper Control is expected to change its RPC API. Following adapter classes
37+# serve the purpose of maintaining funcionality throughout the RPC API version
38+# changes.
39+
40+class IZapperControl:
41+ """Interface to the Zapper Control."""
42+ @abstractmethod
43+ def usb_get_state(self, address):
44+ """
45+ Get state of USBMUX addon.
46+ Note that the address string is used as-is without any checks done on
47+ this side. Any validation and use of that param will be done on the
48+ remote end.
49+
50+ :param str address: address of USBMUX to get state from.
51+ :return str: state of the USBMUX addon.
52+ """
53+
54+ @abstractmethod
55+ def usb_set_state(self, address, state):
56+ """
57+ Set state of USBMUX addon.
58+ Note that both arguments are used as-is without any checks done on
59+ this side. Any validation and use of those params will be done on the
60+ remote end.
61+
62+ :param str address: address of USBMUX the state should be set on.
63+ :param str state: state to set on the USBMUX addon
64+ """
65+
66+
67+class ZapperControlV1(IZapperControl):
68+ """Control Zapper via RPyC using v1 of the API."""
69+
70+ def __init__(self, connection):
71+ self._conn = connection
72+
73+ def usb_get_state(self, address):
74+ ret = []
75+ success = self._conn.root.zombiemux_get_state(address, ret)
76+ if not success:
77+ raise SystemExit(
78+ "Failed to get state for address {}.".format(address))
79+ print("State for address {} is {}".format(address, ret[0]))
80+
81+ def usb_set_state(self, address, state):
82+ success = self._conn.root.zombiemux_get_state(address, state)
83+ if not success:
84+ raise SystemExit(
85+ "Failed to set '{}' state for address {}.".format(
86+ state, address))
87+ print("State '{}' set for the address {}.".format(state, address))
88+
89+
90+def main():
91+ """Entry point."""
92+ # to not make checkbox-support dependant on RPyC let's use one available in
93+ # the system, and if it's not available let's try loading one provided by
94+ # Checkbox. Real world usecase would be to run this program from within
95+ # Checkbox, so chances for not finding it are pretty slim.
96+ try:
97+ rpyc = import_module('rpyc')
98+ except ImportError:
99+ try:
100+ rpyc = import_module('plainbox.vendor.rpyc')
101+ except ImportError as exc:
102+ raise SystemExit(
103+ "RPyC not found. Neither from sys nor from Checkbox") from exc
104+
105+ # generate argparse from the interface of Zapper Control
106+ parser = AutoArgParser(cls=IZapperControl)
107+ parser.add_argument(
108+ '--host', default=os.environ.get('ZAPPER_ADDRESS'),
109+ help=("Address of Zapper to connect to. If not supplied, "
110+ "ZAPPER_ADDRESS environment variable will be used.")
111+ )
112+ # turn Namespace into a normal dict
113+ args = parser.parse_args()
114+ # popping elements from the dict, so at the end the end the right method
115+ # is called with only the item that are expected
116+ host = args.host
117+ if host is None:
118+ raise SystemExit(
119+ "You have to provide Zapper host, either via '--host' or via "
120+ "ZAPPER_ADDRESS environment variable")
121+ # connect and see which protol version should be used
122+ conn = rpyc.connect(
123+ host, 60000, config={"allow_all_attrs": True})
124+ try:
125+ version = conn.root.get_api_version()
126+ except AttributeError:
127+ # there was no "get_api_version" method on Zapper
128+ # so this means the oldest version possible - 1
129+ version = 1
130+ # the following mapping could be replaced by something that generates
131+ # a class name and tries looking it up in this module, but using dict
132+ # feels simpler due to explicitness and can include classes defined in
133+ # some other modules
134+ control_cls = {
135+ 1: ZapperControlV1,
136+ }.get(version, None)
137+ if control_cls is None:
138+ raise SystemExit((
139+ "Zapper host returned unknown Zapper Control Version: {ver}\n"
140+ "Implement ZapperControlV{ver} in checkbox_support!"
141+ ).format(ver=version))
142+ zapper_control = control_cls(conn)
143+ parser.run(zapper_control)
144+
145+
146+if __name__ == '__main__':
147+ main()
148diff --git a/checkbox_support/vendor/auto_argparse.py b/checkbox_support/vendor/auto_argparse.py
149new file mode 100644
150index 0000000..ca6074e
151--- /dev/null
152+++ b/checkbox_support/vendor/auto_argparse.py
153@@ -0,0 +1,115 @@
154+# Copyright (C) 2022 Canonical Ltd.
155+#
156+# Authors:
157+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
158+#
159+# This program is free software: you can redistribute it and/or modify
160+# it under the terms of the GNU General Public License version 3,
161+# as published by the Free Software Foundation.
162+#
163+# This program is distributed in the hope that it will be useful,
164+# but WITHOUT ANY WARRANTY; without even the implied warranty of
165+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
166+# GNU General Public License for more details.
167+#
168+# You should have received a copy of the GNU General Public License
169+# along with this program. If not, see <http://www.gnu.org/licenses/>.
170+"""
171+An extended ArgParser that automatically creates subparsers.
172+"""
173+
174+import argparse
175+import inspect
176+import re
177+
178+def _parse_docstring(doc):
179+ # those are the defaults
180+ param_descs = {}
181+ return_desc = ''
182+ info = ''
183+ if not doc:
184+ return info, param_descs, return_desc
185+ lines = doc.strip().splitlines()
186+ # let's extract the text preceeding param specification
187+ # the first line that explains a param or a return value
188+ # means the end of general info
189+ info_lines = []
190+ for lineno, line in enumerate(lines):
191+ if line.startswith((':param', ':return')):
192+ # we're at the param description section
193+ # let's delete what we already read
194+ lines = lines[lineno:]
195+ break
196+ info_lines.append(line)
197+ info = '\n'.join(info_lines)
198+ # the rest of lines should now contain information about params or the
199+ # return value
200+ param_re = re.compile(
201+ r":param(\s[A-Za-z_][A-Za-z0-9_]*)?\s([A-Za-z_][A-Za-z0-9_]*):(.*)")
202+ return_re = re.compile(
203+ r":returns(\s[A-Za-z_][A-Za-z0-9_]*)?:(.*)")
204+ for line in lines:
205+ param_match = param_re.match(line)
206+ if param_match:
207+ param_type, param_name, description = param_match.groups()
208+ param_type = {
209+ 'str': str, 'int': int, 'float': float, 'bool': bool
210+ }.get(param_type.strip() if param_type else None)
211+ param_descs[param_name] = param_type, description.strip()
212+ continue
213+ return_match = return_re.match(line)
214+ if return_match:
215+ return_desc = return_match.groups()[1].strip()
216+ return info, param_descs, return_desc
217+
218+class AutoArgParser(argparse.ArgumentParser):
219+ def __init__(self, *args, cls=None, **kwargs):
220+ if cls is None:
221+ super().__init__(*args, **kwargs)
222+ return
223+ self._cls = cls
224+ self._auto_args = []
225+ self._args_already_parsed = False
226+ cls_doc = (inspect.getdoc(cls) or '').strip()
227+ super().__init__(*args, description=cls_doc, **kwargs)
228+ subparsers = self.add_subparsers(dest='method')
229+ methods = inspect.getmembers(cls, inspect.isroutine)
230+ for name, method in methods:
231+ if name.startswith('__'):
232+ continue
233+ doc = inspect.getdoc(method)
234+ argspec = inspect.getfullargspec(method)
235+ args = argspec.args
236+ # if the function is a classmethod or instance method, let's pop
237+ # the first arg (the bound object)
238+ if args and args[0] in ['self', 'cls']:
239+ args = args[1:]
240+ info, params, return_desc = _parse_docstring(doc)
241+ sub = subparsers.add_parser(method.__name__, help=info)
242+ for arg in args:
243+ self._auto_args.append(arg)
244+ type, help = params.get(arg, (None, None))
245+ sub.add_argument(arg, type=type, help=help)
246+
247+ def parse_args(self, *args, **kwargs):
248+ args = super().parse_args(*args, **kwargs)
249+ self._args_already_parsed = True
250+ self._picked_method = args.method
251+ # turn members of the args namespace into a normal dictionary so it's
252+ # easier later on to use them as kwargs. But let's take only the one
253+ # automatically generated.
254+ self._method_args = {
255+ a: v for a, v in vars(args).items() if a in self._auto_args}
256+ return args
257+
258+
259+ def run(self, obj=None):
260+ if not self._args_already_parsed:
261+ self.parse_args()
262+ if obj is None:
263+ obj = self._cls()
264+ if not self._picked_method:
265+ raise SystemExit(
266+ "No sub-command chosen!\n\n{}".format(self.format_help()))
267+ print("No subcommand chosen")
268+ return getattr(obj, self._picked_method)(**self._method_args)
269diff --git a/setup.py b/setup.py
270index 0c398e6..2d5ea12 100755
271--- a/setup.py
272+++ b/setup.py
273@@ -94,6 +94,8 @@ setup(
274 ("checkbox-support-lsusb="
275 "checkbox_support.scripts.lsusb:main"),
276 ("checkbox-support-parse=checkbox_support.parsers:main"),
277+ ("checkbox-support-zapper-proxy="
278+ "checkbox_support.scripts.zapper_proxy:main"),
279 ],
280 },
281 )

Subscribers

People subscribed via source and target branches