Merge lp:~benji/charmworld/bundle-deploy-metric-api into lp:charmworld

Proposed by Benji York
Status: Merged
Approved by: Benji York
Approved revision: 456
Merged at revision: 454
Proposed branch: lp:~benji/charmworld/bundle-deploy-metric-api
Merge into: lp:charmworld
Diff against target: 368 lines (+173/-26)
6 files modified
charmworld/models.py (+26/-17)
charmworld/testing/factory.py (+5/-1)
charmworld/tests/test_models.py (+26/-4)
charmworld/views/api/__init__.py (+37/-0)
charmworld/views/tests/test_api.py (+2/-4)
charmworld/views/tests/test_metrics_api.py (+77/-0)
To merge this branch: bzr merge lp:~benji/charmworld/bundle-deploy-metric-api
Reviewer Review Type Date Requested Status
Juju Gui Bot continuous-integration Approve
Charmworld Developers Pending
Review via email: mp+194564@code.launchpad.net

Commit message

Add an API for bundle metrics

https://codereview.appspot.com/23740043/

R=bac

Description of the change

Add an API for bundle metrics

https://codereview.appspot.com/23740043/

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

Reviewers: mp+194564_code.launchpad.net,

Message:
Please take a look.

Description:
Add an API for bundle metrics

https://code.launchpad.net/~benji/charmworld/bundle-deploy-metric-api/+merge/194564

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/23740043/

Affected files (+173, -22 lines):
   A [revision details]
   M charmworld/models.py
   M charmworld/testing/factory.py
   M charmworld/tests/test_models.py
   M charmworld/views/api/__init__.py
   A charmworld/views/tests/test_metrics_api.py

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

https://codereview.appspot.com/23740043/diff/1/charmworld/models.py
File charmworld/models.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/models.py#newcode1
charmworld/models.py:1: # Copyright 2012, 2013 Marco Ceppi, Canonical
Ltd. This software is
All the changes in this file are actually from my previous branch that
was apparently not merged. They have been reviewed previously.

https://codereview.appspot.com/23740043/diff/1/charmworld/tests/test_models.py
File charmworld/tests/test_models.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/tests/test_models.py#newcode1
charmworld/tests/test_models.py:1: # Copyright 2012, 2013 Marco Ceppi,
Canonical Ltd. This software is
All the changes in this file are actually from my previous branch that
was apparently not merged. They have been reviewed previously.

https://codereview.appspot.com/23740043/

Revision history for this message
Brad Crittenden (bac) wrote :

LGTMB

https://codereview.appspot.com/23740043/diff/1/charmworld/testing/factory.py
File charmworld/testing/factory.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/testing/factory.py#newcode365
charmworld/testing/factory.py:365: makeBundle = make_bundle
You sure you'd not like to just fix them?

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py
File charmworld/views/api/__init__.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py#newcode511
charmworld/views/api/__init__.py:511: bundle_metric_types =
('deployments')
I think you meant for this to be a tuple. One of my least favorite
warts.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py#newcode521
charmworld/views/api/__init__.py:521: if metric_name not in
self.bundle_metric_types:
Huh, it works anyway.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py
File charmworld/views/tests/test_metrics_api.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py#newcode2
charmworld/views/tests/test_metrics_api.py:2:
del

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py#newcode20
charmworld/views/tests/test_metrics_api.py:20:
Thank you for not piling onto the other test files.

https://codereview.appspot.com/23740043/

456. By Benji York

review fixes

Revision history for this message
Juju Gui Bot (juju-gui-bot) :
review: Approve (continuous-integration)
Revision history for this message
Richard Harding (rharding) wrote :

+1 on brad's point of the tuple.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py
File charmworld/views/api/__init__.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py#newcode521
charmworld/views/api/__init__.py:521: if metric_name not in
self.bundle_metric_types:
On 2013/11/08 19:03:29, bac wrote:
> Huh, it works anyway.

metric types will be the string and you can check if one string is in
another. It just so happens to work because deployments is in the string
deployments.

It needs a comma up above.

https://codereview.appspot.com/23740043/

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

I forgot to publish my draft comments on Friday. I know everyone has
been waiting patiently for them.

