Merge ~cjwatson/launchpadlib:fakelaunchpad-better-resource-creation into launchpadlib: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)
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
diff --git a/NEWS.rst b/NEWS.rst
index b3475eb..29fc779 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -7,6 +7,8 @@ NEWS for launchpadlib
7- Move the ``keyring`` dependency to a new ``keyring`` extra.7- Move the ``keyring`` dependency to a new ``keyring`` extra.
8- Support setting fake methods that return None on instances of8- Support setting fake methods that return None on instances of
9 ``launchpadlib.testing.launchpad.FakeLaunchpad``.9 ``launchpadlib.testing.launchpad.FakeLaunchpad``.
10- Allow setting ``FakeLaunchpad`` sample data with attributes that are links
11 to other entries or collections.
1012
111.10.18 (2022-10-28)131.10.18 (2022-10-28)
12====================14====================
diff --git a/src/launchpadlib/testing/launchpad.py b/src/launchpadlib/testing/launchpad.py
index a079da1..3b68798 100644
--- a/src/launchpadlib/testing/launchpad.py
+++ b/src/launchpadlib/testing/launchpad.py
@@ -76,8 +76,6 @@ import sys
76if sys.version_info[0] >= 3:76if sys.version_info[0] >= 3:
77 basestring = str77 basestring = str
7878
79JSON_MEDIA_TYPE = "application/json"
80
8179
82class IntegrityError(Exception):80class IntegrityError(Exception):
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."""
@@ -185,6 +183,11 @@ def strip_suffix(string, suffix):
185 return string183 return string
186184
187185
186def wadl_tag(tag_name):
187 """Scope a tag name with the WADL namespace."""
188 return "{http://research.sun.com/wadl/2006/10}" + tag_name
189
190
188class FakeResource(object):191class FakeResource(object):
189 """192 """
190 Represents valid sample data on L{FakeLaunchpad} instances.193 Represents valid sample data on L{FakeLaunchpad} instances.
@@ -283,16 +286,17 @@ class FakeResource(object):
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}
284 attribute.287 attribute.
285 """288 """
286 root_resource = self._application.get_resource_by_path("")289 xml_id = self._find_representation_id(self._resource_type, "get")
290 representation = self._application.representation_definitions[xml_id]
291 params = {
292 child.name: child
293 for child in representation.params(representation)
294 }
287 is_link = False295 is_link = False
288 param = root_resource.get_parameter(296 param = params.get(name + "_collection_link")
289 name + "_collection_link", JSON_MEDIA_TYPE
290 )
291 if param is None:297 if param is None:
292 is_link = True298 is_link = True
293 param = root_resource.get_parameter(299 param = params.get(name + "_link")
294 name + "_link", JSON_MEDIA_TYPE
295 )
296 if param is None:300 if param is None:
297 raise IntegrityError("%s isn't a valid property." % (name,))301 raise IntegrityError("%s isn't a valid property." % (name,))
298 resource_type = self._get_resource_type(param)302 resource_type = self._get_resource_type(param)
@@ -319,7 +323,7 @@ class FakeResource(object):
319 @return: The resource type for the parameter, or None if one isn't323 @return: The resource type for the parameter, or None if one isn't
320 available.324 available.
321 """325 """
322 [link] = list(param.tag)326 link = param.tag.find(wadl_tag("link"))
323 name = link.get("resource_type")327 name = link.get("resource_type")
324 return self._application.get_resource_type(name)328 return self._application.get_resource_type(name)
325329
@@ -411,21 +415,34 @@ class FakeResource(object):
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.
412 """416 """
413 representation = self._application.representation_definitions[xml_id]417 representation = self._application.representation_definitions[xml_id]
414 parameters = {child.get("name"): child for child in representation.tag}418 params = {
415 if name not in parameters:419 child.name: child
416 raise IntegrityError("%s not found" % name)420 for child in representation.params(representation)
417 parameter = parameters[name]421 }
418 data_type = parameter.get("type")422 if name + "_collection_link" in params:
419 if data_type is None:423 resource_type = self._get_resource_type(
420 if not isinstance(value, basestring):424 params[name + "_collection_link"]
421 raise IntegrityError(425 )
422 "%s is not a str or unicode for %s" % (value, name)426 child_name, child_resource_type = self._check_collection_type(
423 )427 resource_type, value
424 elif data_type == "xsd:dateTime":428 )
425 if not isinstance(value, datetime):429 elif name + "_link" in params:
426 raise IntegrityError(430 resource_type = self._get_resource_type(params[name + "_link"])
427 "%s is not a datetime for %s" % (value, name)431 self._check_resource_type(resource_type, value)
428 )432 else:
433 param = params.get(name)
434 if param is None:
435 raise IntegrityError("%s not found" % name)
436 if param.type is None:
437 if not isinstance(value, basestring):
438 raise IntegrityError(
439 "%s is not a str or unicode for %s" % (value, name)
440 )
441 elif param.type == "xsd:dateTime":
442 if not isinstance(value, datetime):
443 raise IntegrityError(
444 "%s is not a datetime for %s" % (value, name)
445 )
429446
430 def _get_method(self, resource_type, name):447 def _get_method(self, resource_type, name):
431 """Get the C{name} method on C{resource_type}.448 """Get the C{name} method on C{resource_type}.
@@ -486,9 +503,23 @@ class FakeResource(object):
486 if xml_id not in self._application.resource_types:503 if xml_id not in self._application.resource_types:
487 xml_id += "-resource"504 xml_id += "-resource"
488 result_resource_type = self._application.resource_types[xml_id]505 result_resource_type = self._application.resource_types[xml_id]
489 self._check_resource_type(result_resource_type, result)506 if xml_id.endswith("-page-resource"):
490 # XXX: Should this wrap in collection?507 name, child_resource_type = self._check_collection_type(
491 return FakeResource(self._application, result_resource_type, result)508 result_resource_type, result
509 )
510 return FakeCollection(
511 self._application,
512 result_resource_type,
513 result,
514 name,
515 child_resource_type,
516 )
517 else:
518 self._check_resource_type(result_resource_type, result)
519 resource = FakeEntry(self._application, result_resource_type)
520 for child_name, child_value in result.items():
521 setattr(resource, child_name, child_value)
522 return resource
492523
493 def _get_child_resource_type(self, resource_type):524 def _get_child_resource_type(self, resource_type):
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.
diff --git a/src/launchpadlib/testing/testing-wadl.xml b/src/launchpadlib/testing/testing-wadl.xml
index cc33f88..650711d 100644
--- a/src/launchpadlib/testing/testing-wadl.xml
+++ b/src/launchpadlib/testing/testing-wadl.xml
@@ -187,6 +187,10 @@
187 <wadl:param style="plain" required="true"187 <wadl:param style="plain" required="true"
188 path="$['name']" name="name">188 path="$['name']" name="name">
189 </wadl:param>189 </wadl:param>
190 <wadl:param style="plain" required="true" name="linked_bugs_collection_link" path="$['linked_bugs_collection_link']">
191 <wadl:doc xmlns="http://www.w3.org/1999/xhtml">The bugs linked to this branch.</wadl:doc>
192 <wadl:link resource_type="https://api.example.com/testing/#bug-page-resource"/>
193 </wadl:param>
190 </wadl:representation>194 </wadl:representation>
191195
192 <wadl:resource_type id="branches">196 <wadl:resource_type id="branches">
@@ -341,6 +345,10 @@
341 <wadl:param style="plain" required="true"345 <wadl:param style="plain" required="true"
342 path="$['title']" name="title">346 path="$['title']" name="title">
343 </wadl:param>347 </wadl:param>
348 <wadl:param style="plain" required="true" name="owner_link" path="$['owner_link']">
349 <wadl:doc xmlns="http://www.w3.org/1999/xhtml">The owner's IPerson</wadl:doc>
350 <wadl:link resource_type="https://api.example.com/testing/#person"/>
351 </wadl:param>
344 </wadl:representation>352 </wadl:representation>
345353
346</wadl:application>354</wadl:application>
diff --git a/src/launchpadlib/testing/tests/test_launchpad.py b/src/launchpadlib/testing/tests/test_launchpad.py
index 956fc06..cad2f10 100644
--- a/src/launchpadlib/testing/tests/test_launchpad.py
+++ b/src/launchpadlib/testing/tests/test_launchpad.py
@@ -42,14 +42,23 @@ class FakeResourceTest(ResourcedTestCase):
4242
43 resources = [("launchpad", FakeLaunchpadResource())]43 resources = [("launchpad", FakeLaunchpadResource())]
4444
45 def test_repr(self):45 def test_repr_entry(self):
46 """A custom C{__repr__} is provided for L{FakeResource}s."""46 """A custom C{__repr__} is provided for L{FakeEntry}s."""
47 bug = dict()
48 self.launchpad.bugs = dict(entries=[bug])
49 [bug] = list(self.launchpad.bugs)
50 self.assertEqual(
51 "<FakeEntry bug object at %s>" % hex(id(bug)), repr(bug)
52 )
53
54 def test_repr_collection(self):
55 """A custom C{__repr__} is provided for L{FakeCollection}s."""
47 branches = dict(total_size="test-branch")56 branches = dict(total_size="test-branch")
48 self.launchpad.me = dict(getBranches=lambda statuses: branches)57 self.launchpad.me = dict(getBranches=lambda statuses: branches)
49 branches = self.launchpad.me.getBranches([])58 branches = self.launchpad.me.getBranches([])
50 obj_id = hex(id(branches))59 obj_id = hex(id(branches))
51 self.assertEqual(60 self.assertEqual(
52 "<FakeResource branch-page-resource object at %s>" % obj_id,61 "<FakeCollection branch-page-resource object at %s>" % obj_id,
53 repr(branches),62 repr(branches),
54 )63 )
5564
@@ -72,9 +81,7 @@ class FakeResourceTest(ResourcedTestCase):
72 bug = dict(id="1", title="Bug #1")81 bug = dict(id="1", title="Bug #1")
73 self.launchpad.bugs = dict(entries=[bug])82 self.launchpad.bugs = dict(entries=[bug])
74 [bug] = list(self.launchpad.bugs)83 [bug] = list(self.launchpad.bugs)
75 self.assertEqual(84 self.assertEqual("<FakeEntry bug 1 at %s>" % hex(id(bug)), repr(bug))
76 "<FakeResource bug 1 at %s>" % hex(id(bug)), repr(bug)
77 )
7885
7986
80class FakeLaunchpadTest(ResourcedTestCase):87class FakeLaunchpadTest(ResourcedTestCase):
@@ -254,16 +261,52 @@ class FakeLaunchpadTest(ResourcedTestCase):
254 self.launchpad.branches = dict(getByUniqueName=lambda name: None)261 self.launchpad.branches = dict(getByUniqueName=lambda name: None)
255 self.assertIsNone(self.launchpad.branches.getByUniqueName("foo"))262 self.assertIsNone(self.launchpad.branches.getByUniqueName("foo"))
256263
257 def test_collection_property(self):264 def test_entry_property(self):
265 """
266 Attributes that represent links to other objects are set using a
267 dict representing the object.
268 """
269 bug = dict(owner=dict(name="test-person"))
270 self.launchpad.bugs = dict(entries=[bug])
271 bug = self.launchpad.bugs[0]
272 self.assertEqual("test-person", bug.owner.name)
273
274 def test_invalid_entry_property(self):
275 """
276 Sample data for linked entries is validated.
277 """
278 bug = dict(owner=dict(foo="bar"))
279 self.assertRaises(
280 IntegrityError,
281 setattr,
282 self.launchpad,
283 "bugs",
284 dict(entries=[bug]),
285 )
286
287 def test_top_level_collection_property(self):
258 """288 """
259 Sample collections can be set on L{FakeLaunchpad} instances. They are289 Sample top-level collections can be set on L{FakeLaunchpad}
260 validated the same way other sample data is validated.290 instances. They are validated the same way other sample data is
291 validated.
261 """292 """
262 branch = dict(name="foo")293 branch = dict(name="foo")
263 self.launchpad.branches = dict(getByUniqueName=lambda name: branch)294 self.launchpad.branches = dict(getByUniqueName=lambda name: branch)
264 branch = self.launchpad.branches.getByUniqueName("foo")295 branch = self.launchpad.branches.getByUniqueName("foo")
265 self.assertEqual("foo", branch.name)296 self.assertEqual("foo", branch.name)
266297
298 def test_collection_property(self):
299 """
300 Attributes that represent links to collections of other objects are
301 set using a dict representing the collection.
302 """
303 bug = dict(id="1")
304 branch = dict(linked_bugs=dict(entries=[bug]))
305 self.launchpad.branches = dict(getByUniqueName=lambda name: branch)
306 branch = self.launchpad.branches.getByUniqueName("foo")
307 [bug] = list(branch.linked_bugs)
308 self.assertEqual("1", bug.id)
309
267 def test_iterate_collection(self):310 def test_iterate_collection(self):
268 """311 """
269 Data for a sample collection set on a L{FakeLaunchpad} instance can be312 Data for a sample collection set on a L{FakeLaunchpad} instance can be
@@ -277,7 +320,7 @@ class FakeLaunchpadTest(ResourcedTestCase):
277 self.assertEqual("1", bug.id)320 self.assertEqual("1", bug.id)
278 self.assertEqual("Bug #1", bug.title)321 self.assertEqual("Bug #1", bug.title)
279322
280 def test_collection_with_invalid_entries(self):323 def test_top_level_collection_with_invalid_entries(self):
281 """324 """
282 Sample data for each entry in a collection is validated when it's set325 Sample data for each entry in a collection is validated when it's set
283 on a L{FakeLaunchpad} instance.326 on a L{FakeLaunchpad} instance.
@@ -291,6 +334,20 @@ class FakeLaunchpadTest(ResourcedTestCase):
291 dict(entries=[bug]),334 dict(entries=[bug]),
292 )335 )
293336
337 def test_collection_with_invalid_entries(self):
338 """
339 Sample data for each entry in a collection is validated when it's set
340 on an attribute representing a link to a collection of objects.
341 """
342 bug = dict(foo="bar")
343 branch = dict(linked_bugs=dict(entries=[bug]))
344 self.launchpad.branches = dict(getByUniqueName=lambda name: branch)
345 self.assertRaises(
346 IntegrityError,
347 self.launchpad.branches.getByUniqueName,
348 "foo",
349 )
350
294 def test_slice_collection(self):351 def test_slice_collection(self):
295 """352 """
296 Data for a sample collection set on a L{FakeLaunchpad} instance can be353 Data for a sample collection set on a L{FakeLaunchpad} instance can be

Subscribers

People subscribed via source and target branches