Merge lp:~rharding/charmworld/bundle-metadata into lp:~juju-jitsu/charmworld/trunk

Proposed by Richard Harding
Status: Merged
Approved by: Aaron Bentley
Approved revision: 405
Merged at revision: 404
Proposed branch: lp:~rharding/charmworld/bundle-metadata
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 340 lines (+191/-41)
3 files modified
charmworld/testing/factory.py (+43/-1)
charmworld/views/api.py (+115/-38)
charmworld/views/tests/test_api.py (+33/-2)
To merge this branch: bzr merge lp:~rharding/charmworld/bundle-metadata
Reviewer Review Type Date Requested Status
Juju Gui Bot continuous-integration Approve
Benji York (community) Approve
Review via email: mp+188905@code.launchpad.net

Commit message

Implement the /file/xxxx api off of the bundle api space.

Description of the change

This branch implements the /file/xxxx api off of the bundle api space. This is required to pull README content. This duplicates some of the style of the api parsing from the Charm code with some changes to be a little simpler. Things are kept as lists until the last minute, etc.

The general idea is to get back to the idea of an api call with a /bundle/some/id/trailing/bits so that we can implement extra calls on a bundle. We'll need a /related trailing call in the future to provide the bundles that are in the same basket, for instance.

The _charm_icon stuff is a start because we'll need to have the ability to load the bundle icon by default if the charm is not promulgated/has one much like we do with charm icon. It just won't have the category complication part.

makeBundle was updated so that you can force the basket record to be created as well. This was required to be able to load the test file object into gridfs to make sure we could fetch it via the api response.

QA:

pull the changes, if you've got the demo bundles loaded you should be able to hit:

http://charmworld:2464/api/3/bundle/~bac/wiki/wiki/file/icon.svg
http://charmworld:2464/api/3/bundle/~bac/wiki/wiki/file/README

and get the proper response.

To post a comment you must log in.
Revision history for this message
Richard Harding (rharding) wrote :

The big goal was to get back the idea of the trailing. I kept it a [] vs a string so that it's easier to find the bits in it.

One thing I changed to make consistent was the custom json response if the bundle id was invalid. That matched the charm behavior vs just throwing the not found exception. We had agreed during API design that all failures should provide some sort of payload of helpful info if possible.

The _charm_icon stuff is a start because we'll need to have the ability to load the bundle icon by default if the charm is not promulgated/has one much like we do with charm icon. It just won't have the category complication part.

As is the old tests are passing and were very helpful. The next step is to get the readme into the test bundle and have tests against getting it back out again via the url /~owner/basket/bundle/file/README and such.

Revision history for this message
Benji York (benji) wrote :

There are a few small things that should be addressed, but otherwise
this looks good.

It would be nice if the comment that includes "Bundle IDs can consist of
up to 4 parts." spelled out explicitly what those four parts are.

Line 96 of the diff: We should capitalize "id" unless we're also talking
about the "ego" and "super-ego". I also feel that we should capitalize
"URL" but that may be more of a personal preference, so I'll leave that
one up to you.

Line 98 of the diff: A "split point" is referenced, but I don't see one.
There is an identical comment earlier in the file; perhaps this is a
copy/paste bug.

The try/except around lines 110 through 119 of the diff seems a little
big and the except is for two unrelated exceptions. I think it would be
better to have less code in the try and handle each exception
independently.

Lines 130 and 162 of the diff: s/id/ID/

make_bundle_file could use a docstring.

Since test_extracting_bundle_id_with_trailing contains three independent
setup/assert blocks it would be better to have it broken into three
separate tests (each with their own customized version of the comment at
the top of the test now).

review: Approve
Revision history for this message
Richard Harding (rharding) wrote :

> There are a few small things that should be addressed, but otherwise
> this looks good.
>
> It would be nice if the comment that includes "Bundle IDs can consist of
> up to 4 parts." spelled out explicitly what those four parts are.

Updated the comment with the parts vs the sample urls. I agree that it's clearer
that way.

> Line 96 of the diff: We should capitalize "id" unless we're also talking
> about the "ego" and "super-ego". I also feel that we should capitalize
> "URL" but that may be more of a personal preference, so I'll leave that
> one up to you.