https://codereview.appspot.com/23740043/diff/1/charmworld/testing/factory.py
File charmworld/testing/factory.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/testing/factory.py#newcode365
charmworld/testing/factory.py:365: makeBundle = make_bundle
On 2013/11/08 19:03:29, bac wrote:
> You sure you'd not like to just fix them?

I'd like to... but I figured a mechanical branch later would be better.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py
File charmworld/views/api/__init__.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py#newcode511
charmworld/views/api/__init__.py:511: bundle_metric_types =
('deployments')
Good eye. Fixed.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/api/__init__.py#newcode521
charmworld/views/api/__init__.py:521: if metric_name not in
self.bundle_metric_types:
On 2013/11/08 19:03:29, bac wrote:
> Huh, it works anyway.

Well, I learned something new today.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py
File charmworld/views/tests/test_metrics_api.py (right):

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py#newcode2
charmworld/views/tests/test_metrics_api.py:2:
On 2013/11/08 19:03:29, bac wrote:
> del

Done.

https://codereview.appspot.com/23740043/diff/1/charmworld/views/tests/test_metrics_api.py#newcode20
charmworld/views/tests/test_metrics_api.py:20:
On 2013/11/08 19:03:29, bac wrote:
> Thank you for not piling onto the other test files.

Yeah, we need to start breaking those files apart. A few are way over
the weight limit.

