Merge lp:~nuclearbob/autopilot/bug1210260 into lp:autopilot

Proposed by Thomi Richards
Status: Merged
Approved by: Thomi Richards
Approved revision: 466
Merged at revision: 455
Proposed branch: lp:~nuclearbob/autopilot/bug1210260
Merge into: lp:autopilot
Diff against target: 388 lines (+292/-20)
2 files modified
autopilot/introspection/dbus.py (+95/-20)
autopilot/tests/unit/test_introspection_features.py (+197/-0)
To merge this branch: bzr merge lp:~nuclearbob/autopilot/bug1210260
Reviewer Review Type Date Requested Status
Thomi Richards (community) Needs Fixing
PS Jenkins bot continuous-integration Needs Fixing
Max Brustkern (community) Needs Resubmitting
Review via email: mp+208260@code.launchpad.net

Commit message

Autopilot emulators can declare a validate_dbus_object class method that takes a dbus path and state as arguments. (LP:1210260)

Description of the change

Autopilot emulators can declare a validate_dbus_object class method that takes a dbus path and state as arguments. If this function returns True, the emulator will be used. If multiple emulators return True, a ValueError will be raised. The default function preserves the current behavior by matching the dbus name to the class name.

To post a comment you must log in.
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Resubmitted to get a better diff, since trunk has moved on since the MP was created.

Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :
Download full text (7.4 KiB)

Hi Max,

I assume you want a review on this, since you created this MP :)

Here goes:

12 + class_type = _select_emulator(_object_registry[self._id], path, state)

let's not call this '_select_emulator'. How about '_get_proxy_object_class'. While we're changing this line, let's improve the 'class_type' variable name - it's not a class type, it's a class. (the class type would be the metaclass). We can't use 'class' since it's a reserved keyword, so how about calling it 'class_object'?

22 + p<F7>7ath, state = dbus_tuple

ummm... O.0

31 + @classmethod
32 + def validate_dbus_object(cls, path, state):
33 + name = get_classname_from_path(path)
34 + return cls.__name__ == name

This needs a docstring. The docstring needs to describe:
 - What this method is for.
 - How users can override it.
 - What the default implementation does.

43 +def _select_emulator(objects, path, state):

Please change the parameter 'objects' to have a mode descriptive name. 'proxy_class_dictionary' or similar. 'objects' makes it sound like a list or tuple, when it's not.

44 + class_type = None

Same comment as above. I realise that this is all my fault :)

45 + for item_name in objects:
46 + item = objects[item_name]

Two things here. First, let's change thos evariable names to be a bit more informative. Second, use the dictionaries 'items' method if you want to get both keys and values (for k,v in my_dict.items():). However, in this case you only care about the values, so this should work:

for proxy_class in objects.values():

(note: I haven't changed the 'items' name from the previous point. I'm sure you can cope :))

On a further note, this entire function can be written in two lines:

possible_classes = [ c for c in objects.values() if c.validate_dbus_object(path, state) ]
if len(possible_classes) > 1:
    raise ValueError(...)
return possible_classes[0] if possible_classes else None

On a final note for this function, your exception needs more information. If we have more than one proxy class in this situation, we need to be able to debug that. A nice error message should include the classes that are applicable... so perhaps something like:

raise ValueError(
  "More than one custom proxy class matches this state. Possible classes are %s. State is %s. Path is %s" % (
    ','.join([repr(c) for c in possible_classes]),
    repr(state),
    path
  )
)

Also note that the error message really shouldn't use the word 'emulator' in it :)

63 +

In general, please avoid adding whitespace to diffs unless you have to.

109 + def test_class_has_validation_method(self):
110 + self.assertThat(callable(self.Rectangle.validate_dbus_object),
111 + Equals(True))

I don't think it's a good idea to assign 'Rectangle' to the class instance. Instead, nest them in the test class. Also, 'Rectangle' and 'Circle' names don't tell us anything about the classes, so let's use better names. Something like this should work:

