Merge lp:~benji/charmworld/bundle-deploy-metric-api into lp:charmworld
- bundle-deploy-metric-api
- Merge into trunk
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 |
Related bugs: |
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
Description of the change
Add an API for bundle metrics
Benji York (benji) wrote : | # |
Benji York (benji) wrote : | # |
https:/
File charmworld/
https:/
charmworld/
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:/
File charmworld/
https:/
charmworld/
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.
Brad Crittenden (bac) wrote : | # |
LGTMB
https:/
File charmworld/
https:/
charmworld/
You sure you'd not like to just fix them?
https:/
File charmworld/
https:/
charmworld/
('deployments')
I think you meant for this to be a tuple. One of my least favorite
warts.
https:/
charmworld/
self.bundle_
Huh, it works anyway.
https:/
File charmworld/
https:/
charmworld/
del
https:/
charmworld/
Thank you for not piling onto the other test files.
- 456. By Benji York
-
review fixes
Juju Gui Bot (juju-gui-bot) : | # |
Richard Harding (rharding) wrote : | # |
+1 on brad's point of the tuple.
https:/
File charmworld/
https:/
charmworld/
self.bundle_
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.
Benji York (benji) wrote : | # |
I forgot to publish my draft comments on Friday. I know everyone has
been waiting patiently for them.
https:/
File charmworld/
https:/
charmworld/
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:/
File charmworld/
https:/
charmworld/
('deployments')
Good eye. Fixed.
https:/
charmworld/
self.bundle_
On 2013/11/08 19:03:29, bac wrote:
> Huh, it works anyway.
Well, I learned something new today.
https:/
File charmworld/
https:/
charmworld/
On 2013/11/08 19:03:29, bac wrote:
> del
Done.
https:/
charmworld/
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.
Preview Diff
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') |
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): models. py testing/ factory. py tests/test_ models. py views/api/ __init_ _.py views/tests/ test_metrics_ api.py
A [revision details]
M charmworld/
M charmworld/
M charmworld/
M charmworld/
A charmworld/