Updated, not a fan of caps URL, but it'll be more consistent.

> Line 98 of the diff: A "split point" is referenced, but I don't see one.
> There is an identical comment earlier in the file; perhaps this is a
> copy/paste bug.

Agreed, updated comment to note the position change vs the 'split point'.

> The try/except around lines 110 through 119 of the diff seems a little
> big and the except is for two unrelated exceptions. I think it would be
> better to have less code in the try and handle each exception
> independently.

Agree in a sense. I moved the try/except around the one line of code that's attempting to find a version number. Both exceptions come out of that line of code though, so left the two exceptions to be caught there. Added a comment on why each was included to help clarify that one can come from the int() call and the other from the [index] use.

> Lines 130 and 162 of the diff: s/id/ID/
>
> make_bundle_file could use a docstring.

Updated! Thanks, saw a lot of methods sans docstrings and trying to find balance. I'm usually a docstring all the things view myself.

> Since test_extracting_bundle_id_with_trailing contains three independent
> setup/assert blocks it would be better to have it broken into three
> separate tests (each with their own customized version of the comment at
> the top of the test now).

Updated.

Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
More details in the following jenkins job:
http://10.55.32.130:8080/job/charmworld-autoland-lxc/1/
Executed test runs:

review: Needs Fixing (continuous-integration)
Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
No commit message was specified in the merge proposal. Hit 'Add commit message' on the merge proposal web page or follow the link below. You can approve the merge proposal yourself to rerun.
https://code.launchpad.net/~rharding/charmworld/bundle-metadata/+merge/188905/+edit-commit-message

review: Needs Fixing (continuous-integration)
Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
More details in the following jenkins job:
http://10.55.32.130:8080/job/charmworld-autoland-lxc/5/
Executed test runs:

review: Needs Fixing (continuous-integration)
Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
More details in the following jenkins job:
http://10.55.32.130:8080/job/charmworld-autoland-lxc/6/
Executed test runs:

review: Needs Fixing (continuous-integration)
Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
More details in the following jenkins job:
http://10.55.32.130:8080/job/charmworld-autoland-lxc/12/
Executed test runs:

