Merge ~cjwatson/launchpadlib:fakelaunchpad-better-resource-creation into launchpadlib:main
- Git
- lp:~cjwatson/launchpadlib
- fakelaunchpad-better-resource-creation
- Merge into main
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | a16d50865b379eb15a0f0a9e1ba2a327dc3eaab3 |
Proposed branch: | ~cjwatson/launchpadlib:fakelaunchpad-better-resource-creation |
Merge into: | launchpadlib:main |
Diff against target: |
303 lines (+136/-38) 4 files modified
NEWS.rst (+2/-0) src/launchpadlib/testing/launchpad.py (+59/-28) src/launchpadlib/testing/testing-wadl.xml (+8/-0) src/launchpadlib/testing/tests/test_launchpad.py (+67/-10) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jürgen Gmach | Approve | ||
Review via email: mp+435261@code.launchpad.net |
Commit message
Allow sample data with links to other entries/collections
Description of the change
`FakeLaunchpad` allowed setting sample data with plain attributes of entries, but it wasn't quite smart enough to support sample entries that link to other entries or collections, which is often necessary when testing `launchpadlib` clients in practice. It can handle this now.
I had to do a bit of flailing around with `wadllib` to figure out how all this works, but I've been able to use this for real testing of an `lpcraft` branch with some moderately complex structure in its sample data, so I'm reasonably sure I've got it right.
To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/NEWS.rst b/NEWS.rst | |||
2 | index b3475eb..29fc779 100644 | |||
3 | --- a/NEWS.rst | |||
4 | +++ b/NEWS.rst | |||
5 | @@ -7,6 +7,8 @@ NEWS for launchpadlib | |||
6 | 7 | - Move the ``keyring`` dependency to a new ``keyring`` extra. | 7 | - Move the ``keyring`` dependency to a new ``keyring`` extra. |
7 | 8 | - Support setting fake methods that return None on instances of | 8 | - Support setting fake methods that return None on instances of |
8 | 9 | ``launchpadlib.testing.launchpad.FakeLaunchpad``. | 9 | ``launchpadlib.testing.launchpad.FakeLaunchpad``. |
9 | 10 | - Allow setting ``FakeLaunchpad`` sample data with attributes that are links | ||
10 | 11 | to other entries or collections. | ||
11 | 10 | 12 | ||
12 | 11 | 1.10.18 (2022-10-28) | 13 | 1.10.18 (2022-10-28) |
13 | 12 | ==================== | 14 | ==================== |
14 | diff --git a/src/launchpadlib/testing/launchpad.py b/src/launchpadlib/testing/launchpad.py | |||
15 | index a079da1..3b68798 100644 | |||
16 | --- a/src/launchpadlib/testing/launchpad.py | |||
17 | +++ b/src/launchpadlib/testing/launchpad.py | |||
18 | @@ -76,8 +76,6 @@ import sys | |||
19 | 76 | if sys.version_info[0] >= 3: | 76 | if sys.version_info[0] >= 3: |
20 | 77 | basestring = str | 77 | basestring = str |
21 | 78 | 78 | ||
22 | 79 | JSON_MEDIA_TYPE = "application/json" | ||
23 | 80 | |||
24 | 81 | 79 | ||
25 | 82 | class IntegrityError(Exception): | 80 | class IntegrityError(Exception): |
26 | 83 | """Raised when bad sample data is used with a L{FakeLaunchpad} instance.""" | 81 | """Raised when bad sample data is used with a L{FakeLaunchpad} instance.""" |
27 | @@ -185,6 +183,11 @@ def strip_suffix(string, suffix): | |||
28 | 185 | return string | 183 | return string |
29 | 186 | 184 | ||
30 | 187 | 185 | ||
31 | 186 | def wadl_tag(tag_name): | ||
32 | 187 | """Scope a tag name with the WADL namespace.""" | ||
33 | 188 | return "{http://research.sun.com/wadl/2006/10}" + tag_name | ||
34 | 189 | |||
35 | 190 | |||
36 | 188 | class FakeResource(object): | 191 | class FakeResource(object): |
37 | 189 | """ | 192 | """ |
38 | 190 | Represents valid sample data on L{FakeLaunchpad} instances. | 193 | Represents valid sample data on L{FakeLaunchpad} instances. |
39 | @@ -283,16 +286,17 @@ class FakeResource(object): | |||
40 | 283 | this resource or if C{values} isn't a valid object for the C{name} | 286 | this resource or if C{values} isn't a valid object for the C{name} |
41 | 284 | attribute. | 287 | attribute. |
42 | 285 | """ | 288 | """ |
44 | 286 | root_resource = self._application.get_resource_by_path("") | 289 | xml_id = self._find_representation_id(self._resource_type, "get") |
45 | 290 | representation = self._application.representation_definitions[xml_id] | ||
46 | 291 | params = { | ||
47 | 292 | child.name: child | ||
48 | 293 | for child in representation.params(representation) | ||
49 | 294 | } | ||
50 | 287 | is_link = False | 295 | is_link = False |
54 | 288 | param = root_resource.get_parameter( | 296 | param = params.get(name + "_collection_link") |
52 | 289 | name + "_collection_link", JSON_MEDIA_TYPE | ||
53 | 290 | ) | ||
55 | 291 | if param is None: | 297 | if param is None: |
56 | 292 | is_link = True | 298 | is_link = True |
60 | 293 | param = root_resource.get_parameter( | 299 | param = params.get(name + "_link") |
58 | 294 | name + "_link", JSON_MEDIA_TYPE | ||
59 | 295 | ) | ||
61 | 296 | if param is None: | 300 | if param is None: |
62 | 297 | raise IntegrityError("%s isn't a valid property." % (name,)) | 301 | raise IntegrityError("%s isn't a valid property." % (name,)) |
63 | 298 | resource_type = self._get_resource_type(param) | 302 | resource_type = self._get_resource_type(param) |
64 | @@ -319,7 +323,7 @@ class FakeResource(object): | |||
65 | 319 | @return: The resource type for the parameter, or None if one isn't | 323 | @return: The resource type for the parameter, or None if one isn't |
66 | 320 | available. | 324 | available. |
67 | 321 | """ | 325 | """ |
69 | 322 | [link] = list(param.tag) | 326 | link = param.tag.find(wadl_tag("link")) |
70 | 323 | name = link.get("resource_type") | 327 | name = link.get("resource_type") |
71 | 324 | return self._application.get_resource_type(name) | 328 | return self._application.get_resource_type(name) |
72 | 325 | 329 | ||
73 | @@ -411,21 +415,34 @@ class FakeResource(object): | |||
74 | 411 | name or if C{value}'s type is not valid for the attribute. | 415 | name or if C{value}'s type is not valid for the attribute. |
75 | 412 | """ | 416 | """ |
76 | 413 | representation = self._application.representation_definitions[xml_id] | 417 | representation = self._application.representation_definitions[xml_id] |
92 | 414 | parameters = {child.get("name"): child for child in representation.tag} | 418 | params = { |
93 | 415 | if name not in parameters: | 419 | child.name: child |
94 | 416 | raise IntegrityError("%s not found" % name) | 420 | for child in representation.params(representation) |
95 | 417 | parameter = parameters[name] | 421 | } |
96 | 418 | data_type = parameter.get("type") | 422 | if name + "_collection_link" in params: |
97 | 419 | if data_type is None: | 423 | resource_type = self._get_resource_type( |
98 | 420 | if not isinstance(value, basestring): | 424 | params[name + "_collection_link"] |
99 | 421 | raise IntegrityError( | 425 | ) |
100 | 422 | "%s is not a str or unicode for %s" % (value, name) | 426 | child_name, child_resource_type = self._check_collection_type( |
101 | 423 | ) | 427 | resource_type, value |
102 | 424 | elif data_type == "xsd:dateTime": | 428 | ) |
103 | 425 | if not isinstance(value, datetime): | 429 | elif name + "_link" in params: |
104 | 426 | raise IntegrityError( | 430 | resource_type = self._get_resource_type(params[name + "_link"]) |
105 | 427 | "%s is not a datetime for %s" % (value, name) | 431 | self._check_resource_type(resource_type, value) |
106 | 428 | ) | 432 | else: |
107 | 433 | param = params.get(name) | ||
108 | 434 | if param is None: | ||
109 | 435 | raise IntegrityError("%s not found" % name) | ||
110 | 436 | if param.type is None: | ||
111 | 437 | if not isinstance(value, basestring): | ||
112 | 438 | raise IntegrityError( | ||
113 | 439 | "%s is not a str or unicode for %s" % (value, name) | ||
114 | 440 | ) | ||
115 | 441 | elif param.type == "xsd:dateTime": | ||
116 | 442 | if not isinstance(value, datetime): | ||
117 | 443 | raise IntegrityError( | ||
118 | 444 | "%s is not a datetime for %s" % (value, name) | ||
119 | 445 | ) | ||
120 | 429 | 446 | ||
121 | 430 | def _get_method(self, resource_type, name): | 447 | def _get_method(self, resource_type, name): |
122 | 431 | """Get the C{name} method on C{resource_type}. | 448 | """Get the C{name} method on C{resource_type}. |
123 | @@ -486,9 +503,23 @@ class FakeResource(object): | |||
124 | 486 | if xml_id not in self._application.resource_types: | 503 | if xml_id not in self._application.resource_types: |
125 | 487 | xml_id += "-resource" | 504 | xml_id += "-resource" |
126 | 488 | result_resource_type = self._application.resource_types[xml_id] | 505 | result_resource_type = self._application.resource_types[xml_id] |
130 | 489 | self._check_resource_type(result_resource_type, result) | 506 | if xml_id.endswith("-page-resource"): |
131 | 490 | # XXX: Should this wrap in collection? | 507 | name, child_resource_type = self._check_collection_type( |
132 | 491 | return FakeResource(self._application, result_resource_type, result) | 508 | result_resource_type, result |
133 | 509 | ) | ||
134 | 510 | return FakeCollection( | ||
135 | 511 | self._application, | ||
136 | 512 | result_resource_type, | ||
137 | 513 | result, | ||
138 | 514 | name, | ||
139 | 515 | child_resource_type, | ||
140 | 516 | ) | ||
141 | 517 | else: | ||
142 | 518 | self._check_resource_type(result_resource_type, result) | ||
143 | 519 | resource = FakeEntry(self._application, result_resource_type) | ||
144 | 520 | for child_name, child_value in result.items(): | ||
145 | 521 | setattr(resource, child_name, child_value) | ||
146 | 522 | return resource | ||
147 | 492 | 523 | ||
148 | 493 | def _get_child_resource_type(self, resource_type): | 524 | def _get_child_resource_type(self, resource_type): |
149 | 494 | """Get the name and resource type for the entries in a collection. | 525 | """Get the name and resource type for the entries in a collection. |
150 | diff --git a/src/launchpadlib/testing/testing-wadl.xml b/src/launchpadlib/testing/testing-wadl.xml | |||
151 | index cc33f88..650711d 100644 | |||
152 | --- a/src/launchpadlib/testing/testing-wadl.xml | |||
153 | +++ b/src/launchpadlib/testing/testing-wadl.xml | |||
154 | @@ -187,6 +187,10 @@ | |||
155 | 187 | <wadl:param style="plain" required="true" | 187 | <wadl:param style="plain" required="true" |
156 | 188 | path="$['name']" name="name"> | 188 | path="$['name']" name="name"> |
157 | 189 | </wadl:param> | 189 | </wadl:param> |
158 | 190 | <wadl:param style="plain" required="true" name="linked_bugs_collection_link" path="$['linked_bugs_collection_link']"> | ||
159 | 191 | <wadl:doc xmlns="http://www.w3.org/1999/xhtml">The bugs linked to this branch.</wadl:doc> | ||
160 | 192 | <wadl:link resource_type="https://api.example.com/testing/#bug-page-resource"/> | ||
161 | 193 | </wadl:param> | ||
162 | 190 | </wadl:representation> | 194 | </wadl:representation> |
163 | 191 | 195 | ||
164 | 192 | <wadl:resource_type id="branches"> | 196 | <wadl:resource_type id="branches"> |
165 | @@ -341,6 +345,10 @@ | |||
166 | 341 | <wadl:param style="plain" required="true" | 345 | <wadl:param style="plain" required="true" |
167 | 342 | path="$['title']" name="title"> | 346 | path="$['title']" name="title"> |
168 | 343 | </wadl:param> | 347 | </wadl:param> |
169 | 348 | <wadl:param style="plain" required="true" name="owner_link" path="$['owner_link']"> | ||
170 | 349 | <wadl:doc xmlns="http://www.w3.org/1999/xhtml">The owner's IPerson</wadl:doc> | ||
171 | 350 | <wadl:link resource_type="https://api.example.com/testing/#person"/> | ||
172 | 351 | </wadl:param> | ||
173 | 344 | </wadl:representation> | 352 | </wadl:representation> |
174 | 345 | 353 | ||
175 | 346 | </wadl:application> | 354 | </wadl:application> |
176 | diff --git a/src/launchpadlib/testing/tests/test_launchpad.py b/src/launchpadlib/testing/tests/test_launchpad.py | |||
177 | index 956fc06..cad2f10 100644 | |||
178 | --- a/src/launchpadlib/testing/tests/test_launchpad.py | |||
179 | +++ b/src/launchpadlib/testing/tests/test_launchpad.py | |||
180 | @@ -42,14 +42,23 @@ class FakeResourceTest(ResourcedTestCase): | |||
181 | 42 | 42 | ||
182 | 43 | resources = [("launchpad", FakeLaunchpadResource())] | 43 | resources = [("launchpad", FakeLaunchpadResource())] |
183 | 44 | 44 | ||
186 | 45 | def test_repr(self): | 45 | def test_repr_entry(self): |
187 | 46 | """A custom C{__repr__} is provided for L{FakeResource}s.""" | 46 | """A custom C{__repr__} is provided for L{FakeEntry}s.""" |
188 | 47 | bug = dict() | ||
189 | 48 | self.launchpad.bugs = dict(entries=[bug]) | ||
190 | 49 | [bug] = list(self.launchpad.bugs) | ||
191 | 50 | self.assertEqual( | ||
192 | 51 | "<FakeEntry bug object at %s>" % hex(id(bug)), repr(bug) | ||
193 | 52 | ) | ||
194 | 53 | |||
195 | 54 | def test_repr_collection(self): | ||
196 | 55 | """A custom C{__repr__} is provided for L{FakeCollection}s.""" | ||
197 | 47 | branches = dict(total_size="test-branch") | 56 | branches = dict(total_size="test-branch") |
198 | 48 | self.launchpad.me = dict(getBranches=lambda statuses: branches) | 57 | self.launchpad.me = dict(getBranches=lambda statuses: branches) |
199 | 49 | branches = self.launchpad.me.getBranches([]) | 58 | branches = self.launchpad.me.getBranches([]) |
200 | 50 | obj_id = hex(id(branches)) | 59 | obj_id = hex(id(branches)) |
201 | 51 | self.assertEqual( | 60 | self.assertEqual( |
203 | 52 | "<FakeResource branch-page-resource object at %s>" % obj_id, | 61 | "<FakeCollection branch-page-resource object at %s>" % obj_id, |
204 | 53 | repr(branches), | 62 | repr(branches), |
205 | 54 | ) | 63 | ) |
206 | 55 | 64 | ||
207 | @@ -72,9 +81,7 @@ class FakeResourceTest(ResourcedTestCase): | |||
208 | 72 | bug = dict(id="1", title="Bug #1") | 81 | bug = dict(id="1", title="Bug #1") |
209 | 73 | self.launchpad.bugs = dict(entries=[bug]) | 82 | self.launchpad.bugs = dict(entries=[bug]) |
210 | 74 | [bug] = list(self.launchpad.bugs) | 83 | [bug] = list(self.launchpad.bugs) |
214 | 75 | self.assertEqual( | 84 | self.assertEqual("<FakeEntry bug 1 at %s>" % hex(id(bug)), repr(bug)) |
212 | 76 | "<FakeResource bug 1 at %s>" % hex(id(bug)), repr(bug) | ||
213 | 77 | ) | ||
215 | 78 | 85 | ||
216 | 79 | 86 | ||
217 | 80 | class FakeLaunchpadTest(ResourcedTestCase): | 87 | class FakeLaunchpadTest(ResourcedTestCase): |
218 | @@ -254,16 +261,52 @@ class FakeLaunchpadTest(ResourcedTestCase): | |||
219 | 254 | self.launchpad.branches = dict(getByUniqueName=lambda name: None) | 261 | self.launchpad.branches = dict(getByUniqueName=lambda name: None) |
220 | 255 | self.assertIsNone(self.launchpad.branches.getByUniqueName("foo")) | 262 | self.assertIsNone(self.launchpad.branches.getByUniqueName("foo")) |
221 | 256 | 263 | ||
223 | 257 | def test_collection_property(self): | 264 | def test_entry_property(self): |
224 | 265 | """ | ||
225 | 266 | Attributes that represent links to other objects are set using a | ||
226 | 267 | dict representing the object. | ||
227 | 268 | """ | ||
228 | 269 | bug = dict(owner=dict(name="test-person")) | ||
229 | 270 | self.launchpad.bugs = dict(entries=[bug]) | ||
230 | 271 | bug = self.launchpad.bugs[0] | ||
231 | 272 | self.assertEqual("test-person", bug.owner.name) | ||
232 | 273 | |||
233 | 274 | def test_invalid_entry_property(self): | ||
234 | 275 | """ | ||
235 | 276 | Sample data for linked entries is validated. | ||
236 | 277 | """ | ||
237 | 278 | bug = dict(owner=dict(foo="bar")) | ||
238 | 279 | self.assertRaises( | ||
239 | 280 | IntegrityError, | ||
240 | 281 | setattr, | ||
241 | 282 | self.launchpad, | ||
242 | 283 | "bugs", | ||
243 | 284 | dict(entries=[bug]), | ||
244 | 285 | ) | ||
245 | 286 | |||
246 | 287 | def test_top_level_collection_property(self): | ||
247 | 258 | """ | 288 | """ |
250 | 259 | Sample collections can be set on L{FakeLaunchpad} instances. They are | 289 | Sample top-level collections can be set on L{FakeLaunchpad} |
251 | 260 | validated the same way other sample data is validated. | 290 | instances. They are validated the same way other sample data is |
252 | 291 | validated. | ||
253 | 261 | """ | 292 | """ |
254 | 262 | branch = dict(name="foo") | 293 | branch = dict(name="foo") |
255 | 263 | self.launchpad.branches = dict(getByUniqueName=lambda name: branch) | 294 | self.launchpad.branches = dict(getByUniqueName=lambda name: branch) |
256 | 264 | branch = self.launchpad.branches.getByUniqueName("foo") | 295 | branch = self.launchpad.branches.getByUniqueName("foo") |
257 | 265 | self.assertEqual("foo", branch.name) | 296 | self.assertEqual("foo", branch.name) |
258 | 266 | 297 | ||
259 | 298 | def test_collection_property(self): | ||
260 | 299 | """ | ||
261 | 300 | Attributes that represent links to collections of other objects are | ||
262 | 301 | set using a dict representing the collection. | ||
263 | 302 | """ | ||
264 | 303 | bug = dict(id="1") | ||
265 | 304 | branch = dict(linked_bugs=dict(entries=[bug])) | ||
266 | 305 | self.launchpad.branches = dict(getByUniqueName=lambda name: branch) | ||
267 | 306 | branch = self.launchpad.branches.getByUniqueName("foo") | ||
268 | 307 | [bug] = list(branch.linked_bugs) | ||
269 | 308 | self.assertEqual("1", bug.id) | ||
270 | 309 | |||
271 | 267 | def test_iterate_collection(self): | 310 | def test_iterate_collection(self): |
272 | 268 | """ | 311 | """ |
273 | 269 | Data for a sample collection set on a L{FakeLaunchpad} instance can be | 312 | Data for a sample collection set on a L{FakeLaunchpad} instance can be |
274 | @@ -277,7 +320,7 @@ class FakeLaunchpadTest(ResourcedTestCase): | |||
275 | 277 | self.assertEqual("1", bug.id) | 320 | self.assertEqual("1", bug.id) |
276 | 278 | self.assertEqual("Bug #1", bug.title) | 321 | self.assertEqual("Bug #1", bug.title) |
277 | 279 | 322 | ||
279 | 280 | def test_collection_with_invalid_entries(self): | 323 | def test_top_level_collection_with_invalid_entries(self): |
280 | 281 | """ | 324 | """ |
281 | 282 | Sample data for each entry in a collection is validated when it's set | 325 | Sample data for each entry in a collection is validated when it's set |
282 | 283 | on a L{FakeLaunchpad} instance. | 326 | on a L{FakeLaunchpad} instance. |
283 | @@ -291,6 +334,20 @@ class FakeLaunchpadTest(ResourcedTestCase): | |||
284 | 291 | dict(entries=[bug]), | 334 | dict(entries=[bug]), |
285 | 292 | ) | 335 | ) |
286 | 293 | 336 | ||
287 | 337 | def test_collection_with_invalid_entries(self): | ||
288 | 338 | """ | ||
289 | 339 | Sample data for each entry in a collection is validated when it's set | ||
290 | 340 | on an attribute representing a link to a collection of objects. | ||
291 | 341 | """ | ||
292 | 342 | bug = dict(foo="bar") | ||
293 | 343 | branch = dict(linked_bugs=dict(entries=[bug])) | ||
294 | 344 | self.launchpad.branches = dict(getByUniqueName=lambda name: branch) | ||
295 | 345 | self.assertRaises( | ||
296 | 346 | IntegrityError, | ||
297 | 347 | self.launchpad.branches.getByUniqueName, | ||
298 | 348 | "foo", | ||
299 | 349 | ) | ||
300 | 350 | |||
301 | 294 | def test_slice_collection(self): | 351 | def test_slice_collection(self): |
302 | 295 | """ | 352 | """ |
303 | 296 | Data for a sample collection set on a L{FakeLaunchpad} instance can be | 353 | Data for a sample collection set on a L{FakeLaunchpad} instance can be |