class MakeIntrospectionObjectTests(TestCase):

  class DefaultSelector(CustomEmulatorBase):
    pass

  class AlwaysSelected(CustomEmulatorBase):
    @classmethod
    def validate_dbus_object(...): return True

  class NeverSelected(CustomEmulatorBase):
    @classmethod
...

Read more...

review: Needs Fixing
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Please set this back to 'needs review' when you'd like another review.

Thanks

Revision history for this message
Max Brustkern (nuclearbob) wrote :

Should be cleaned up now per Thomi's review.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :
Download full text (6.7 KiB)

Hi,

This is looking much better, thanks for your work. There are still a few issues though.

10 + :rtype: instance of appropriate CustomProxyObject class

You seem to like using the :rtype: field. In general, we haven't used this before in autopilot, so unless there's a good reason to, I'd prefer to encode this information in the :returns: field. For example, this:

9 + :returns: introspection object
10 + :rtype: instance of appropriate CustomProxyObject class

...could more simply be written as:

:returns: A proxy object that derives from DBusIntrospectionObject

...or something similar. If there's a good reason to use :rtype: that I've missed, please let me know.

51 + :type path: string

Same here. This:

50 + :param path: The dbus path of the object to check
51 + :type path: string

should just be:

:param path: A string containing the path of the object to check.

Finally, in general we don't insist on docstrings for private methods / functions, unless their intent is hard to divine from the function / class name (however, if tht's the case then the name probably needs to be changed). I'm not saying *don't* document them, just htat you don't need to go to so much effort to document them. Often a single line docstring will suffice.

Now, on to the tests:

    @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
    @patch('autopilot.introspection.dbus._get_default_proxy_class')
    def test_get_proxy_object_class_from_list(self, gdpc, tcpc):
        """_get_proxy_object_class should return the value of
        _try_custom_proxy_classes if there is one."""
        gdpc.side_effect = Exception(
            'Called _get_default_proxy_class when _try_custom_proxy_classes '
            'returned a class.')
        retval = 'return'
        tcpc.return_value = retval
        class_dict = {'DefaultSelector': self.DefaultSelector}
        default = self.DefaultSelector
        path = '/path/to/DefaultSelector'
        state = {}
        gpoc_return = _get_proxy_object_class(class_dict,
                                              default,
                                              path,
                                              state)
        self.assertThat(gpoc_return, Equals(retval))
        tcpc.assert_called_once_with(class_dict, path, state)

A few things:

First, don't use the side_effects attribute on the mock to indicate a failure. First, it will produce an error, not a failure, and second, it's not particularly expressive.

Second, when you just want some placeholder text, it's better to use 'self.getUniqueString()', since it doesn't add a literal to the test that distracts from the test intent.

Third, since this is a unit test, this should fail in exactly one way. We often ignore that rule, but in this case, obeying it helps make this test a lot clearer. The intent of this test is that the '_get_proxy_object_class' should call '_try_custom_proxy_classes' first, not not call anything else if it succeeds. We can pair down the test until it's just testing that:

Here's how I'd do it:

    @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
    @patch('autopilot.introspection.dbus._get_default_proxy_cl...

Read more...

review: Needs Fixing
Revision history for this message
Max Brustkern (nuclearbob) wrote :

More updates that should address the latest comments, I think.

review: Needs Resubmitting
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

Hi,

This looks great - with one teensy exception - please change these:

 :raises: ValueError if more than one class in the dict matches

to be:

 :raises ValueError: if more than one class in the dict matches

i.e.- as we discussed in the PEP257 thread.

After that's done, I'm happy to merge it.

Cheers

Revision history for this message
Max Brustkern (nuclearbob) wrote :

Should be taken care of now.

review: Needs Resubmitting
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
lp:~nuclearbob/autopilot/bug1210260 updated
466. By Max Brustkern

Merged trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Thomi Richards (thomir-deactivatedaccount) wrote :

LGTM

review: Needs Fixing

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'autopilot/introspection/dbus.py'
2--- autopilot/introspection/dbus.py 2014-03-11 16:39:25 +0000
3+++ autopilot/introspection/dbus.py 2014-03-13 21:44:45 +0000
4@@ -413,7 +413,7 @@
5 of the appropriate type (the latter case is for overridden emulator
6 classes).
7
8- :raises: **TypeError** if neither *type_name* or keyword filters are
9+ :raises TypeError: if neither *type_name* or keyword filters are
10 provided.
11
12 .. seealso::
13@@ -466,7 +466,7 @@
14 automatically retrieves new state every time this object's attributes
15 are read.
16
17- :raises: **StateNotFound** if the object in the application under test
18+ :raises StateNotFound: if the object in the application under test
19 has been destroyed.
20
21 """
22@@ -530,7 +530,7 @@
23
24 :param piece: an XPath-like query that specifies which bit of the tree
25 you want to look at.
26- :raises: **TypeError** on invalid *piece* parameter.
27+ :raises TypeError: on invalid *piece* parameter.
28
29 """
30 if not isinstance(piece, six.string_types):
31@@ -598,25 +598,16 @@
32 (path, state_dict).
33
34 This only works for classes that derive from DBusIntrospectionObject.
35+
36+ :returns: A proxy object that derives from DBusIntrospectionObject
37+ :raises ValueError: if more than one class is appropriate for this
38+ dbus_tuple
39+
40 """
41 path, state = dbus_tuple
42- name = get_classname_from_path(path)
43- try:
44- class_type = _object_registry[self._id][name]
45- except KeyError:
46- get_debug_logger().warning(
47- "Generating introspection instance for type '%s' based on "
48- "generic class.", name)
49- # we want the object to inherit from the custom emulator base, not
50- # the object class that is doing the selecting
51- for base in type(self).__bases__:
52- if issubclass(base, CustomEmulatorBase):
53- base_class = base
54- break
55- else:
56- base_class = type(self)
57- class_type = type(str(name), (base_class,), {})
58- return class_type(state, path, self._backend)
59+ class_object = _get_proxy_object_class(
60+ _object_registry[self._id], type(self), path, state)
61+ return class_object(state, path, self._backend)
62
63 def print_tree(self, output=None, maxdepth=None, _curdepth=0):
64 """Print properties of the object and its children to a stream.
65@@ -678,6 +669,24 @@
66 finally:
67 self.__refresh_on_attribute = True
68
69+ @classmethod
70+ def validate_dbus_object(cls, path, _state):
71+ """Return whether this class is the appropriate proxy object class for
72+ a given dbus path and state.
73+
74+ The default version matches the name of the dbus object and the class.
75+ Subclasses of CustomProxyObject can override it to define a different
76+ validation method.
77+
78+ :param path: The dbus path of the object to check
79+ :param state: The dbus state dict of the object to check
80+ (ignored in default implementation)
81+ :returns: Whether this class is appropriate for the dbus object
82+
83+ """
84+ name = get_classname_from_path(path)
85+ return cls.__name__ == name
86+
87
88 def _is_valid_server_side_filter_param(key, value):
89 """Return True if the key and value parameters are valid for server-side
90@@ -741,6 +750,72 @@
91 raise ValueError("Unsupported value type: {}".format(type(value)))
92
93
94+def _get_proxy_object_class(proxy_class_dict, default_class, path, state):
95+ """Return a custom proxy class, either from the list or the default.
96+
97+ Use helper functions to check the class list or return the default.
98+ :param proxy_class_dict: dict of proxy classes to try
99+ :param default_class: default class to use if nothing in dict matches
100+ :param path: dbus path
101+ :param state: dbus state
102+ :returns: appropriate custom proxy class
103+ :raises ValueError: if more than one class in the dict matches
104+
105+ """
106+ class_type = _try_custom_proxy_classes(proxy_class_dict, path, state)
107+ if class_type:
108+ return class_type
109+ return _get_default_proxy_class(default_class,
110+ get_classname_from_path(path))
111+
112+
113+def _try_custom_proxy_classes(proxy_class_dict, path, state):
114+ """Identify which custom proxy class matches the dbus path and state.
115+
116+ If more than one class in proxy_class_dict matches, raise an exception.
117+ :param proxy_class_dict: dict of proxy classes to try
118+ :param path: dbus path
119+ :param state: dbus state dict
120+ :returns: matching custom proxy class
121+ :raises ValueError: if more than one class matches
122+
123+ """
124+ possible_classes = [c for c in proxy_class_dict.values() if
125+ c.validate_dbus_object(path, state)]
126+ if len(possible_classes) > 1:
127+ raise ValueError(
128+ 'More than one custom proxy class matches this object: '
129+ 'Matching classes are: %s. State is %s. Path is %s.'
130+ ','.join([repr(c) for c in possible_classes]),
131+ repr(state),
132+ path)
133+ if len(possible_classes) == 1:
134+ return possible_classes[0]
135+ return None
136+
137+
138+def _get_default_proxy_class(default_class, name):
139+ """Return a custom proxy object class of the default or a base class.
140+
141+ We want the object to inherit from CustomEmulatorBase, not the object
142+ class that is doing the selecting.
143+ :param default_class: default class to use if no bases match
144+ :param name: name of new class
145+ :returns: custom proxy object class
146+
147+ """
148+ get_debug_logger().warning(
149+ "Generating introspection instance for type '%s' based on generic "
150+ "class.", name)
151+ for base in default_class.__bases__:
152+ if issubclass(base, CustomEmulatorBase):
153+ base_class = base
154+ break
155+ else:
156+ base_class = default_class
157+ return type(str(name), (base_class,), {})
158+
159+
160 class _CustomEmulatorMeta(IntrospectableObjectMetaclass):
161
162 def __new__(cls, name, bases, d):
163
164=== modified file 'autopilot/tests/unit/test_introspection_features.py'
165--- autopilot/tests/unit/test_introspection_features.py 2014-03-11 17:36:53 +0000
166+++ autopilot/tests/unit/test_introspection_features.py 2014-03-13 21:44:45 +0000
167@@ -27,6 +27,7 @@
168 from testtools import TestCase
169 from testtools.matchers import (
170 Equals,
171+ IsInstance,
172 Not,
173 NotEquals,
174 Raises,
175@@ -46,13 +47,18 @@
176 _get_application_name_from_dbus_address,
177 _get_search_criteria_string_representation,
178 _maybe_filter_connections_by_app_name,
179+ get_classname_from_path,
180 get_proxy_object_for_existing_process,
181 ProcessSearchError,
182 )
183 from autopilot.introspection.dbus import (
184 _get_filter_string_for_key_value_pair,
185+ _get_default_proxy_class,
186 _is_valid_server_side_filter_param,
187+ _get_proxy_object_class,
188 _object_passes_filters,
189+ _object_registry,
190+ _try_custom_proxy_classes,
191 CustomEmulatorBase,
192 DBusIntrospectionObject,
193 )
194@@ -639,3 +645,194 @@
195 )
196 )
197 )
198+
199+
200+class MakeIntrospectionObjectTests(TestCase):
201+
202+ """Test selection of custom proxy object class."""
203+
204+ class DefaultSelector(CustomEmulatorBase):
205+ pass
206+
207+ class AlwaysSelected(CustomEmulatorBase):
208+ @classmethod
209+ def validate_dbus_object(cls, path, state):
210+ """Validate always.
211+
212+ :returns: True
213+
214+ """
215+ return True
216+
217+ class NeverSelected(CustomEmulatorBase):
218+ @classmethod
219+ def validate_dbus_object(cls, path, state):
220+ """Validate never.
221+
222+ :returns: False
223+
224+ """
225+ return False
226+
227+ def test_class_has_validation_method(self):
228+ """Verify that a class has a validation method by default."""
229+ self.assertTrue(callable(self.DefaultSelector.validate_dbus_object))
230+
231+ @patch('autopilot.introspection.dbus._get_proxy_object_class')
232+ def test_make_introspection_object(self, gpoc):
233+ """Verify that make_introspection_object makes the right call."""
234+ gpoc.return_value = self.DefaultSelector
235+ fake_object = self.DefaultSelector(
236+ dict(id=[0, 123], path=[0, '/some/path']),
237+ '/',
238+ Mock()
239+ )
240+ new_fake = fake_object.make_introspection_object(('/Object', {}))
241+ self.assertThat(new_fake, IsInstance(self.DefaultSelector))
242+ gpoc.assert_called_once_with(
243+ _object_registry[fake_object._id],
244+ self.DefaultSelector,
245+ '/Object',
246+ {}
247+ )
248+
249+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
250+ @patch('autopilot.introspection.dbus._get_default_proxy_class')
251+ def test_get_proxy_object_class_return_from_list(self, gdpc, tcpc):
252+ """_get_proxy_object_class should return the value of
253+ _try_custom_proxy_classes if there is one."""
254+ token = self.getUniqueString()
255+ tcpc.return_value = token
256+ gpoc_return = _get_proxy_object_class(None, None, None, None)
257+
258+ self.assertThat(gpoc_return, Equals(token))
259+ self.assertFalse(gdpc.called)
260+
261+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
262+ def test_get_proxy_object_class_send_right_args(self, tcpc):
263+ """_get_proxy_object_class should send the right arguments to
264+ _try_custom_proxy_classes."""
265+ class_dict = {'DefaultSelector': self.DefaultSelector}
266+ path = '/path/to/DefaultSelector'
267+ state = {}
268+ _get_proxy_object_class(class_dict, None, path, state)
269+ tcpc.assert_called_once_with(class_dict, path, state)
270+
271+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
272+ def test_get_proxy_object_class_not_handle_error(self, tcpc):
273+ """_get_proxy_object_class should not handle an exception raised by
274+ _try_custom_proxy_classes."""
275+ tcpc.side_effect = ValueError
276+ self.assertThat(
277+ lambda: _get_proxy_object_class(
278+ None,
279+ None,
280+ None,
281+ None
282+ ),
283+ raises(ValueError))
284+
285+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
286+ @patch('autopilot.introspection.dbus._get_default_proxy_class')
287+ @patch('autopilot.introspection.dbus.get_classname_from_path')
288+ def test_get_proxy_object_class_call_default_call(self, gcfp, gdpc, tcpc):
289+ """_get_proxy_object_class should call _get_default_proxy_class if
290+ _try_custom_proxy_classes returns None."""
291+ tcpc.return_value = None
292+ _get_proxy_object_class(None, None, None, None)
293+ self.assertTrue(gdpc.called)
294+
295+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
296+ @patch('autopilot.introspection.dbus._get_default_proxy_class')
297+ def test_get_proxy_object_class_default_args(self, gdpc, tcpc):
298+ """_get_proxy_object_class should pass the correct arguments to
299+ _get_default_proxy_class"""
300+ tcpc.return_value = None
301+ default = self.DefaultSelector
302+ path = '/path/to/DefaultSelector'
303+ _get_proxy_object_class(None, default, path, None)
304+ gdpc.assert_called_once_with(default, get_classname_from_path(path))
305+
306+ @patch('autopilot.introspection.dbus._try_custom_proxy_classes')
307+ @patch('autopilot.introspection.dbus._get_default_proxy_class')
308+ @patch('autopilot.introspection.dbus.get_classname_from_path')
309+ def test_get_proxy_object_class_default(self, gcfp, gdpc, tcpc):
310+ """_get_proxy_object_class should return the value of
311+ _get_default_proxy_class if _try_custom_proxy_classes returns None."""
312+ token = self.getUniqueString()
313+ gdpc.return_value = token
314+ tcpc.return_value = None
315+ gpoc_return = _get_proxy_object_class(None, None, None, None)
316+ self.assertThat(gpoc_return, Equals(token))
317+
318+ def test_try_custom_proxy_classes_zero_results(self):
319+ """_try_custom_proxy_classes must return None if no classes match."""
320+ proxy_class_dict = {'NeverSelected': self.NeverSelected}
321+ path = '/path/to/NeverSelected'
322+ state = {}
323+ class_type = _try_custom_proxy_classes(proxy_class_dict, path, state)
324+ self.assertThat(class_type, Equals(None))
325+
326+ def test_try_custom_proxy_classes_one_result(self):
327+ """_try_custom_proxy_classes must return the matching class if there is
328+ exacly 1."""
329+ proxy_class_dict = {'DefaultSelector': self.DefaultSelector}
330+ path = '/path/to/DefaultSelector'
331+ state = {}
332+ class_type = _try_custom_proxy_classes(proxy_class_dict, path, state)
333+ self.assertThat(class_type, Equals(self.DefaultSelector))
334+
335+ def test_try_custom_proxy_classes_two_results(self):
336+ """_try_custom_proxy_classes must raise ValueError if multiple classes
337+ match."""
338+ proxy_class_dict = {'DefaultSelector': self.DefaultSelector,
339+ 'AlwaysSelected': self.AlwaysSelected}
340+ path = '/path/to/DefaultSelector'
341+ state = {}
342+ self.assertThat(
343+ lambda: _try_custom_proxy_classes(
344+ proxy_class_dict,
345+ path,
346+ state
347+ ),
348+ raises(ValueError)
349+ )
350+
351+ @patch('autopilot.introspection.dbus.get_debug_logger')
352+ def test_get_default_proxy_class_logging(self, gdl):
353+ """_get_default_proxy_class should log a message."""
354+ _get_default_proxy_class(self.DefaultSelector, None)
355+ gdl.assert_called_once_with()
356+
357+ def test_get_default_proxy_class_base(self):
358+ """Subclass must return an emulator of base class."""
359+ class SubclassedProxy(self.DefaultSelector):
360+ pass
361+
362+ result = _get_default_proxy_class(SubclassedProxy, 'Object')
363+ self.assertTrue(result, Equals(self.DefaultSelector))
364+
365+ def test_get_default_proxy_class_base_instead_of_self(self):
366+ """Subclass must not use self if base class works."""
367+ class SubclassedProxy(self.DefaultSelector):
368+ pass
369+
370+ result = _get_default_proxy_class(SubclassedProxy, 'Object')
371+ self.assertFalse(issubclass(result, SubclassedProxy))
372+
373+ def test_get_default_proxy_class(self):
374+ """Must default to own class if no usable bases present."""
375+ result = _get_default_proxy_class(self.DefaultSelector, 'Object')
376+ self.assertTrue(result, Equals(self.DefaultSelector))
377+
378+ def test_get_default_proxy_name(self):
379+ """Must default to own class if no usable bases present."""
380+ token = self.getUniqueString()
381+ result = _get_default_proxy_class(self.DefaultSelector, token)
382+ self.assertThat(result.__name__, Equals(token))
383+
384+ def test_validate_dbus_object_matches_on_class_name(self):
385+ """Validate_dbus_object must match class name."""
386+ selected = self.DefaultSelector.validate_dbus_object(
387+ '/DefaultSelector', {})
388+ self.assertTrue(selected)

Subscribers

People subscribed via source and target branches