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
1=== modified file 'charmworld/testing/factory.py'
2--- charmworld/testing/factory.py 2013-09-19 21:54:54 +0000
3+++ charmworld/testing/factory.py 2013-10-03 17:14:08 +0000
4@@ -11,8 +11,10 @@
5 datetime,
6 )
7 import hashlib
8+import magic
9 import os
10 from random import randint
11+import re
12 import random
13 import stat
14 import string
15@@ -34,7 +36,11 @@
16 make_bundle_doc,
17 options_to_storage,
18 )
19-from charmworld.utils import write_locked
20+from charmworld.utils import (
21+ quote_yaml,
22+ unquote_yaml,
23+ write_locked,
24+)
25
26
27 def random_string(length=None):
28@@ -335,10 +341,22 @@
29
30
31 def makeBundle(db, *args, **kwargs):
32+ with_basket = False
33+ if 'with_basket' in kwargs:
34+ with_basket = True
35+ del kwargs['with_basket']
36+
37 bundle_data = get_bundle_data(*args, **kwargs)
38 bundle = Bundle(bundle_data)
39 bundle_data['_id'] = bundle.id
40 db.bundles.insert(bundle_data)
41+
42+ if with_basket:
43+ basket_data = {
44+ '_id': re.sub('\/[^/]*$', '', bundle.id),
45+ 'file_hashes': {}
46+ }
47+ db.baskets.save(basket_data)
48 return bundle, bundle_data
49
50
51@@ -352,6 +370,30 @@
52 return charm_file
53
54
55+def make_bundle_file(db, bundle, path, content=None):
56+ """Create a file in gridfs for a bundle.
57+
58+ Note that these are actually keyed off the basket the bundle is in and so
59+ the supplied bundle must have a basket entry for the file to be created
60+ successfully.
61+
62+ """
63+ fs = Bundle.getfs(db)
64+ if content is None:
65+ content = random_string(10).encode('utf-8')
66+ _id = hashlib.sha256(content).hexdigest()
67+ content_type = magic.from_buffer(content, mime=True)
68+
69+ fs.put(content, contentType=content_type, _id=_id)
70+
71+ # The list of files is actually stored on the basket object.
72+ basket = db.baskets.find_one(bundle.basket_id)
73+ hashes = unquote_yaml(basket.get('file_hashes', {}))
74+ hashes[path] = _id
75+ basket['file_hashes'] = quote_yaml(hashes)
76+ db.baskets.save(basket)
77+
78+
79 def makeTestResult(db, name, branch_spec, status, provider,
80 branch_revision=None, timestamp=None, test_number=None):
81 """Create a test record.
82
83=== modified file 'charmworld/views/api.py'
84--- charmworld/views/api.py 2013-10-02 15:33:27 +0000
85+++ charmworld/views/api.py 2013-10-03 17:14:08 +0000
86@@ -149,7 +149,7 @@
87
88 @staticmethod
89 def _get_api_id(charm):
90- """Return the API id for a Mongo-formatted charm."""
91+ """Return the API ID for a Mongo-formatted charm."""
92 return re.sub('^cs:', '', charm.store_url)
93
94 @staticmethod
95@@ -309,8 +309,77 @@
96 return charm_id, trailing
97
98 @staticmethod
99+ def _parse_bundle_id(path):
100+ """Extract the bundle API ID from a given URL path.
101+
102+ Bundle API IDs beginning with a '~' are considered to include an owner.
103+ Bundle IDs can consist of up to 4 parts.
104+
105+ - owner (optional)
106+ - basket (required)
107+ - version (optional)
108+ - bundle (required)
109+
110+ """
111+ if len(path) < 2:
112+ raise ValueError('The bundle ID does not contain enough elements')
113+
114+ # Determine if the path includes an owner and adjust the backet index
115+ # appropriately.
116+ owner = None
117+ if unquote(path[0])[0] == '~':
118+ owner = path[0]
119+ basket = path[1]
120+ else:
121+ basket = path[0]
122+
123+ # Now check if there's an optional revision section in there after the
124+ # basket name.
125+ version_index = 2 if owner else 1
126+ try:
127+ version = int(path[version_index])
128+ except (IndexError, ValueError):
129+ # If the url isn't long enough to have a version value, or the
130+ # value in that slot is not an integer, then no version was
131+ # provided.
132+ version = None
133+
134+ if version:
135+ if owner:
136+ # URL includes an owner, a basket, and a version.
137+ bundle = path[3]
138+ else:
139+ # URL does not include an owner and only has a basket,
140+ # version, and bundle name.
141+ bundle = path[2]
142+ else:
143+ if owner:
144+ # URL includes an owner, a basket, and a bundle name.
145+ bundle = path[2]
146+ else:
147+ # URL only includes a basket name and a bundle name.
148+ bundle = path[1]
149+
150+ # So now we can pull out the parts that are the ID and the rest will
151+ # be the trailing.
152+ id_length = 2
153+ if owner:
154+ id_length = id_length + 1
155+ if version:
156+ id_length = id_length + 1
157+ parsedId = '/'.join(path[0:id_length])
158+ trailing = path[id_length:]
159+
160+ return parsedId, trailing, {
161+ 'owner': owner,
162+ 'basket': basket,
163+ 'version': version,
164+ 'bundle': bundle
165+ }
166+
167+ @staticmethod
168 def _parse_charm_id(charm_id):
169- """Split a charm id into its component parts.
170+ """Split a charm ID into its component parts.
171
172 Returns a tuple of (owner, series, name, revision).
173 """
174@@ -358,6 +427,34 @@
175 query, sort=[('store_data.revision', pymongo.DESCENDING)])
176 return charm_id, trailing, charm
177
178+ def _find_bundle(self, path):
179+ try:
180+ bundle_id, trailing, bundle_bits = self._parse_bundle_id(path)
181+ except ValueError:
182+ bundle = bundle_id = trailing = bundle_bits = None
183+ else:
184+ if bundle_bits['owner'] and bundle_bits['version']:
185+ # We have all 4 parts needed, just use the ID.
186+ query = {'_id': bundle_id}
187+ elif bundle_bits['owner']:
188+ # This URL includes the owner, basket name, and bundle name.
189+ # The specified bundle may be promulgated or not.
190+ owner = bundle_bits['owner'][1:] # Strip the tilde.
191+ query = {
192+ 'owner': owner,
193+ 'basket_name': bundle_bits['basket'],
194+ 'name': bundle_bits['bundle']
195+ }
196+ else:
197+ query = {
198+ 'basket_name': bundle_bits['basket'],
199+ 'name': bundle_bits['bundle'],
200+ 'promulgated': True
201+ }
202+
203+ bundle = Bundle.from_query(query, self.request.db)
204+ return bundle_id, trailing, bundle
205+
206 def charm(self, path=None):
207 """Retrieve a charm according to its API ID (the path prefix)."""
208 if path is None:
209@@ -370,7 +467,7 @@
210 if trailing is None:
211 return self._charm_details(charm_data)
212 elif trailing == self.ICON_TRAILING:
213- return self._icon(charm)
214+ return self._charm_icon(charm)
215 elif trailing.startswith('file/'):
216 return self._charm_file(charm, trailing)
217 elif trailing == 'qa':
218@@ -393,46 +490,26 @@
219 status_code=200)
220
221 def bundle(self, path=None):
222- """Retrieve a bundle based on id."""
223+ """Retrieve a bundle based on ID."""
224 if path is None:
225 raise HTTPNotFound(self.request.path)
226+ path = list(path)
227+ bundle_id, trailing, bundle = self._find_bundle(path)
228+
229 filename = None
230- path = list(path)
231- # If this is a request for the bundle's icon, we need to remove the
232- # file name from the path so the code below can use the remainder of
233- # the path to find the bundle.
234- if path[-1] == 'icon.svg':
235- filename = path.pop()
236- fullpath = '/'.join(path)
237- if len(path) == 4:
238- # We have an owner, basket name, bundle name, and revision, all
239- # those together are enough to build the bundle's ID.
240- query = {'_id': fullpath}
241- elif len(path) == 3:
242- # This URL includes the owner, basket name, and bundle name. The
243- # specified bundle may be promulgated or not.
244- owner = path[0][1:] # Strip the tilde.
245- query = {'owner': owner, 'basket_name': path[1],
246- 'name': path[2]}
247- elif len(path) == 2:
248- # Since there is no owner in the URL, this is a request for a
249- # promulgated bundle.
250- query = {'basket_name': path[0], 'name': path[1],
251- 'promulgated': True}
252- else:
253- raise HTTPNotFound(self.request.path)
254- bundle = Bundle.from_query(query, self.request.db)
255+
256 if bundle is None:
257 status = 404
258- result = {'type': 'no_such_bundle', 'bundle_id': fullpath}
259+ result = {'type': 'no_such_bundle', 'bundle_id': '/'.join(path)}
260+ return json_response(status, result)
261+ if not trailing:
262+ return json_response(200, bundle)
263+ elif trailing and trailing[0] == self.ICON_TRAILING:
264+ return self._bundle_file(bundle, filename)
265+ elif trailing and trailing[0] == 'file':
266+ return self._bundle_file(bundle, '/'.join(trailing[1:]))
267 else:
268- # If the URL specified one of the bundle's files (as opposed to
269- # the bundle itself), return that file's contents.
270- if filename is not None:
271- return self._bundle_file(bundle, filename)
272- status = 200
273- result = bundle
274- return json_response(status, result)
275+ raise HTTPNotFound('/'.join(path))
276
277 @staticmethod
278 def _get_file_headers(md5sum, content_type=None):
279@@ -478,7 +555,7 @@
280 headerlist=headerlist,
281 status_code=200)
282
283- def _icon(self, charm):
284+ def _charm_icon(self, charm):
285 if (charm.files and
286 charm.files.get(quote_key('icon.svg')) and
287 charm.promulgated):
288
289=== modified file 'charmworld/views/tests/test_api.py'
290--- charmworld/views/tests/test_api.py 2013-09-30 15:26:01 +0000
291+++ charmworld/views/tests/test_api.py 2013-10-03 17:14:08 +0000
292@@ -745,8 +745,8 @@
293
294 def test_invalid_path(self):
295 self.makeBundle(name='bat')
296- with self.assertRaises(HTTPNotFound):
297- self.get_response('bundle', 'foo')
298+ response = self.get_response('bundle', 'foo')
299+ self.assertEqual(404, response.status_code)
300
301 def test_not_found(self):
302 self.makeBundle(name='bat')
303@@ -802,6 +802,37 @@
304 u'title': u'',
305 })
306
307+ def test_extracting_bundle_id_with_trailing_full_id(self):
308+ # If there are trailing characters after the bundle ID,
309+ # they are returned in "trailing".
310+ bundle_id, trailing, bits = self.api_class._parse_bundle_id(
311+ '~bac/byobu/4/bat/file/README'.split('/'))
312+ self.assertEqual('~bac/byobu/4/bat', bundle_id)
313+ self.assertEqual(['file', 'README'], trailing)
314+
315+ def test_extracting_bundle_id_with_trailing_no_owner(self):
316+ bundle_id, trailing, bits = self.api_class._parse_bundle_id(
317+ 'byobu/4/bat/file/README'.split('/'))
318+ self.assertEqual('byobu/4/bat', bundle_id)
319+ self.assertEqual(['file', 'README'], trailing)
320+
321+ def test_extracting_bundle_id_with_trailing_no_owner_or_version(self):
322+ bundle_id, trailing, bits = self.api_class._parse_bundle_id(
323+ 'byobu/bat/file/README'.split('/'))
324+ self.assertEqual('byobu/bat', bundle_id)
325+ self.assertEqual(['file', 'README'], trailing)
326+
327+ def test_bundle_readme(self):
328+ # Test that we can store a readme file and get it back out in a
329+ # response with the url bundle/id/file/README or something like that.
330+ bundle = self.makeBundle(
331+ name='bat', owner='bac',
332+ basket_with_rev='byobu/4', with_basket=True)
333+ factory.make_bundle_file(self.db, bundle, u'README', 'Test Content')
334+ response = self.get_response('bundle', '~bac/byobu/4/bat/file/README')
335+ self.assertEqual('Test Content', response.body)
336+ self.assertEqual(u'text/plain', response.content_type)
337+
338
339 class TestAPI3Bundles(TestAPIBundles, API3Mixin):
340 """Test API 3 bundle endpoint."""

Subscribers

People subscribed via source and target branches