Merge lp:~zyga/checkbox/usage-expectations into lp:checkbox

Proposed by Zygmunt Krynicki
Status: Merged
Approved by: Sylvain Pineau
Approved revision: 3921
Merged at revision: 3926
Proposed branch: lp:~zyga/checkbox/usage-expectations
Merge into: lp:checkbox
Diff against target: 314 lines (+286/-1)
3 files modified
plainbox/plainbox/impl/developer.py (+194/-0)
plainbox/plainbox/impl/test_developer.py (+88/-0)
plainbox/plainbox/tests.py (+4/-1)
To merge this branch: bzr merge lp:~zyga/checkbox/usage-expectations
Reviewer Review Type Date Requested Status
Sylvain Pineau Approve
Review via email: mp+266083@code.launchpad.net
To post a comment you must log in.
lp:~zyga/checkbox/usage-expectations updated
3919. By Zygmunt Krynicki

"automatic merge of lp:~zyga/checkbox/misc-stuff/ by tarmac [r=sylvain-pineau][bug=][author=zyga]"

Revision history for this message
Zygmunt Krynicki (zyga) wrote :

Left one small inline note below.

Revision history for this message
Maciej Kisielewski (kissiel) wrote :

Only one request for one docstring.
Overall +1

Revision history for this message
Zygmunt Krynicki (zyga) :
lp:~zyga/checkbox/usage-expectations updated
3920. By Zygmunt Krynicki

plainbox:tests: fix the loader to use correct import dir

This patch makes discovered tests include the plainbox package name.
Before this, all tests were discovered from the plainbox/ directory (the
one with __init__.py, not the one with setup.py). This way the top-level
imported test was impl.something.

This patch fixes this by using the optional top_level_dir argument.

Signed-off-by: Zygmunt Krynicki <email address hidden>

3921. By Zygmunt Krynicki

plainbox:developer: add the developer module

This patch adds a new module, plainbox.impl.developer, with the support
class, UsageExpecation, that helps 3rd party developers use the public
APIs of plainbox correctly. The class makes it easier to follow
and enforce the correct sequence of method calls on a given class.

Signed-off-by: Zygmunt Krynicki <email address hidden>

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

One more piece to the puzzle, +1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'plainbox/plainbox/impl/developer.py'
2--- plainbox/plainbox/impl/developer.py 1970-01-01 00:00:00 +0000
3+++ plainbox/plainbox/impl/developer.py 2015-07-30 11:31:57 +0000
4@@ -0,0 +1,194 @@
5+# This file is part of Checkbox.
6+#
7+# Copyright 2015 Canonical Ltd.
8+# Written by:
9+# Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
10+#
11+# Checkbox 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+# Checkbox 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 Checkbox. If not, see <http://www.gnu.org/licenses/>.
22+
23+"""Support code for enforcing usage expectations on public API."""
24+
25+import inspect
26+
27+__all__ = ('UsageExpectation',)
28+
29+
30+class DeveloperError(Exception):
31+
32+ """
33+ Exception raised when program flow is incorrect.
34+
35+ This exception is meant to gently educate the developer about a mistake in
36+ his or her choices in the flow of calls. Some classes may use it to explain
37+ that a precondition was not met. Applications are not intended to catch
38+ this exception.
39+ """
40+
41+ pass # Eh, PEP-257 checkers...
42+
43+
44+# NOTE: This is not meant for internationalization. There is some string
45+# manipulation associated with this that would be a bit more cumbersome to do
46+# "correctly" for the small benefit.
47+_msg_template = """
48+Uh, oh...
49+
50+You are not expected to call {cls_name}.{fn_name}() at this time.
51+
52+If you see this message then there is a bug somewhere in your code. We are
53+sorry for this. Perhaps the documentation is flawed, incomplete or confusing.
54+Please reach out to us if this happens more often than you'd like.
55+
56+The set of allowed calls, at this time, is:
57+
58+{allowed_calls}
59+
60+Refer to the documentation of {cls_name} for details.
61+ TIP: python -m pydoc {cls_module}.{cls_name}
62+"""
63+
64+
65+class UnexpectedMethodCall(DeveloperError):
66+
67+ """
68+ Developer error reported when an unexpected method call is made.
69+
70+ This type of error is reported when some set of methods is expected to be
71+ called in a given way but that expectation was not followed.
72+ """
73+
74+ def __init__(self, cls, fn_name, allowed_pairs):
75+ """
76+ Initialize a new exception.
77+
78+ :param cls:
79+ The class this exception refers to (the code user calls must be a
80+ method on that class).
81+ :param fn_name:
82+ Name of the method that was unexpectedly called.
83+ :param allowed_pairs:
84+ A sequence of pairs ``(fn_name, why)`` that explain the set of
85+ allowed function calls. There is a certain pattern on how the
86+ ``why`` strings are expected to be structured. They will be used as
87+ a part of a string that looks like this: ``' - call {fn_name}() to
88+ {why}.'``. Developers should use explanations that look natural in
89+ this context. This text is not meant for internationalization.
90+ """
91+ self.cls = cls
92+ self.fn_name = fn_name
93+ self.allowed_pairs = allowed_pairs
94+
95+ def __str__(self):
96+ """Get a developer-friendly message that describes the problem."""
97+ return _msg_template.format(
98+ cls_module=self.cls.__module__,
99+ cls_name=self.cls.__name__,
100+ fn_name=self.fn_name,
101+ allowed_calls='\n'.join(
102+ ' - call {}.{}() to {}.'.format(
103+ self.cls.__name__, allowed_fn_name, why)
104+ for allowed_fn_name, why in self.allowed_pairs))
105+
106+
107+class UsageExpectation:
108+
109+ """
110+ Class representing API usage expectation at any given time.
111+
112+ Expectations help formalize the way developers are expected to use some set
113+ of classes, methods and other instruments. Technically, they also encode
114+ the expectations and can raise :class:`DeveloperError`.
115+
116+ :attr allowed_calls:
117+ A dictionary mapping from bound methods / functions to the use case
118+ explaining how that method can be used at the given moment. This works
119+ best if the usage is mostly linear (call foo.a(), then foo.b(), then
120+ foo.c()).
121+
122+ This attribute can be set directly for simplicity.
123+
124+ :attr cls:
125+ The class of objects this expectation object applies to.
126+ """
127+
128+ @classmethod
129+ def of(cls, obj):
130+ """
131+ Get the usage expectation of a given object.
132+
133+ :param obj:
134+ The object for which usage expectation is to be set
135+ :returns:
136+ Either a previously made object or a fresh instance of
137+ :class:`UsageExpectation`.
138+ """
139+ try:
140+ return obj.__usage_expectation
141+ except AttributeError:
142+ ua = cls(type(obj))
143+ obj.__usage_expectation = ua
144+ return ua
145+
146+ def __init__(self, cls):
147+ """
148+ Initialize a new, empty, usage expectations object.
149+
150+ :param cls:
151+ The class of objects that this usage expectation applies to. This
152+ is used only to inform the developer where to look for help when
153+ something goes wrong.
154+ """
155+ self.cls = cls
156+ self._allowed_calls = {}
157+
158+ @property
159+ def allowed_calls(self):
160+ """Get the mapping of possible methods to call."""
161+ return self._allowed_calls
162+
163+ @allowed_calls.setter
164+ def allowed_calls(self, value):
165+ """Set new mapping of possible methods to call."""
166+ self._allowed_calls = value
167+ self._allowed_code = frozenset(func.__code__ for func in value)
168+
169+ def enforce(self, back=1):
170+ """
171+ Enforce that usage expectations of the caller are met.
172+
173+ :param back:
174+ How many function call frames to climb to look for caller. By
175+ default we always go one frame back (the immediate caller) but if
176+ this is used in some decorator or other similar construct then you
177+ may need to pass a bigger value.
178+
179+ Depending on this value, the error message displayed to the
180+ developer will be either spot-on or downright wrong and confusing.
181+ Make sure the value you use it correct!
182+
183+ :raises DeveloperError:
184+ If the expectations are not met.
185+ """
186+ caller_frame = inspect.stack(0)[back][0]
187+ try:
188+ if caller_frame.f_code not in self._allowed_code:
189+ fn_name = caller_frame.f_code.co_name
190+ allowed_pairs = tuple(
191+ (fn.__code__.co_name, why)
192+ for fn, why in sorted(
193+ self.allowed_calls.items(),
194+ key=lambda fn_why: fn_why[0].__code__.co_name)
195+ )
196+ raise UnexpectedMethodCall(self.cls, fn_name, allowed_pairs)
197+ finally:
198+ del caller_frame
199
200=== added file 'plainbox/plainbox/impl/test_developer.py'
201--- plainbox/plainbox/impl/test_developer.py 1970-01-01 00:00:00 +0000
202+++ plainbox/plainbox/impl/test_developer.py 2015-07-30 11:31:57 +0000
203@@ -0,0 +1,88 @@
204+# This file is part of Checkbox.
205+#
206+# Copyright 2015 Canonical Ltd.
207+# Written by:
208+# Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
209+#
210+# Checkbox is free software: you can redistribute it and/or modify
211+# it under the terms of the GNU General Public License version 3,
212+# as published by the Free Software Foundation.
213+#
214+# Checkbox is distributed in the hope that it will be useful,
215+# but WITHOUT ANY WARRANTY; without even the implied warranty of
216+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
217+# GNU General Public License for more details.
218+#
219+# You should have received a copy of the GNU General Public License
220+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
221+
222+"""Tests for the developer support module."""
223+
224+import unittest
225+
226+from plainbox.impl.developer import DeveloperError
227+from plainbox.impl.developer import UnexpectedMethodCall
228+from plainbox.impl.developer import UsageExpectation
229+
230+
231+class _Foo:
232+
233+ def m1(self):
234+ UsageExpectation.of(self).enforce()
235+
236+ def m2(self):
237+ UsageExpectation.of(self).enforce()
238+
239+
240+class UnexpectedMethodCallTests(unittest.TestCase):
241+
242+ """Tests for the UnexpectedMethodCall class."""
243+
244+ def test_ancestry(self):
245+ """Check that UnexpectedMethodCall is a subclass of DeveloperError."""
246+ self.assertTrue(issubclass(UnexpectedMethodCall, DeveloperError))
247+
248+
249+class UsageExpectationTests(unittest.TestCase):
250+
251+ """Tests for the UsageExpectation class."""
252+
253+ def test_of(self):
254+ """Check that .of() returns the same object for each target."""
255+ foo1 = _Foo()
256+ foo2 = _Foo()
257+ ue1 = UsageExpectation.of(foo1)
258+ ue2 = UsageExpectation.of(foo2)
259+ self.assertIsInstance(ue1, UsageExpectation)
260+ self.assertIsInstance(ue2, UsageExpectation)
261+ self.assertIs(ue1, UsageExpectation.of(foo1))
262+ self.assertIs(ue2, UsageExpectation.of(foo2))
263+ self.assertIsNot(ue1, ue2)
264+
265+ def test_enforce(self):
266+ """Check that .enforce() works and produces useful messages."""
267+ foo = _Foo()
268+ UsageExpectation.of(foo).allowed_calls = {
269+ foo.m1: "call m1 now"
270+ }
271+ # Nothing should happen here
272+ foo.m1()
273+ # Exception should be raised here
274+ with self.assertRaises(UnexpectedMethodCall) as boom:
275+ foo.m2()
276+ self.assertEqual(str(boom.exception), """
277+Uh, oh...
278+
279+You are not expected to call _Foo.m2() at this time.
280+
281+If you see this message then there is a bug somewhere in your code. We are
282+sorry for this. Perhaps the documentation is flawed, incomplete or confusing.
283+Please reach out to us if this happens more often than you'd like.
284+
285+The set of allowed calls, at this time, is:
286+
287+ - call _Foo.m1() to call m1 now.
288+
289+Refer to the documentation of _Foo for details.
290+ TIP: python -m pydoc plainbox.impl.test_developer._Foo
291+""")
292
293=== modified file 'plainbox/plainbox/tests.py'
294--- plainbox/plainbox/tests.py 2014-02-20 20:47:48 +0000
295+++ plainbox/plainbox/tests.py 2015-07-30 11:31:57 +0000
296@@ -22,6 +22,7 @@
297 ============================================================
298 """
299
300+import os
301 from unittest.loader import defaultTestLoader
302
303 from plainbox.impl import get_plainbox_dir
304@@ -33,7 +34,9 @@
305 """
306 # Discover all unit tests. By simple convention those are kept in
307 # python modules that start with the word 'test_' .
308- return defaultTestLoader.discover(get_plainbox_dir())
309+ start_dir = get_plainbox_dir()
310+ top_level_dir = os.path.normpath(os.path.join(start_dir, '..'))
311+ return defaultTestLoader.discover(start_dir, top_level_dir=top_level_dir)
312
313
314 def load_integration_tests():

Subscribers

People subscribed via source and target branches