review: Needs Fixing (continuous-integration)
Revision history for this message
Juju Gui Bot (juju-gui-bot) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'charmworld/testing/factory.py'
--- charmworld/testing/factory.py 2013-09-19 21:54:54 +0000
+++ charmworld/testing/factory.py 2013-10-03 17:14:08 +0000
@@ -11,8 +11,10 @@
11 datetime,11 datetime,
12)12)
13import hashlib13import hashlib
14import magic
14import os15import os
15from random import randint16from random import randint
17import re
16import random18import random
17import stat19import stat
18import string20import string
@@ -34,7 +36,11 @@
34 make_bundle_doc,36 make_bundle_doc,
35 options_to_storage,37 options_to_storage,
36)38)
37from charmworld.utils import write_locked39from charmworld.utils import (
40 quote_yaml,
41 unquote_yaml,
42 write_locked,
43)
3844
3945
40def random_string(length=None):46def random_string(length=None):
@@ -335,10 +341,22 @@
335341
336342
337def makeBundle(db, *args, **kwargs):343def makeBundle(db, *args, **kwargs):
344 with_basket = False
345 if 'with_basket' in kwargs:
346 with_basket = True
347 del kwargs['with_basket']
348
338 bundle_data = get_bundle_data(*args, **kwargs)349 bundle_data = get_bundle_data(*args, **kwargs)
339 bundle = Bundle(bundle_data)350 bundle = Bundle(bundle_data)
340 bundle_data['_id'] = bundle.id351 bundle_data['_id'] = bundle.id
341 db.bundles.insert(bundle_data)352 db.bundles.insert(bundle_data)
353
354 if with_basket:
355 basket_data = {
356 '_id': re.sub('\/[^/]*$', '', bundle.id),
357 'file_hashes': {}
358 }
359 db.baskets.save(basket_data)
342 return bundle, bundle_data360 return bundle, bundle_data
343361
344362
@@ -352,6 +370,30 @@
352 return charm_file370 return charm_file
353371
354372
373def make_bundle_file(db, bundle, path, content=None):
374 """Create a file in gridfs for a bundle.
375
376 Note that these are actually keyed off the basket the bundle is in and so
377 the supplied bundle must have a basket entry for the file to be created
378 successfully.
379
380 """
381 fs = Bundle.getfs(db)
382 if content is None:
383 content = random_string(10).encode('utf-8')
384 _id = hashlib.sha256(content).hexdigest()
385 content_type = magic.from_buffer(content, mime=True)
386
387 fs.put(content, contentType=content_type, _id=_id)
388
389 # The list of files is actually stored on the basket object.
390 basket = db.baskets.find_one(bundle.basket_id)
391 hashes = unquote_yaml(basket.get('file_hashes', {}))
392 hashes[path] = _id
393 basket['file_hashes'] = quote_yaml(hashes)
394 db.baskets.save(basket)
395
396
355def makeTestResult(db, name, branch_spec, status, provider,397def makeTestResult(db, name, branch_spec, status, provider,
356 branch_revision=None, timestamp=None, test_number=None):398 branch_revision=None, timestamp=None, test_number=None):
357 """Create a test record.399 """Create a test record.
358400
=== modified file 'charmworld/views/api.py'
--- charmworld/views/api.py 2013-10-02 15:33:27 +0000
+++ charmworld/views/api.py 2013-10-03 17:14:08 +0000
@@ -149,7 +149,7 @@
149149
150 @staticmethod150 @staticmethod
151 def _get_api_id(charm):151 def _get_api_id(charm):
152 """Return the API id for a Mongo-formatted charm."""152 """Return the API ID for a Mongo-formatted charm."""
153 return re.sub('^cs:', '', charm.store_url)153 return re.sub('^cs:', '', charm.store_url)
154154
155 @staticmethod155 @staticmethod
@@ -309,8 +309,77 @@
309 return charm_id, trailing309 return charm_id, trailing
310310
311 @staticmethod311 @staticmethod
312 def _parse_bundle_id(path):
313 """Extract the bundle API ID from a given URL path.
314
315 Bundle API IDs beginning with a '~' are considered to include an owner.
316 Bundle IDs can consist of up to 4 parts.
317
318 - owner (optional)
319 - basket (required)
320 - version (optional)
321 - bundle (required)
322
323 """
324 if len(path) < 2:
325 raise ValueError('The bundle ID does not contain enough elements')
326
327 # Determine if the path includes an owner and adjust the backet index
328 # appropriately.
329 owner = None
330 if unquote(path[0])[0] == '~':
331 owner = path[0]
332 basket = path[1]
333 else:
334 basket = path[0]
335
336 # Now check if there's an optional revision section in there after the
337 # basket name.
338 version_index = 2 if owner else 1
339 try:
340 version = int(path[version_index])
341 except (IndexError, ValueError):
342 # If the url isn't long enough to have a version value, or the
343 # value in that slot is not an integer, then no version was
344 # provided.
345 version = None
346
347 if version:
348 if owner:
349 # URL includes an owner, a basket, and a version.
350 bundle = path[3]
351 else:
352 # URL does not include an owner and only has a basket,
353 # version, and bundle name.
354 bundle = path[2]
355 else:
356 if owner:
357 # URL includes an owner, a basket, and a bundle name.
358 bundle = path[2]
359 else:
360 # URL only includes a basket name and a bundle name.
361 bundle = path[1]
362
363 # So now we can pull out the parts that are the ID and the rest will
364 # be the trailing.
365 id_length = 2
366 if owner:
367 id_length = id_length + 1
368 if version:
369 id_length = id_length + 1
370 parsedId = '/'.join(path[0:id_length])
371 trailing = path[id_length:]
372
373 return parsedId, trailing, {
374 'owner': owner,
375 'basket': basket,
376 'version': version,
377 'bundle': bundle
378 }
379
380 @staticmethod
312 def _parse_charm_id(charm_id):381 def _parse_charm_id(charm_id):
313 """Split a charm id into its component parts.382 """Split a charm ID into its component parts.
314383
315 Returns a tuple of (owner, series, name, revision).384 Returns a tuple of (owner, series, name, revision).
316 """385 """
@@ -358,6 +427,34 @@
358 query, sort=[('store_data.revision', pymongo.DESCENDING)])427 query, sort=[('store_data.revision', pymongo.DESCENDING)])
359 return charm_id, trailing, charm428 return charm_id, trailing, charm
360429
430 def _find_bundle(self, path):
431 try:
432 bundle_id, trailing, bundle_bits = self._parse_bundle_id(path)
433 except ValueError:
434 bundle = bundle_id = trailing = bundle_bits = None
435 else:
436 if bundle_bits['owner'] and bundle_bits['version']:
437 # We have all 4 parts needed, just use the ID.
438 query = {'_id': bundle_id}
439 elif bundle_bits['owner']:
440 # This URL includes the owner, basket name, and bundle name.
441 # The specified bundle may be promulgated or not.
442 owner = bundle_bits['owner'][1:] # Strip the tilde.
443 query = {
444 'owner': owner,
445 'basket_name': bundle_bits['basket'],
446 'name': bundle_bits['bundle']
447 }
448 else:
449 query = {
450 'basket_name': bundle_bits['basket'],
451 'name': bundle_bits['bundle'],
452 'promulgated': True
453 }
454
455 bundle = Bundle.from_query(query, self.request.db)
456 return bundle_id, trailing, bundle
457
361 def charm(self, path=None):458 def charm(self, path=None):
362 """Retrieve a charm according to its API ID (the path prefix)."""459 """Retrieve a charm according to its API ID (the path prefix)."""
363 if path is None:460 if path is None:
@@ -370,7 +467,7 @@
370 if trailing is None:467 if trailing is None:
371 return self._charm_details(charm_data)468 return self._charm_details(charm_data)
372 elif trailing == self.ICON_TRAILING:469 elif trailing == self.ICON_TRAILING:
373 return self._icon(charm)470 return self._charm_icon(charm)
374 elif trailing.startswith('file/'):471 elif trailing.startswith('file/'):
375 return self._charm_file(charm, trailing)472 return self._charm_file(charm, trailing)
376 elif trailing == 'qa':473 elif trailing == 'qa':
@@ -393,46 +490,26 @@
393 status_code=200)490 status_code=200)
394491
395 def bundle(self, path=None):492 def bundle(self, path=None):
396 """Retrieve a bundle based on id."""493 """Retrieve a bundle based on ID."""
397 if path is None:494 if path is None:
398 raise HTTPNotFound(self.request.path)495 raise HTTPNotFound(self.request.path)
496 path = list(path)
497 bundle_id, trailing, bundle = self._find_bundle(path)
498
399 filename = None499 filename = None
400 path = list(path)500
401 # If this is a request for the bundle's icon, we need to remove the
402 # file name from the path so the code below can use the remainder of
403 # the path to find the bundle.
404 if path[-1] == 'icon.svg':
405 filename = path.pop()
406 fullpath = '/'.join(path)
407 if len(path) == 4:
408 # We have an owner, basket name, bundle name, and revision, all
409 # those together are enough to build the bundle's ID.
410 query = {'_id': fullpath}
411 elif len(path) == 3:
412 # This URL includes the owner, basket name, and bundle name. The
413 # specified bundle may be promulgated or not.
414 owner = path[0][1:] # Strip the tilde.
415 query = {'owner': owner, 'basket_name': path[1],
416 'name': path[2]}
417 elif len(path) == 2:
418 # Since there is no owner in the URL, this is a request for a
419 # promulgated bundle.
420 query = {'basket_name': path[0], 'name': path[1],
421 'promulgated': True}
422 else:
423 raise HTTPNotFound(self.request.path)
424 bundle = Bundle.from_query(query, self.request.db)
425 if bundle is None:501 if bundle is None:
426 status = 404502 status = 404
427 result = {'type': 'no_such_bundle', 'bundle_id': fullpath}503 result = {'type': 'no_such_bundle', 'bundle_id': '/'.join(path)}
504 return json_response(status, result)
505 if not trailing:
506 return json_response(200, bundle)
507 elif trailing and trailing[0] == self.ICON_TRAILING:
508 return self._bundle_file(bundle, filename)
509 elif trailing and trailing[0] == 'file':
510 return self._bundle_file(bundle, '/'.join(trailing[1:]))
428 else:511 else:
429 # If the URL specified one of the bundle's files (as opposed to512 raise HTTPNotFound('/'.join(path))
430 # the bundle itself), return that file's contents.
431 if filename is not None:
432 return self._bundle_file(bundle, filename)
433 status = 200
434 result = bundle
435 return json_response(status, result)
436513
437 @staticmethod514 @staticmethod
438 def _get_file_headers(md5sum, content_type=None):515 def _get_file_headers(md5sum, content_type=None):
@@ -478,7 +555,7 @@
478 headerlist=headerlist,555 headerlist=headerlist,
479 status_code=200)556 status_code=200)
480557
481 def _icon(self, charm):558 def _charm_icon(self, charm):
482 if (charm.files and559 if (charm.files and
483 charm.files.get(quote_key('icon.svg')) and560 charm.files.get(quote_key('icon.svg')) and
484 charm.promulgated):561 charm.promulgated):
485562
=== modified file 'charmworld/views/tests/test_api.py'
--- charmworld/views/tests/test_api.py 2013-09-30 15:26:01 +0000
+++ charmworld/views/tests/test_api.py 2013-10-03 17:14:08 +0000
@@ -745,8 +745,8 @@
745745
746 def test_invalid_path(self):746 def test_invalid_path(self):
747 self.makeBundle(name='bat')747 self.makeBundle(name='bat')
748 with self.assertRaises(HTTPNotFound):748 response = self.get_response('bundle', 'foo')
749 self.get_response('bundle', 'foo')749 self.assertEqual(404, response.status_code)
750750
751 def test_not_found(self):751 def test_not_found(self):
752 self.makeBundle(name='bat')752 self.makeBundle(name='bat')
@@ -802,6 +802,37 @@
802 u'title': u'',802 u'title': u'',
803 })803 })
804804
805 def test_extracting_bundle_id_with_trailing_full_id(self):
806 # If there are trailing characters after the bundle ID,
807 # they are returned in "trailing".
808 bundle_id, trailing, bits = self.api_class._parse_bundle_id(
809 '~bac/byobu/4/bat/file/README'.split('/'))
810 self.assertEqual('~bac/byobu/4/bat', bundle_id)
811 self.assertEqual(['file', 'README'], trailing)
812
813 def test_extracting_bundle_id_with_trailing_no_owner(self):
814 bundle_id, trailing, bits = self.api_class._parse_bundle_id(
815 'byobu/4/bat/file/README'.split('/'))
816 self.assertEqual('byobu/4/bat', bundle_id)
817 self.assertEqual(['file', 'README'], trailing)
818
819 def test_extracting_bundle_id_with_trailing_no_owner_or_version(self):
820 bundle_id, trailing, bits = self.api_class._parse_bundle_id(
821 'byobu/bat/file/README'.split('/'))
822 self.assertEqual('byobu/bat', bundle_id)
823 self.assertEqual(['file', 'README'], trailing)
824
825 def test_bundle_readme(self):
826 # Test that we can store a readme file and get it back out in a
827 # response with the url bundle/id/file/README or something like that.
828 bundle = self.makeBundle(
829 name='bat', owner='bac',
830 basket_with_rev='byobu/4', with_basket=True)
831 factory.make_bundle_file(self.db, bundle, u'README', 'Test Content')
832 response = self.get_response('bundle', '~bac/byobu/4/bat/file/README')
833 self.assertEqual('Test Content', response.body)
834 self.assertEqual(u'text/plain', response.content_type)
835
805836
806class TestAPI3Bundles(TestAPIBundles, API3Mixin):837class TestAPI3Bundles(TestAPIBundles, API3Mixin):
807 """Test API 3 bundle endpoint."""838 """Test API 3 bundle endpoint."""

Subscribers

People subscribed via source and target branches