https://codereview.appspot.com/23740043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmworld/models.py'
2--- charmworld/models.py 2013-11-08 15:20:57 +0000
3+++ charmworld/models.py 2013-11-08 19:14:48 +0000
4@@ -1863,6 +1863,10 @@
5 return hashes
6
7
8+def utc_today():
9+ return datetime.datetime.utcnow().date()
10+
11+
12 class DatedMetric:
13 """A metric which is recorded per day as well as totaled over all time.
14
15@@ -1882,21 +1886,24 @@
16
17 def __init__(self, start=None, buckets=None, overflow=0):
18 # The date of the zeroth daily bucket.
19- self.start = start or datetime.date.today()
20- # A sequence of buckets for many days proceeding the starting day.
21+ self.start = start or utc_today()
22+
23+ # A sequence of buckets for many days preceding the starting day.
24 self.buckets = buckets or [0] * METRIC_DAYS_TO_KEEP
25- # A bucket for all the numbers that proceed the oldest daily bucket.
26+ # The above reference to METRIC_DAYS_TO_KEEP should be the only place
27+ # in the (non-test) code base that this particular constant is
28+ # referenced. This is to protect against the "constant" changing and
29+ # causing subtile bugs around already-existing database entries.
30+
31+ # A bucket for all the numbers that precede the oldest daily bucket.
32 self.overflow = overflow
33
34 @staticmethod
35 def _shift_buckets(buckets, overflow):
36 """Make a new bucket at the head of the list, accumulating overflow."""
37- # Put the oldest bucket's count into the overflow.
38- overflow += buckets[-1]
39- # Remove the oldest bucket.
40- del buckets[-1]
41- # Put a new bucket on the head of the list and update the starting
42- # day.
43+ # Remove the oldest bucket and put its count into the overflow.
44+ overflow += buckets.pop()
45+ # Add a new bucket at the head of the list.
46 buckets.insert(0, 0)
47 return buckets, overflow
48
49@@ -1920,21 +1927,22 @@
50 return (start - day).days
51
52 def increment(self, day=None):
53- """Increment the count for a paricular day (today if not given)."""
54- day = day or datetime.date.today()
55+ """Increment the count for a particular day (today if not given)."""
56+ day = day or utc_today()
57 # If we are being asked to increment a day's counter but that day's
58 # bucket does not yet exist, make it.
59 if day > self.start:
60 self.start, self.buckets, self.overflow = self._fill_in_buckets(
61 day, self.start, self.buckets, self.overflow)
62 bucket_index = self._find_day_offset(day, self.start)
63- if bucket_index is None:
64+ if bucket_index > len(self.buckets):
65 # We couldn't find the day's bucket so the increment must go
66 # into the overflow bucket.
67 self.overflow += 1
68 return
69- # We found the requested day's bucket, now increment its count.
70- self.buckets[bucket_index] += 1
71+ else:
72+ # We found the requested day's bucket, now increment its count.
73+ self.buckets[bucket_index] += 1
74
75 def get_total(self):
76 """Fetch the total number of increments over all time."""
77@@ -1953,15 +1961,14 @@
78 bucket_index = cls._find_day_offset(day, start)
79 # Select the buckets that are in the requested date range.
80 day_buckets = buckets[bucket_index:bucket_index + num_days]
81- total = sum(day_buckets)
82 # If there were more days requested than we have data for, raise an
83 # error.
84 if len(day_buckets) < num_days:
85 raise ValueError('data not available')
86- return total
87+ return sum(day_buckets)
88
89 def get_range_total(self, num_days, day=None):
90- day = day or datetime.date.today()
91+ day = day or utc_today()
92 return self._get_range_total(
93 num_days, day, self.start, self.buckets, self.overflow)
94
95@@ -2001,6 +2008,8 @@
96 def retrieve(self, key):
97 """Fetch a metric by its key."""
98 document = self.collection.find_one({'_id': quote_key(key)})
99+ if document is None:
100+ raise KeyError(key)
101 # Since MongoDB doesn't know about dates we have to parse the start
102 # out of a string and into a datetime.date instance.
103 start = datetime.date(*map(int, document['start'].split('-')))
104
105=== modified file 'charmworld/testing/factory.py'
106--- charmworld/testing/factory.py 2013-10-24 17:24:43 +0000
107+++ charmworld/testing/factory.py 2013-11-08 19:14:48 +0000
108@@ -341,7 +341,7 @@
109 return bundle_doc
110
111
112-def makeBundle(db, *args, **kwargs):
113+def make_bundle(db, *args, **kwargs):
114 with_basket = False
115 if 'with_basket' in kwargs:
116 with_basket = True
117@@ -361,6 +361,10 @@
118 return bundle, bundle_data
119
120
121+# Backwards compatability with incorrectly-named function.
122+makeBundle = make_bundle
123+
124+
125 def make_charm_file(db, charm, path, content=None):
126 fs = getfs(db)
127 charm_file = CharmFileSet.make_charm_file(
128
129=== modified file 'charmworld/tests/test_models.py'
130--- charmworld/tests/test_models.py 2013-11-07 21:37:09 +0000
131+++ charmworld/tests/test_models.py 2013-11-08 19:14:48 +0000
132@@ -626,7 +626,7 @@
133
134 def test_changes_since(self):
135 changes = [factory.makeChange(created=date(2013, 6, num))
136- for num in range(1, 31)]
137+ for num in xrange(1, 31)]
138 charm = Charm(factory.get_charm_json(changes=changes))
139 self.assertEqual([], charm.changes_since(datetime(2013, 7, 1)))
140 self.assertEqual(30, len(charm.changes_since(datetime(2013, 5, 1))))
141@@ -2338,6 +2338,11 @@
142 metric = DatedMetric()
143 metric.get_total()
144
145+ def test_fresh_metric_has_zero_total(self):
146+ # If a metric has never been incremented, it has a total of zero.
147+ metric = DatedMetric()
148+ self.assertEqual(metric.get_total(), 0)
149+
150 def test_incrementing(self):
151 # A metric can be incremented, which means adding one to a particular
152 # day's count.
153@@ -2367,7 +2372,7 @@
154 # No values have been added, so the starting day hasn't changed.
155 assert metric.start == original_starting_day
156 # Add enough records to fill all the buckets.
157- for x in range(METRIC_DAYS_TO_KEEP):
158+ for x in xrange(METRIC_DAYS_TO_KEEP):
159 metric.increment(original_starting_day + timedelta(x))
160 # The overflow is still empty because up until this point there have
161 # been enough buckets to hold all the days.
162@@ -2393,7 +2398,7 @@
163 # The total will include any values that have overflowed.
164 original_starting_day = date(2000, 1, 1)
165 metric = DatedMetric(original_starting_day)
166- for x in range(METRIC_DAYS_TO_KEEP + 10):
167+ for x in xrange(METRIC_DAYS_TO_KEEP + 10):
168 metric.increment(original_starting_day + timedelta(x))
169 self.assertEqual(metric.get_total(), METRIC_DAYS_TO_KEEP + 10)
170
171@@ -2405,7 +2410,7 @@
172 # No values have been added, so the starting day hasn't changed.
173 assert metric.start == original_starting_day
174 # Add enough records to fill all the buckets.
175- for x in range(METRIC_DAYS_TO_KEEP):
176+ for x in xrange(METRIC_DAYS_TO_KEEP):
177 metric.increment(original_starting_day + timedelta(x))
178 metric.increment(original_starting_day + timedelta(x))
179 # Now that values have been added, the starting day has changed.
180@@ -2427,6 +2432,17 @@
181 'overflow': metric.overflow,
182 })
183
184+ def test_incrementing_a_far_past_date(self):
185+ # When incrementing a date far in the past (so long ago there are no
186+ # buckets for a date that old), the increment goes into the overflow
187+ # bucket.
188+ original_starting_day = date(2000, 1, 1)
189+ metric = DatedMetric(start=original_starting_day)
190+ # The overflow starts empty.
191+ assert metric.overflow == 0
192+ metric.increment(date(1000, 1, 1))
193+ self.assertEqual(metric.overflow, 1)
194+
195
196 class TestDatedMetricIncidentals(TestCase):
197 """Herein lie the tests for the internal machinery of DatedMetrics"""
198@@ -2558,6 +2574,12 @@
199 retrieved = self.source.retrieve('metric-key')
200 self.assertEqual(retrieved, metric)
201
202+ def test_retrieving_a_metric_that_does_not_exist(self):
203+ # If a key is given that does not have a matching metric, a KeyError
204+ # is generated.
205+ with self.assertRaises(KeyError):
206+ self.source.retrieve('missing-key')
207+
208 def test_key_quoting(self):
209 # Keys are quoted to make them MongoDB-safe.
210 metric = DatedMetric(start=date(2000, 1, 1))
211
212=== modified file 'charmworld/views/api/__init__.py'
213--- charmworld/views/api/__init__.py 2013-11-05 13:13:01 +0000
214+++ charmworld/views/api/__init__.py 2013-11-08 19:14:48 +0000
215@@ -27,6 +27,8 @@
216 BundledCharmDescription,
217 Charm,
218 CharmFileSet,
219+ DatedMetric,
220+ DatedMetricSource,
221 FeaturedSource,
222 getfs,
223 QADataSource,
224@@ -506,6 +508,38 @@
225 headerlist=headerlist,
226 status_code=200)
227
228+ bundle_metric_types = ('deployments',)
229+
230+ def _bundle_metric(self, bundle_id, metric_name, operation):
231+ """Handle a request for a bundle metric.
232+
233+ The metric_name will be something like "deployments" and the operation
234+ can be "total" (retrieve the current total) or "increment" (add one
235+ to the count).
236+ """
237+ # The metric names are a controlled vocabulary.
238+ if metric_name not in self.bundle_metric_types:
239+ raise HTTPNotFound
240+ # Define the key that corresponds to the desired metric.
241+ metric_key = '/'.join([metric_name, bundle_id])
242+ # Create a metric source and look up (or create) the metric.
243+ source = DatedMetricSource.from_db(self.request.db)
244+ try:
245+ metric = source.retrieve(metric_key)
246+ except KeyError:
247+ metric = DatedMetric()
248+
249+ # Do the thing we were asked to do.
250+ if operation == 'total':
251+ return metric.get_total()
252+ elif operation == 'increment':
253+ metric.increment()
254+ # Save the newly updated metric to the database.
255+ source.store(metric, metric_key)
256+ return
257+ else:
258+ raise HTTPNotFound('unexpected operation: %r' % operation)
259+
260 def bundle(self, path=None):
261 """Retrieve a bundle based on ID."""
262 if path is None:
263@@ -529,6 +563,9 @@
264 return self._bundle_icon(bundle)
265 elif trailing and trailing[0] == 'file':
266 return self._bundle_file(bundle, '/'.join(trailing[1:]))
267+ elif trailing and trailing[0] == 'metric' and len(trailing) == 3:
268+ return json_response(
269+ 200, self._bundle_metric(bundle_id, *trailing[1:]))
270 else:
271 raise HTTPNotFound('/'.join(path))
272
273
274=== modified file 'charmworld/views/tests/test_api.py'
275--- charmworld/views/tests/test_api.py 2013-11-05 15:00:41 +0000
276+++ charmworld/views/tests/test_api.py 2013-11-08 19:14:48 +0000
277@@ -1,7 +1,5 @@
278-# Copyright 2012, 2013 Canonical
279-
280-# Ltd. This software is licensed under the GNU Affero General Public License
281-# version 3 (see the file LICENSE).
282+# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the GNU
283+# Affero General Public License version 3 (see the file LICENSE).
284
285 __metaclass__ = type
286
287
288=== added file 'charmworld/views/tests/test_metrics_api.py'
289--- charmworld/views/tests/test_metrics_api.py 1970-01-01 00:00:00 +0000
290+++ charmworld/views/tests/test_metrics_api.py 2013-11-08 19:14:48 +0000
291@@ -0,0 +1,77 @@
292+# Copyright 2012, 2013 Canonical
293+
294+# Ltd. This software is licensed under the GNU Affero General Public License
295+# version 3 (see the file LICENSE).
296+
297+__metaclass__ = type
298+
299+
300+from charmworld.testing import factory
301+from charmworld.models import (
302+ DatedMetric,
303+ DatedMetricSource,
304+)
305+from charmworld.views.tests.test_api import (
306+ APITestBase,
307+ API3Mixin,
308+)
309+from pyramid.httpexceptions import HTTPNotFound
310+
311+
312+class TestAPI3BundleMetrics(API3Mixin, APITestBase):
313+ """Test API 3's "charms" endpoint."""
314+
315+ def test_incrementing_bundle_metric_count(self):
316+ # The API provides a way to increment a metric count.
317+ bundle = factory.make_bundle(self.db)[0]
318+ response = self.get_response(
319+ 'bundle', bundle.id + '/metric/deployments/increment')
320+ self.assertEqual(200, response.status_code)
321+
322+ # Now look up the metric that was supposed to be incremented...
323+ source = DatedMetricSource.from_db(self.db)
324+ metric_key = 'deployments/' + bundle.id
325+ metric = source.retrieve(metric_key)
326+ # ...and check that it has the right value.
327+ self.assertEqual(metric.get_total(), 1)
328+
329+ def test_fetching_bundle_metric_count(self):
330+ # The API provides a way to retrieve a metric count.
331+ bundle = factory.make_bundle(self.db)[0]
332+ metric = DatedMetric()
333+ metric.increment()
334+ metric.increment()
335+ source = DatedMetricSource.from_db(self.db)
336+ source.store(metric, 'deployments/' + bundle.id)
337+ response = self.get_response(
338+ 'bundle', bundle.id + '/metric/deployments/total')
339+ self.assertEqual(200, response.status_code)
340+ # ...and check that it has the right value.
341+ self.assertEqual(response.body, '2')
342+
343+ def test_fetching_unknown_bundle_metric_type_generates_a_404(self):
344+ # If a metric type is unknown, requesting it generates a 404.
345+ bundle = factory.make_bundle(self.db)[0]
346+ with self.assertRaises(HTTPNotFound):
347+ self.get_response(
348+ 'bundle', bundle.id + '/metric/does-not-exist/total')
349+
350+ def test_requesting_unknown_operation_generates_a_404(self):
351+ # If an operation is unknown, requesting it generates a 404.
352+ bundle = factory.make_bundle(self.db)[0]
353+ with self.assertRaises(HTTPNotFound):
354+ self.get_response(
355+ 'bundle', bundle.id + '/metric/deployments/do-something-else')
356+
357+ def test_not_including_a_metric_name(self):
358+ # If no metric name is included, a 404 is generated.
359+ bundle = factory.make_bundle(self.db)[0]
360+ with self.assertRaises(HTTPNotFound):
361+ self.get_response('bundle', bundle.id + '/metric')
362+
363+ def test_sending_no_metric_operation_generates_a_404(self):
364+ # If a metrics request does not provide an operation, a 404 is
365+ # generated.
366+ bundle = factory.make_bundle(self.db)[0]
367+ with self.assertRaises(HTTPNotFound):
368+ self.get_response('bundle', bundle.id + '/metric/deployments')

Subscribers

People subscribed via source and target branches

to all changes: