Merge lp:~zyga/checkbox/usage-expectations into lp:checkbox
- usage-expectations
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Sylvain Pineau | Approve | ||
Review via email: mp+266083@code.launchpad.net |
Commit message
Description of the change
- 3919. By Zygmunt Krynicki
-
"automatic merge of lp:~zyga/checkbox/misc-stuff/ by tarmac [r=sylvain-
pineau] [bug=][ author= zyga]"
Zygmunt Krynicki (zyga) wrote : | # |
Maciej Kisielewski (kissiel) wrote : | # |
Only one request for one docstring.
Overall +1
Zygmunt Krynicki (zyga) : | # |
- 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>
Sylvain Pineau (sylvain-pineau) wrote : | # |
One more piece to the puzzle, +1
Preview Diff
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(): |
Left one small inline note below.