Merge lp:~lifeless/lazr.batchnavigator/keyoffset into lp:lazr.batchnavigator
- keyoffset
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Robert Collins |
Approved revision: | no longer in the source branch. |
Merged at revision: | 38 |
Proposed branch: | lp:~lifeless/lazr.batchnavigator/keyoffset |
Merge into: | lp:lazr.batchnavigator |
Diff against target: |
1400 lines (+659/-209) 12 files modified
.bzrignore (+2/-0) .testr.conf (+5/-0) setup.py (+2/-0) src/lazr/batchnavigator/NEWS.txt (+8/-0) src/lazr/batchnavigator/README.txt (+39/-38) src/lazr/batchnavigator/_batchnavigator.py (+96/-73) src/lazr/batchnavigator/interfaces.py (+57/-10) src/lazr/batchnavigator/tests/__init__.py (+16/-0) src/lazr/batchnavigator/tests/test_batchnavigator.py (+216/-0) src/lazr/batchnavigator/tests/test_z3batching.py (+135/-71) src/lazr/batchnavigator/version.txt (+1/-1) src/lazr/batchnavigator/z3batching/batch.py (+82/-16) |
To merge this branch: | bzr merge lp:~lifeless/lazr.batchnavigator/keyoffset |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Steve Kowalik (community) | code | Approve | |
Review via email: mp+56499@code.launchpad.net |
Commit message
Description of the change
Provide the necessary machinery (I hope!) to solve bug 752153 in Launchpad.
Essentially we change from assuming all collections are cheaply indexable by ints to assuming we can cheaply go forward or back a page of results in a SQL query. We'll need to write IRangeFactories for collections where this matters in LP, and there are some changes to URL generation (more consistency about when params are included/not included) that may cause test fallout when upgrading LP to use this version.
Care has been taken to check that existing urls will behave the same way.
Robert Collins (lifeless) wrote : | # |
Thanks for the review; Once we're out of the boundary cases more results won't affect things, so I'll pass on those tests. I *may* revisit that based on experience in LP.
- 38. By Robert Collins
-
Release 1.2.3. Migrate from pure list-slicing to a more complex protocol friendly to databases.
Preview Diff
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2009-03-23 16:14:28 +0000 |
3 | +++ .bzrignore 2011-04-06 03:20:48 +0000 |
4 | @@ -9,3 +9,5 @@ |
5 | build |
6 | *.egg |
7 | dist |
8 | +eggs |
9 | +.testrepository |
10 | |
11 | === added file '.testr.conf' |
12 | --- .testr.conf 1970-01-01 00:00:00 +0000 |
13 | +++ .testr.conf 2011-04-06 03:20:48 +0000 |
14 | @@ -0,0 +1,5 @@ |
15 | +[DEFAULT] |
16 | +test_command=bin/py -m subunit.run -- $LISTOPT $IDOPTION lazr.batchnavigator.tests.test_suite |
17 | +test_id_option=--load-list $IDFILE |
18 | +test_list_option=--list |
19 | + |
20 | |
21 | === modified file 'setup.py' |
22 | --- setup.py 2011-04-03 22:07:03 +0000 |
23 | +++ setup.py 2011-04-06 03:20:48 +0000 |
24 | @@ -54,7 +54,9 @@ |
25 | 'src/lazr/batchnavigator/NEWS.txt'), |
26 | license='LGPL v3', |
27 | install_requires=[ |
28 | + 'fixtures', |
29 | 'setuptools', |
30 | + 'testtools', |
31 | 'zope.cachedescriptors', |
32 | 'zope.interface', |
33 | 'zope.publisher', |
34 | |
35 | === modified file 'src/lazr/batchnavigator/NEWS.txt' |
36 | --- src/lazr/batchnavigator/NEWS.txt 2010-08-19 14:23:41 +0000 |
37 | +++ src/lazr/batchnavigator/NEWS.txt 2011-04-06 03:20:48 +0000 |
38 | @@ -2,6 +2,14 @@ |
39 | NEWS for lazr.batchnavigator |
40 | ============================ |
41 | |
42 | +1.2.3 (2011-04-06) |
43 | +================== |
44 | + |
45 | +- Add IRangeFactory and the ability to use backend database hints for efficient |
46 | + retrieval of pages. |
47 | + |
48 | +- Remove terrible-scaling getBatchURLs method. |
49 | + |
50 | 1.2.2 (2010-08-19) |
51 | ================== |
52 | |
53 | |
54 | === modified file 'src/lazr/batchnavigator/README.txt' |
55 | --- src/lazr/batchnavigator/README.txt 2010-08-19 14:19:34 +0000 |
56 | +++ src/lazr/batchnavigator/README.txt 2011-04-06 03:20:48 +0000 |
57 | @@ -21,9 +21,17 @@ |
58 | page by providing URL links to the next, previous and numbered pages |
59 | of results. |
60 | |
61 | -It uses two arguments to control the batching: |
62 | +It uses four query/POST arguments to control the batching: |
63 | |
64 | - - start: The first item we should show in current batch, defaulting to 0. |
65 | + - memo: A record of the underlying storage index pointer for the position of |
66 | + the batch. |
67 | + - direction: Indicates whether the memo is at the start or end of the batch. |
68 | + - start: Cosmetic - used to calculate the apparent location (but note that |
69 | + due to the concurrent nature of repeated visits to batches that the |
70 | + true offset may differ - however the collection won't skip or show |
71 | + items twice. For compatibility with saved URLs, if memo and |
72 | + direction are both missing then start is used to do list slicing |
73 | + into the collection. |
74 | - batch: Controls the amount of items we are showing per batch. It will only |
75 | appear if it's different from the default value set when the batch |
76 | is created. |
77 | @@ -34,7 +42,7 @@ |
78 | |
79 | Imports: |
80 | |
81 | - >>> from lazr.batchnavigator import BatchNavigator |
82 | + >>> from lazr.batchnavigator import BatchNavigator, ListRangeFactory |
83 | >>> from zope.publisher.browser import TestRequest |
84 | >>> from zope.publisher.http import HTTPCharsets |
85 | >>> from zope.component import getSiteManager |
86 | @@ -62,7 +70,17 @@ |
87 | >>> reindeer = ['Dasher', 'Dancer', 'Prancer', 'Vixen', 'Comet', |
88 | ... 'Cupid', 'Donner', 'Blitzen', 'Rudolph'] |
89 | |
90 | -You can create a batch and a batch navigator like so: |
91 | +Because slicing large collections can be very expensive, BatchNavigator offers |
92 | +a non-slice protocol for determining the edge of batches. The range_factory |
93 | +supplies an object implementing IRangeFactory and manages this protocol. |
94 | +ListRangeFactory is a simple included implementation which BatchNavigator will |
95 | +use if no range_factory is supplied. |
96 | + |
97 | + >>> _ = BatchNavigator(reindeer, build_request(), |
98 | + ... range_factory=ListRangeFactory(reindeer)) |
99 | + |
100 | +For the examples in the documentation we let BatchNavigator construct a |
101 | +range_factory implicitly: |
102 | |
103 | >>> safe_reindeer = reindeer |
104 | >>> safe_reindeer_batch_navigator = BatchNavigator( |
105 | @@ -119,14 +137,14 @@ |
106 | >>> reindeer_batch_navigator.prevBatchURL() |
107 | '' |
108 | >>> reindeer_batch_navigator.nextBatchURL() |
109 | - 'http://www.example.com/foo?start=3' |
110 | + 'http://www.example.com/foo?memo=3&start=3' |
111 | |
112 | There's no way to get the URL to the final batch without knowing the |
113 | length of the entire list, so we'll use the safe batch navigator to |
114 | demonstrate lastBatchURL(): |
115 | |
116 | >>> safe_reindeer_batch_navigator.lastBatchURL() |
117 | - 'http://www.example.com/foo?start=6' |
118 | + 'http://www.example.com/foo?direction=backwards&start=6' |
119 | |
120 | The next link will be empty when there are no further results: |
121 | |
122 | @@ -146,10 +164,10 @@ |
123 | one when we create the Batch object, by default, it's 5. |
124 | |
125 | >>> last_reindeer_batch_navigator.firstBatchURL() |
126 | - 'http://www.example.com/foo?start=0&batch=3' |
127 | + 'http://www.example.com/foo?batch=3' |
128 | |
129 | >>> last_reindeer_batch_navigator.prevBatchURL() |
130 | - 'http://www.example.com/foo?start=0&batch=3' |
131 | + 'http://www.example.com/foo?batch=3&direction=backwards&memo=2' |
132 | |
133 | This all works with other values in the query string, too: |
134 | |
135 | @@ -167,28 +185,17 @@ |
136 | don't need to show 'batch' as part of the URL. |
137 | |
138 | >>> reindeer_batch_navigator_with_qs.firstBatchURL() |
139 | - 'http://www.example.com/foo?fnorb=bar&start=0' |
140 | + 'http://www.example.com/foo?fnorb=bar' |
141 | >>> reindeer_batch_navigator_with_qs.prevBatchURL() |
142 | - 'http://www.example.com/foo?fnorb=bar&start=0' |
143 | + 'http://www.example.com/foo?fnorb=bar&direction=backwards&memo=3' |
144 | >>> reindeer_batch_navigator_with_qs.nextBatchURL() |
145 | - 'http://www.example.com/foo?fnorb=bar&start=6' |
146 | + 'http://www.example.com/foo?fnorb=bar&memo=6&start=6' |
147 | |
148 | (Again, there's no way to get the last batch without knowing the size |
149 | of the entire list.) |
150 | |
151 | >>> safe_reindeer_batch_navigator_with_qs.lastBatchURL() |
152 | - 'http://www.example.com/foo?fnorb=bar&start=6' |
153 | - |
154 | -You can ask for the links for each of the result pages: |
155 | - |
156 | - >>> links = safe_reindeer_batch_navigator.batchPageURLs() |
157 | - >>> for link in links: |
158 | - ... label, url = link.items()[0] |
159 | - ... print label, url |
160 | - [1] http://www.example.com/foo?start=0 |
161 | - 2 http://www.example.com/foo?start=3 |
162 | - 3 http://www.example.com/foo?start=6 |
163 | - _last_ http://www.example.com/foo?start=6 |
164 | + 'http://www.example.com/foo?fnorb=bar&direction=backwards&start=6' |
165 | |
166 | The ``force_start`` argument allows you to ignore the start value in the |
167 | request. This can be useful when, for instance, a filter has changed, and the |
168 | @@ -199,7 +206,7 @@ |
169 | >>> reindeer_batch_navigator_with_qs.currentBatch().start |
170 | 0 |
171 | >>> reindeer_batch_navigator_with_qs.nextBatchURL() |
172 | - 'http://www.example.com/foo?fnorb=bar&start=3' |
173 | + 'http://www.example.com/foo?fnorb=bar&memo=3&start=3' |
174 | >>> reindeer[:3] == list(reindeer_batch_navigator_with_qs.currentBatch()) |
175 | True |
176 | |
177 | @@ -215,7 +222,7 @@ |
178 | >>> reindeer_batch_navigator_post_with_qs.start |
179 | 3 |
180 | >>> reindeer_batch_navigator_post_with_qs.nextBatchURL() |
181 | - 'http://www.example.com/foo?fnorb=bar&start=6&batch=3' |
182 | + 'http://www.example.com/foo?fnorb=bar&batch=3&memo=6&start=6' |
183 | |
184 | We ensure that multiple size and batch arguments supplied in the URL don't |
185 | blow up the application. The first one is preferred. |
186 | @@ -224,7 +231,7 @@ |
187 | ... [('batch', '1'), ('batch', '7'), ('start', '2'), ('start', '10')]) |
188 | >>> navigator = BatchNavigator(reindeer, request=request) |
189 | >>> navigator.nextBatchURL() |
190 | - 'http://www.example.com/foo?start=3&batch=1' |
191 | + 'http://www.example.com/foo?batch=1&memo=3&start=3' |
192 | |
193 | The batch argument must be positive. Other numbers are ignored, and the |
194 | default batch size is used instead. |
195 | @@ -232,19 +239,13 @@ |
196 | >>> from cgi import parse_qs |
197 | >>> request = build_request({'batch': '0'}) |
198 | >>> navigator = BatchNavigator(range(99), request=request) |
199 | - >>> print navigator.default_batch_size |
200 | - 50 |
201 | - >>> for value in parse_qs(navigator.nextBatchURL())['batch']: |
202 | - ... print value |
203 | - 50 |
204 | + >>> print 'batch' in parse_qs(navigator.nextBatchURL()) |
205 | + False |
206 | |
207 | >>> request = build_request({'batch': '-1'}) |
208 | >>> navigator = BatchNavigator(range(99), request=request) |
209 | - >>> print navigator.default_batch_size |
210 | - 50 |
211 | - >>> for value in parse_qs(navigator.nextBatchURL())['batch']: |
212 | - ... print value |
213 | - 50 |
214 | + >>> print 'batch' in parse_qs(navigator.nextBatchURL()) |
215 | + False |
216 | |
217 | |
218 | ============= |
219 | @@ -463,9 +464,9 @@ |
220 | the batch navigator. |
221 | |
222 | >>> navigator_with_parameters.nextBatchURL() |
223 | - 'http://www.example.com/foo?noisy=HELLO&start=3' |
224 | + 'http://www.example.com/foo?noisy=HELLO&memo=3&start=3' |
225 | >>> safe_navigator_with_parameters.lastBatchURL() |
226 | - 'http://www.example.com/foo?noisy=HELLO&start=6' |
227 | + 'http://www.example.com/foo?noisy=HELLO&direction=backwards&start=6' |
228 | |
229 | The transient parameter is omitted, and the one that was never passed in |
230 | in the first place does not magically appear. |
231 | |
232 | === modified file 'src/lazr/batchnavigator/_batchnavigator.py' |
233 | --- src/lazr/batchnavigator/_batchnavigator.py 2010-08-19 14:19:34 +0000 |
234 | +++ src/lazr/batchnavigator/_batchnavigator.py 2011-04-06 03:20:48 +0000 |
235 | @@ -24,9 +24,16 @@ |
236 | |
237 | from lazr.batchnavigator.z3batching.batch import _Batch |
238 | from lazr.batchnavigator.interfaces import ( |
239 | - IBatchNavigator, InvalidBatchSizeError, IBatchNavigatorFactory) |
240 | + IBatchNavigator, |
241 | + IBatchNavigatorFactory, |
242 | + IRangeFactory, |
243 | + InvalidBatchSizeError, |
244 | + ) |
245 | |
246 | -__all__ = ['BatchNavigator'] |
247 | +__all__ = [ |
248 | + 'BatchNavigator', |
249 | + 'ListRangeFactory', |
250 | + ] |
251 | |
252 | class BatchNavigator: |
253 | |
254 | @@ -38,6 +45,8 @@ |
255 | |
256 | start_variable_name = 'start' |
257 | batch_variable_name = 'batch' |
258 | + memo_variable_name = 'memo' |
259 | + direction_variable_name = 'direction' |
260 | |
261 | default_batch_size = 50 |
262 | max_batch_size = 300 |
263 | @@ -66,10 +75,12 @@ |
264 | return cgi.parse_qs(query_string, keep_blank_values=True) |
265 | |
266 | def __init__(self, results, request, start=0, size=None, callback=None, |
267 | - transient_parameters=None, force_start=False): |
268 | + transient_parameters=None, force_start=False, |
269 | + range_factory=None): |
270 | "See `IBatchNavigatorFactory.__call__`" |
271 | self.request = request |
272 | - local = (self.batch_variable_name, self.start_variable_name) |
273 | + local = (self.batch_variable_name, self.start_variable_name, |
274 | + self.memo_variable_name, self.direction_variable_name) |
275 | self.transient_parameters = set(local) |
276 | if transient_parameters is not None: |
277 | self.transient_parameters.update(transient_parameters) |
278 | @@ -95,8 +106,10 @@ |
279 | # In this code we ignore invalid request variables since it |
280 | # probably means the user finger-fumbled it in the request. We |
281 | # could raise UnexpectedFormData, but is there a good reason? |
282 | - request_start = batch_params_source.get( |
283 | - self.start_variable_name, None) |
284 | + def param_var(name): |
285 | + return batch_params_source.get(name, None) |
286 | + # -- start |
287 | + request_start = param_var(self.start_variable_name) |
288 | if force_start or request_start is None: |
289 | self.start = start |
290 | else: |
291 | @@ -104,15 +117,33 @@ |
292 | self.start = int(request_start) |
293 | except (ValueError, TypeError): |
294 | self.start = start |
295 | - |
296 | + # -- size |
297 | self.default_size = size |
298 | request_size = self._getRequestedSize(batch_params_source) |
299 | if request_size is not None: |
300 | size = request_size |
301 | if size is None: |
302 | size = self.default_batch_size |
303 | + # -- direction |
304 | + direction = param_var(self.direction_variable_name) |
305 | + if direction == 'backwards': |
306 | + direction = False |
307 | + else: |
308 | + direction = None |
309 | + # -- memo |
310 | + memo = param_var(self.memo_variable_name) |
311 | + if direction is not None and memo is None: |
312 | + # Walking backwards from the end - the only case where we generate |
313 | + # a url with no memo but a direction (and the only case where we |
314 | + # need it: from the start with no memo is equivalent to a simple |
315 | + # list slice anyway). |
316 | + memo = '' |
317 | |
318 | - self.batch = self._batch_factory(results, start=self.start, size=size) |
319 | + if range_factory is None: |
320 | + range_factory = ListRangeFactory(results) |
321 | + self.batch = self._batch_factory(results, range_factory, |
322 | + start=self.start, size=size, range_forwards=direction, |
323 | + range_memo=memo) |
324 | if callback is not None: |
325 | callback(self, self.batch) |
326 | self.setHeadings( |
327 | @@ -161,7 +192,7 @@ |
328 | self._plural_heading = plural |
329 | |
330 | def getCleanQueryString(self, params=None): |
331 | - """Removes start and batch params if present and returns a query |
332 | + """Removes batch nav params if present and returns a query |
333 | string. |
334 | |
335 | If ``params`` is None, uses the current query_string_params. |
336 | @@ -183,34 +214,35 @@ |
337 | if key not in self.transient_parameters], |
338 | doseq=True) |
339 | |
340 | - def generateBatchURL(self, batch): |
341 | + def generateBatchURL(self, batch, backwards=False): |
342 | url = "" |
343 | if batch is None: |
344 | return url |
345 | |
346 | qs = self.getCleanQueryString() |
347 | if qs: |
348 | - qs += "&" |
349 | + elements = [qs] |
350 | + else: |
351 | + elements = [] |
352 | |
353 | - start = batch.startNumber() - 1 |
354 | size = batch.size |
355 | - base_url = str(self.request.URL) |
356 | - url = "%s?%s%s=%d" % (base_url, qs, self.start_variable_name, start) |
357 | if size != self.default_size: |
358 | # The current batch size should only be part of the URL if it's |
359 | # different from the default batch size. |
360 | - url = "%s&%s=%d" % (url, self.batch_variable_name, size) |
361 | - return url |
362 | - |
363 | - def getBatches(self): |
364 | - batch = self.batch.firstBatch() |
365 | - batches = [batch] |
366 | - while 1: |
367 | - batch = batch.nextBatch() |
368 | - if not batch: |
369 | - break |
370 | - batches.append(batch) |
371 | - return batches |
372 | + elements.append("%s=%s" % (self.batch_variable_name, size)) |
373 | + |
374 | + if backwards: |
375 | + elements.append("%s=backwards" % (self.direction_variable_name,)) |
376 | + |
377 | + if batch.range_memo: |
378 | + elements.append("%s=%s" % (self.memo_variable_name, batch.range_memo)) |
379 | + |
380 | + start = batch.startNumber() - 1 |
381 | + if start: |
382 | + elements.append("%s=%s" % (self.start_variable_name, start)) |
383 | + |
384 | + base_url = str(self.request.URL) |
385 | + return "%s?%s" % (base_url, '&'.join(elements)) |
386 | |
387 | def firstBatchURL(self): |
388 | batch = self.batch.firstBatch() |
389 | @@ -220,7 +252,7 @@ |
390 | return self.generateBatchURL(batch) |
391 | |
392 | def prevBatchURL(self): |
393 | - return self.generateBatchURL(self.batch.prevBatch()) |
394 | + return self.generateBatchURL(self.batch.prevBatch(), backwards=True) |
395 | |
396 | def nextBatchURL(self): |
397 | return self.generateBatchURL(self.batch.nextBatch()) |
398 | @@ -230,52 +262,43 @@ |
399 | if self.start == batch.start: |
400 | # We are already on the last batch. |
401 | batch = None |
402 | - return self.generateBatchURL(batch) |
403 | - |
404 | - def batchPageURLs(self): |
405 | - batches = self.getBatches() |
406 | - urls = [] |
407 | - size = len(batches) |
408 | - |
409 | - nextb = self.batch.nextBatch() |
410 | - |
411 | - # Find the current page |
412 | - if nextb: |
413 | - current = nextb.start/nextb.size |
414 | - else: |
415 | - current = size |
416 | - |
417 | - self.current = current |
418 | - # Find the start page to show |
419 | - if (current - 5) > 0: |
420 | - start = current-5 |
421 | - else: |
422 | - start = 0 |
423 | - |
424 | - # Find the last page to show |
425 | - if (start + 10) < size: |
426 | - stop = start + 10 |
427 | - else: |
428 | - stop = size |
429 | - |
430 | - initial = start |
431 | - while start < stop: |
432 | - this_batch = batches[start] |
433 | - url = self.generateBatchURL(this_batch) |
434 | - if (start+1) == current: |
435 | - urls.append({'['+str(start + 1)+']' : url}) |
436 | - else: |
437 | - urls.append({start + 1 : url}) |
438 | - start += 1 |
439 | - |
440 | - if current != 1: |
441 | - url = self.generateBatchURL(batches[0]) |
442 | - urls.insert(0, {'_first_' : url}) |
443 | - if current != size: |
444 | - url = self.generateBatchURL(batches[size-1]) |
445 | - urls.append({'_last_':url}) |
446 | - |
447 | - return urls |
448 | + return self.generateBatchURL(batch, backwards=True) |
449 | |
450 | def currentBatch(self): |
451 | return self.batch |
452 | + |
453 | + |
454 | +class ListRangeFactory: |
455 | + """Implements an IRangeFactory for lists (and list-like objects). |
456 | + |
457 | + This uses the slice protocol index as its memos: 'up to and not including |
458 | + low', 'from this point and up'. |
459 | + """ |
460 | + |
461 | + implements(IRangeFactory) |
462 | + |
463 | + def __init__(self, results): |
464 | + self.results = results |
465 | + |
466 | + def getEndpointMemos(self, batch): |
467 | + """See `IRangeFactory` |
468 | + |
469 | + Most implementations will want to use batch.sliced_list to retrieve |
470 | + database keys. |
471 | + """ |
472 | + end_idx = batch.trueSize + batch.start |
473 | + return (str(batch.start), str(end_idx)) |
474 | + |
475 | + def getSlice(self, endpoint_memo=None, forwards=True): |
476 | + """See `IRangeFactory`""" |
477 | + if endpoint_memo: |
478 | + try: |
479 | + offset = int(endpoint_memo) |
480 | + except ValueError: |
481 | + raise InvalidBatchSizeError('not an int') |
482 | + else: |
483 | + offset = None |
484 | + if forwards: |
485 | + return self.results[offset:] |
486 | + else: |
487 | + return list(reversed(self.results[:offset])) |
488 | |
489 | === modified file 'src/lazr/batchnavigator/interfaces.py' |
490 | --- src/lazr/batchnavigator/interfaces.py 2009-03-23 16:14:28 +0000 |
491 | +++ src/lazr/batchnavigator/interfaces.py 2011-04-06 03:20:48 +0000 |
492 | @@ -20,7 +20,40 @@ |
493 | |
494 | |
495 | class InvalidBatchSizeError(AssertionError): |
496 | - """Received a batch parameter that exceed our configured max size.""" |
497 | + """Received a batch parameter that was invalid (e.g. exceeds our configured max size).""" |
498 | + |
499 | + |
500 | +class IRangeFactory(Interface): |
501 | + """A helper used to construct better backend queries for batches. |
502 | + |
503 | + IRangeFactory instances are paired with a result object that the |
504 | + BatchNavigator was constructed with. Generally they will be curried |
505 | + as part of determining what backend query to run for the thing being |
506 | + batched. |
507 | + """ |
508 | + |
509 | + def getEndpointMemos(batch): |
510 | + """Converts a batch to a pair of (lower, upper) endpoint memos. |
511 | + |
512 | + :return: A tuple of bytestrings suitable for inclusion in URLs. |
513 | + """ |
514 | + |
515 | + def getSlice(endpoint_memo='', forwards=True): |
516 | + """Returns a slice of the results starting after endpoint_memo. |
517 | + |
518 | + Note that endpoint_memo should be a simple string and is untrusted. |
519 | + |
520 | + :param endpoint_memo: An endpoint memo as returned by getEndpointMemos. |
521 | + If None or '', the edge of the results is used. |
522 | + :param forwards: If True, slice forwards from endpoint_memo. Otherwise |
523 | + slice backwards from endpoint_memo (e.g. ascending indices in the |
524 | + result will indicate earlier items in the results collection). |
525 | + :return: An object honouring the tuple protocol, just like 'results' in |
526 | + the IBatchNavigatorFactory constructor call. If forwards was not |
527 | + True, the order will be reversed vs the 'results' object, otherwise |
528 | + it is identical. |
529 | + """ |
530 | + |
531 | |
532 | class IBatchNavigator(Interface): |
533 | """A batch navigator for a specified set of results.""" |
534 | @@ -39,22 +72,32 @@ |
535 | def nextBatchURL(): |
536 | """Return a URL to the next chunk of results.""" |
537 | |
538 | - def batchPageURLs(): |
539 | - """Return a list of links representing URLs to pages of |
540 | - results.""" |
541 | |
542 | class IBatchNavigatorFactory(Interface): |
543 | def __call__(results, request, start=0, size=None, callback=None, |
544 | - transient_parameters=None, force_start=False): |
545 | + transient_parameters=None, force_start=False, |
546 | + range_factory=None): |
547 | """Constructs an `IBatchNavigator` instance. |
548 | |
549 | :param results: is an iterable of results. |
550 | |
551 | - :param request: will be inspected for a start variable; if set, |
552 | - it indicates which point we are currently displaying at. It |
553 | - will also be inspected for a batch variable; if set, it will |
554 | - be used instead of the size supplied in the callsite. Expected to |
555 | - confirm to zope.publisher.interfaces.IRequest. |
556 | + :param request: Expected to confirm to |
557 | + zope.publisher.interfaces.IRequest. |
558 | + The following variables are pulled out of the request to control |
559 | + the batch navigator: |
560 | + - memo: A record of the underlying storage index pointer for the |
561 | + position of the batch. |
562 | + - direction: Indicates whether the memo is at the start or end of |
563 | + the batch. |
564 | + - start: Cosmetic - used to calculate the apparent location (but |
565 | + note that due to the concurrent nature of repeated visits to |
566 | + batches that the true offset may differ - however the |
567 | + collection won't skip or show items twice. For compatibility |
568 | + with saved URLs, if memo and direction are both missing then |
569 | + start is used to do list slicing into the collection. |
570 | + - batch: Controls the amount of items we are showing per batch. |
571 | + It will only appear if it's different from the default value |
572 | + set when the batch is created. |
573 | |
574 | :param size: is the default batch size, to fall back to if the |
575 | request does not specify one. If no size can be determined |
576 | @@ -74,6 +117,10 @@ |
577 | :param force_start: if True, the given `start` argument has |
578 | precedence over the start value in the request, if any. |
579 | |
580 | + :param range_factory: An IRangeFactory paired with results, or None. |
581 | + If None, a default IRangeFactory is constructed which simply adapts |
582 | + results. |
583 | + |
584 | :raises InvalidBatchSizeError: if the requested batch size is higher |
585 | than the maximum allowed. |
586 | """ |
587 | |
588 | === modified file 'src/lazr/batchnavigator/tests/__init__.py' |
589 | --- src/lazr/batchnavigator/tests/__init__.py 2009-03-23 16:14:28 +0000 |
590 | +++ src/lazr/batchnavigator/tests/__init__.py 2011-04-06 03:20:48 +0000 |
591 | @@ -14,3 +14,19 @@ |
592 | # You should have received a copy of the GNU Lesser General Public License |
593 | # along with lazr.batchnavigator. If not, see <http://www.gnu.org/licenses/>. |
594 | "The lazr.batchnavigator tests." |
595 | + |
596 | +import unittest |
597 | + |
598 | +from lazr.batchnavigator.tests import ( |
599 | + test_docs, |
600 | + ) |
601 | + |
602 | +def test_suite(): |
603 | + # Discovery would be nice, but needs python2.7 for use with subunit & |
604 | + # testrepository. |
605 | + result = unittest.TestSuite() |
606 | + result.addTests(test_docs.additional_tests()) |
607 | + modules = ['batchnavigator', 'z3batching'] |
608 | + result.addTests(unittest.TestLoader().loadTestsFromNames([ |
609 | + 'lazr.batchnavigator.tests.test_' + name for name in modules])) |
610 | + return result |
611 | |
612 | === added file 'src/lazr/batchnavigator/tests/test_batchnavigator.py' |
613 | --- src/lazr/batchnavigator/tests/test_batchnavigator.py 1970-01-01 00:00:00 +0000 |
614 | +++ src/lazr/batchnavigator/tests/test_batchnavigator.py 2011-04-06 03:20:48 +0000 |
615 | @@ -0,0 +1,216 @@ |
616 | +# Copyright 2011 Canonical Ltd. All rights reserved. |
617 | +# |
618 | +# This file is part of lazr.batchnavigator |
619 | +# |
620 | +# lazr.batchnavigator is free software: you can redistribute it and/or modify |
621 | +# it under the terms of the GNU Lesser General Public License as published by |
622 | +# the Free Software Foundation, version 3 of the License. |
623 | +# |
624 | +# lazr.batchnavigator is distributed in the hope that it will be useful, but |
625 | +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY |
626 | +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public |
627 | +# License for more details. |
628 | +# |
629 | +# You should have received a copy of the GNU Lesser General Public License |
630 | +# along with lazr.batchnavigator. If not, see <http://www.gnu.org/licenses/>. |
631 | + |
632 | +"""Unit tests for lazr.batchnavigator.BatchNavigator.""" |
633 | + |
634 | +import testtools |
635 | +from testtools.matchers import Equals |
636 | +from zope.interface.verify import verifyObject |
637 | +from zope.publisher.browser import TestRequest |
638 | + |
639 | +from lazr.batchnavigator import BatchNavigator, ListRangeFactory |
640 | +from lazr.batchnavigator.interfaces import ( |
641 | + IRangeFactory, |
642 | + InvalidBatchSizeError, |
643 | + ) |
644 | +from lazr.batchnavigator.z3batching.batch import _Batch |
645 | +from lazr.batchnavigator.tests.test_z3batching import RecordingFactory |
646 | + |
647 | +SERVER_URL = 'http://www.example.com/foo' |
648 | + |
649 | + |
650 | +def query_string(start=None, batch=None, memo=None, direction=None): |
651 | + query_string_args = {} |
652 | + params = locals() |
653 | + def maybe(arg): |
654 | + if params[arg] is not None: |
655 | + query_string_args[arg] = params[arg] |
656 | + maybe('batch') |
657 | + maybe('direction') |
658 | + maybe('start') |
659 | + maybe('memo') |
660 | + query_string = "&".join(["%s=%s" % item for item |
661 | + in sorted(query_string_args.items())]) |
662 | + return query_string |
663 | + |
664 | + |
665 | +def query_url(start=None, batch=None, memo=None, direction=None): |
666 | + return SERVER_URL + '?' + query_string(start=start, batch=batch, |
667 | + memo=memo,direction=direction) |
668 | + |
669 | + |
670 | +def batchnav_request(start=None, batch=None, memo=None, direction=None): |
671 | + return TestRequest(SERVER_URL=SERVER_URL, |
672 | + method='GET', environ={'QUERY_STRING': query_string(start=start, |
673 | + batch=batch, memo=memo,direction=direction)}) |
674 | + |
675 | + |
676 | +def sample_batchnav(start=None, batch=None, memo=None, direction=None): |
677 | + collection = range(1, 11) |
678 | + request = batchnav_request(start=start, batch=batch, memo=memo, |
679 | + direction=direction) |
680 | + range_factory = RecordingFactory(collection) |
681 | + return BatchNavigator(collection, request, range_factory=range_factory) |
682 | + |
683 | + |
684 | +class EqualsQuery(Equals): |
685 | + |
686 | + def __init__(self, start=None, batch=None, direction=None, memo=None): |
687 | + Equals.__init__(self, query_url(start=start, batch=batch, |
688 | + direction=direction, memo=memo)) |
689 | + |
690 | + |
691 | +class TestBatchFactory(testtools.TestCase): |
692 | + |
693 | + def test_accepts_range_factory(self): |
694 | + # A BatchNavigator can be instantiated with a RangeFactory. |
695 | + results = [1, 2, 3, 4] |
696 | + range_factory = ListRangeFactory(results) |
697 | + request = batchnav_request() |
698 | + batchnav = BatchNavigator(results, request, range_factory=range_factory) |
699 | + self.assertEqual(batchnav.batch.range_factory, range_factory) |
700 | + |
701 | + def test_without_memo_direction_gets_non_factory_batch(self): |
702 | + # memo and direction were added after the core was developed; for |
703 | + # compatability with bookmarks when they are missing the old behaviour |
704 | + # is invoked even when an explicit range factory is supplied |
705 | + batchnav = sample_batchnav(start=3, batch=3) |
706 | + self.assertEqual(range(4,7), list(batchnav.currentBatch())) |
707 | + self.assertEqual([], batchnav.batch.range_factory.calls) |
708 | + |
709 | + def test_without_memo_with_backwards_direction_gets_reverse_batch(self): |
710 | + # When direction is specified backwards without memo the batch seeks |
711 | + # backwards rather than doing a big list protocol slice. |
712 | + batchnav = sample_batchnav(start=3, batch=3, direction='backwards') |
713 | + self.assertEqual(range(10,11), list(batchnav.currentBatch())) |
714 | + self.assertEqual([('getSlice', '', False)], |
715 | + batchnav.batch.range_factory.calls) |
716 | + |
717 | + def test_memo_used_for_middle_batch_forwards(self): |
718 | + # If we have a memo for a batch, it is used. |
719 | + batchnav = sample_batchnav(start=3, batch=3, memo='3') |
720 | + self.assertEqual(range(4,7), list(batchnav.currentBatch())) |
721 | + self.assertEqual([('getSlice', '3', True)], |
722 | + batchnav.batch.range_factory.calls) |
723 | + |
724 | + def test_memo_used_for_middle_batch_backwards(self): |
725 | + # If we have a memo for a batch, it is used. |
726 | + batchnav = sample_batchnav(start=3, batch=3, memo='6', |
727 | + direction='backwards') |
728 | + self.assertEqual(range(4,7), list(batchnav.currentBatch())) |
729 | + self.assertEqual([('getSlice', '6', False)], |
730 | + batchnav.batch.range_factory.calls) |
731 | + |
732 | + def test_lastBatchURL_sets_direction(self): |
733 | + batchnav = sample_batchnav(batch=3) |
734 | + self.assertThat(batchnav.lastBatchURL(), |
735 | + EqualsQuery(start=9, batch=3, direction='backwards')) |
736 | + |
737 | + def test_firstBatchURL_is_trivial(self): |
738 | + batchnav = sample_batchnav(start=3, batch=3, memo='3') |
739 | + self.assertThat(batchnav.firstBatchURL(), EqualsQuery(batch=3)) |
740 | + |
741 | + def test_nextBatchURL_has_memo(self): |
742 | + batchnav = sample_batchnav(start=3, batch=3, memo='3') |
743 | + self.assertThat(batchnav.nextBatchURL(), |
744 | + EqualsQuery(start=6, batch=3, memo='6')) |
745 | + |
746 | + def test_prevBatchURL_has_memo_direction(self): |
747 | + batchnav = sample_batchnav(start=6, batch=3, memo='6') |
748 | + self.assertThat(batchnav.prevBatchURL(), |
749 | + EqualsQuery(start=3, batch=3, memo='6', direction='backwards')) |
750 | + |
751 | + |
752 | +class TestListRangeFactory(testtools.TestCase): |
753 | + |
754 | + def test_valid_object(self): |
755 | + verifyObject(IRangeFactory, ListRangeFactory([])) |
756 | + |
757 | + def test_getEndpointMemos_empty(self): |
758 | + batch = _Batch([], ListRangeFactory([])) |
759 | + self.assertEqual(('0', '0'), |
760 | + batch.range_factory.getEndpointMemos(batch)) |
761 | + |
762 | + def test_getEndpointMemos_one_item(self): |
763 | + results = [0, 1, 2] |
764 | + batch = _Batch(results, ListRangeFactory(results), start=1, size=1) |
765 | + self.assertEqual(('1', '2'), |
766 | + batch.range_factory.getEndpointMemos(batch)) |
767 | + |
768 | + def test_getEndpointMemos_two_items(self): |
769 | + results = [0, 1, 2, 3] |
770 | + batch = _Batch(results, ListRangeFactory(results), start=1, size=2) |
771 | + self.assertEqual(('1', '3'), |
772 | + batch.range_factory.getEndpointMemos(batch)) |
773 | + |
774 | + def test_getEndpointMemos_three_items(self): |
775 | + results = [0, 1, 2, 3, 4] |
776 | + batch = _Batch(results, ListRangeFactory(results), start=1, size=3) |
777 | + self.assertEqual(('1', '4'), |
778 | + batch.range_factory.getEndpointMemos(batch)) |
779 | + |
780 | + def test_getEndpointMemos_start_border(self): |
781 | + results = [0, 1] |
782 | + batch = _Batch(results, ListRangeFactory(results), size=1) |
783 | + self.assertEqual(('0', '1'), |
784 | + batch.range_factory.getEndpointMemos(batch)) |
785 | + |
786 | + def test_getEndpointMemos_end_border(self): |
787 | + results = [0, 1] |
788 | + batch = _Batch(results, ListRangeFactory(results), start=1, size=1) |
789 | + self.assertEqual(('1', '2'), |
790 | + batch.range_factory.getEndpointMemos(batch)) |
791 | + |
792 | + def test_getEndpointMemos_last_batch_end_border(self): |
793 | + results = [0, 1, 2, 3] |
794 | + batch = _Batch(results, ListRangeFactory(results), start=4, size=1) |
795 | + self.assertEqual(('4', '4'), |
796 | + batch.range_factory.getEndpointMemos(batch)) |
797 | + |
798 | + def test_getslice_bad_memo(self): |
799 | + self.assertRaises(InvalidBatchSizeError, |
800 | + ListRangeFactory([]).getSlice, 'foo') |
801 | + |
802 | + def test_getslice_next_end(self): |
803 | + # at the end, crickets... |
804 | + results = [0, 1, 2, 3, 4, 5] |
805 | + slice = ListRangeFactory(results).getSlice('6') |
806 | + self.assertEqual([], slice) |
807 | + |
808 | + def test_getslice_before_start(self): |
809 | + # at the end, crickets... |
810 | + results = [0, 1, 2, 3, 4, 5] |
811 | + slice = list(ListRangeFactory(results).getSlice('0', forwards=False)) |
812 | + self.assertEqual([], slice) |
813 | + |
814 | + def test_getslice_before_end(self): |
815 | + results = [0, 1, 2, 3, 4, 5] |
816 | + slice = list(ListRangeFactory(results).getSlice('6', forwards=False)) |
817 | + self.assertEqual([5, 4, 3, 2, 1, 0], slice) |
818 | + |
819 | + def test_getslice_next(self): |
820 | + # The slice returned starts where indicated but continues on. |
821 | + results = [0, 1, 2, 3, 4, 5] |
822 | + slice = ListRangeFactory(results).getSlice('3') |
823 | + self.assertEqual([3, 4, 5], slice) |
824 | + |
825 | + def test_getslice_before_middle(self): |
826 | + # Going backwards does not include the anchor (because going forwards |
827 | + # includes it) |
828 | + results = [0, 1, 2, 3, 4, 5] |
829 | + slice = list(ListRangeFactory(results).getSlice('3', forwards=False)) |
830 | + self.assertEqual([2, 1, 0], slice) |
831 | + |
832 | |
833 | === modified file 'src/lazr/batchnavigator/tests/test_z3batching.py' |
834 | --- src/lazr/batchnavigator/tests/test_z3batching.py 2010-08-18 11:38:56 +0000 |
835 | +++ src/lazr/batchnavigator/tests/test_z3batching.py 2011-04-06 03:20:48 +0000 |
836 | @@ -18,6 +18,7 @@ |
837 | |
838 | from lazr.batchnavigator.z3batching.batch import _Batch, BATCH_SIZE |
839 | from lazr.batchnavigator.z3batching.interfaces import IBatch |
840 | +from lazr.batchnavigator import ListRangeFactory |
841 | |
842 | |
843 | class ListWithExplosiveLen(list): |
844 | @@ -49,6 +50,20 @@ |
845 | operator.getitem, ListAllergicToNegativeIndexes([1,2,3]), -1) |
846 | |
847 | |
848 | +class RecordingFactory(ListRangeFactory): |
849 | + def __init__(self, results): |
850 | + ListRangeFactory.__init__(self, results) |
851 | + self.calls = [] |
852 | + |
853 | + def getEndpointMemos(self, batch): |
854 | + self.calls.append('getEndpointMemos') |
855 | + return ListRangeFactory.getEndpointMemos(self, batch) |
856 | + |
857 | + def getSlice(self, memo, forwards): |
858 | + self.calls.append(('getSlice', memo, forwards)) |
859 | + return ListRangeFactory.getSlice(self, memo, forwards) |
860 | + |
861 | + |
862 | class BatchTest(unittest.TestCase): |
863 | |
864 | def getData(self): |
865 | @@ -56,87 +71,95 @@ |
866 | 'seven', 'eight', 'nine', 'ten'] |
867 | |
868 | def test_Interface(self): |
869 | - self.failUnless(IBatch.providedBy(_Batch([], 0, 0))) |
870 | + self.failUnless(IBatch.providedBy(self.getBatch())) |
871 | + |
872 | + def getBatch(self, start=0, size=None): |
873 | + return _Batch(self.getData(), ListRangeFactory(self.getData()), start, |
874 | + size) |
875 | + |
876 | + def getRecordingBatch(self, start=0, size=None, memo=None, forwards=True): |
877 | + range_factory = RecordingFactory(self.getData()) |
878 | + batch = _Batch(range_factory.results, range_factory, start=start, |
879 | + size=size, range_forwards=forwards, range_memo=memo) |
880 | + return batch |
881 | |
882 | def test_constructor(self): |
883 | - _Batch(self.getData(), 9, 3) |
884 | - # A start that's larger than the length should still construct a working |
885 | - # (but empty) batch. |
886 | - _Batch(self.getData(), start=99, size=3) |
887 | + self.getBatch(9, 3) |
888 | + # A start that's larger than the length should still construct a |
889 | + # working (but empty) batch. |
890 | + self.getBatch(99, 3) |
891 | # If no size is provided a default is used. |
892 | - _Batch(self.getData()) |
893 | + _Batch(self.getData(), ListRangeFactory(self.getData())) |
894 | |
895 | def test_default_batch_size(self): |
896 | # If we don't specify a batch size, a default one is chosen for us. |
897 | - batch = _Batch(self.getData()) |
898 | + batch = _Batch(self.getData(), ListRangeFactory(self.getData())) |
899 | self.assertEqual(batch.size, BATCH_SIZE) |
900 | |
901 | def test__len__(self): |
902 | - batch = _Batch(self.getData(), 99, 3) |
903 | - self.assertEqual(len(batch), 0) |
904 | - |
905 | - batch = _Batch(self.getData(), 0, 3) |
906 | + batch = self.getBatch(0, 3) |
907 | self.assertEqual(len(batch), 3) |
908 | |
909 | - batch = _Batch(self.getData(), 9, 3) |
910 | + batch = self.getBatch(9, 3) |
911 | self.assertEqual(len(batch), 1) |
912 | |
913 | - batch = _Batch(self.getData(), 99, 3) |
914 | + batch = self.getBatch(99, 3) |
915 | self.assertEqual(len(batch), 0) |
916 | |
917 | # If the entire set of contents fits into a single batch, the result |
918 | # of len() is correct. |
919 | - batch = _Batch(self.getData(), size=999) |
920 | + batch = self.getBatch(size=999) |
921 | self.assertEqual(len(batch), 10) |
922 | |
923 | # If the entire set of contents fits into a single batch, and we've |
924 | # iterated over the items, the result of len() is correct. |
925 | - batch = _Batch(self.getData(), size=999) |
926 | + batch = self.getBatch(size=999) |
927 | list(batch) |
928 | self.assertEqual(len(batch), 10) |
929 | |
930 | def test__getitem__(self): |
931 | - batch = _Batch(self.getData(), 0, 3) |
932 | + batch = self.getBatch(0, 3) |
933 | self.assertEqual(batch[0], 'one') |
934 | self.assertEqual(batch[1], 'two') |
935 | self.assertEqual(batch[2], 'three') |
936 | - batch = _Batch(self.getData(), 3, 3) |
937 | + batch = self.getBatch(3, 3) |
938 | self.assertEqual(batch[0], 'four') |
939 | self.assertEqual(batch[1], 'five') |
940 | self.assertEqual(batch[2], 'six') |
941 | - batch = _Batch(self.getData(), 9, 3) |
942 | + batch = self.getBatch(9, 3) |
943 | self.assertRaises(IndexError, batch.__getitem__, 3) |
944 | |
945 | # If the batch is "off the end" and we've materialized the underlying |
946 | # list (by iterating over the batch) we will get an IndexError if we |
947 | # pass in a negative item index. |
948 | - batch = _Batch(self.getData(), 99, 3) |
949 | + batch = self.getBatch(99, 3) |
950 | list(iter(batch)) |
951 | self.assertRaises(IndexError, batch.__getitem__, -1) |
952 | |
953 | def test__iter__(self): |
954 | - batch = _Batch(self.getData(), 0, 3) |
955 | + batch = self.getBatch(0, 3) |
956 | self.assertEqual(list(iter(batch)), ['one', 'two', 'three']) |
957 | - batch = _Batch(self.getData(), 9, 3) |
958 | + batch = self.getBatch(9, 3) |
959 | self.assertEqual(list(iter(batch)), ['ten']) |
960 | - batch = _Batch(self.getData(), 99, 3) |
961 | + batch = self.getBatch(99, 3) |
962 | self.assertEqual(list(iter(batch)), []) |
963 | |
964 | def test__getitem__does_not_use_negative_indices(self): |
965 | # Some collections don't implement __len__, so slicing them doesn't |
966 | # work, therefore we want to be sure negative slices are not passed |
967 | # through to the underlying collection. |
968 | - batch = _Batch(ListAllergicToNegativeIndexes(self.getData()), 99, 3) |
969 | + data = ListAllergicToNegativeIndexes(self.getData()) |
970 | + batch = _Batch(data, ListRangeFactory(data), 99, 3) |
971 | list(iter(batch)) |
972 | self.assertRaises(IndexError, batch.__getitem__, -1) |
973 | |
974 | def test__contains__(self): |
975 | - batch = _Batch(self.getData(), 0, 3) |
976 | + batch = self.getBatch(0, 3) |
977 | self.assert_(batch.__contains__('one')) |
978 | self.assert_(batch.__contains__('two')) |
979 | self.assert_(batch.__contains__('three')) |
980 | self.assert_(not batch.__contains__('four')) |
981 | - batch = _Batch(self.getData(), 6, 3) |
982 | + batch = self.getBatch(6, 3) |
983 | self.assert_(not batch.__contains__('one')) |
984 | self.assert_(batch.__contains__('seven')) |
985 | self.assert_(not batch.__contains__('ten')) |
986 | @@ -145,7 +168,8 @@ |
987 | # If the current batch is known to be the last batch, then we can |
988 | # calculate the total number of items without calling len() on the |
989 | # underlying collection. |
990 | - batch = _Batch(ListWithExplosiveLen(self.getData()), 0, 20) |
991 | + data = ListWithExplosiveLen(self.getData()) |
992 | + batch = _Batch(data, ListRangeFactory(data), 0, 20) |
993 | |
994 | # If we get the total number of items before accessing the underlying |
995 | # data, we'll call len() on the collection object. |
996 | @@ -165,45 +189,45 @@ |
997 | will not be None ever. |
998 | """ |
999 | # first batch when we are at the beginning of the batch. |
1000 | - first = _Batch(self.getData(), 0, 3).firstBatch() |
1001 | + first = self.getBatch(0, 3).firstBatch() |
1002 | self.assertEqual(list(iter(first)), ['one', 'two', 'three']) |
1003 | # first batch when we are in the second set of items of the batch. |
1004 | - first = _Batch(self.getData(), 3, 3).firstBatch() |
1005 | + first = self.getBatch(3, 3).firstBatch() |
1006 | self.assertEqual(list(iter(first)), ['one', 'two', 'three']) |
1007 | # first batch when we are in the third set of items of the batch. |
1008 | - first = _Batch(self.getData(), 6, 3).firstBatch() |
1009 | + first = self.getBatch(6, 3).firstBatch() |
1010 | self.assertEqual(list(iter(first)), ['one', 'two', 'three']) |
1011 | # first batch when we are at the end of the batch. |
1012 | - first = _Batch(self.getData(), 9, 3).firstBatch() |
1013 | + first = self.getBatch(9, 3).firstBatch() |
1014 | self.assertEqual(list(iter(first)), ['one', 'two', 'three']) |
1015 | # first batch when we get a request for an out of range item. |
1016 | - first = _Batch(self.getData(), 99, 3).firstBatch() |
1017 | + first = self.getBatch(99, 3).firstBatch() |
1018 | self.assertEqual(list(iter(first)), ['one', 'two', 'three']) |
1019 | |
1020 | def test_nextBatch(self): |
1021 | - next = _Batch(self.getData(), 0, 3).nextBatch() |
1022 | + next = self.getBatch(0, 3).nextBatch() |
1023 | self.assertEqual(list(iter(next)), ['four', 'five', 'six']) |
1024 | nextnext = next.nextBatch() |
1025 | self.assertEqual(list(iter(nextnext)), ['seven', 'eight', 'nine']) |
1026 | - next = _Batch(self.getData(), 9, 3).nextBatch() |
1027 | + next = self.getBatch(9, 3).nextBatch() |
1028 | self.assertEqual(next, None) |
1029 | - next = _Batch(self.getData(), 99, 3).nextBatch() |
1030 | + next = self.getBatch(99, 3).nextBatch() |
1031 | self.assertEqual(next, None) |
1032 | |
1033 | def test_prevBatch(self): |
1034 | - prev = _Batch(self.getData(), 9, 3).prevBatch() |
1035 | + prev = self.getBatch(9, 3).prevBatch() |
1036 | self.assertEqual(list(iter(prev)), ['seven', 'eight', 'nine']) |
1037 | prevprev = prev.prevBatch() |
1038 | self.assertEqual(list(iter(prevprev)), ['four', 'five', 'six']) |
1039 | - prev = _Batch(self.getData(), 0, 3).prevBatch() |
1040 | + prev = self.getBatch(0, 3).prevBatch() |
1041 | self.assertEqual(prev, None) |
1042 | - prev = _Batch(self.getData(), 2, 3).prevBatch() |
1043 | + prev = self.getBatch(2, 3).prevBatch() |
1044 | self.assertEqual(list(iter(prev)), ['one', 'two', 'three']) |
1045 | |
1046 | # If we create a batch that's out of range, and don't get its |
1047 | # length or any of its data, its previous batch will also be |
1048 | # out of range. |
1049 | - out_of_range = _Batch(self.getData(), 99, 3) |
1050 | + out_of_range = self.getBatch(99, 3) |
1051 | out_of_range_2 = out_of_range.prevBatch() |
1052 | self.assertEqual(out_of_range_2.start, 96) |
1053 | |
1054 | @@ -215,19 +239,19 @@ |
1055 | will not be None ever. |
1056 | """ |
1057 | # last batch when we are at the beginning of the batch. |
1058 | - last = _Batch(self.getData(), 0, 3).lastBatch() |
1059 | + last = self.getBatch(0, 3).lastBatch() |
1060 | self.assertEqual(list(iter(last)), ['ten']) |
1061 | # last batch when we are in the second set of items of the batch. |
1062 | - last = _Batch(self.getData(), 3, 3).lastBatch() |
1063 | + last = self.getBatch(3, 3).lastBatch() |
1064 | self.assertEqual(list(iter(last)), ['ten']) |
1065 | # last batch when we are in the third set of items of the batch. |
1066 | - last = _Batch(self.getData(), 6, 3).lastBatch() |
1067 | + last = self.getBatch(6, 3).lastBatch() |
1068 | self.assertEqual(list(iter(last)), ['ten']) |
1069 | # last batch when we are at the end of the batch. |
1070 | - last = _Batch(self.getData(), 9, 3).lastBatch() |
1071 | + last = self.getBatch(9, 3).lastBatch() |
1072 | self.assertEqual(list(iter(last)), ['ten']) |
1073 | # last batch when we get a request for an out of range item. |
1074 | - last = _Batch(self.getData(), 99, 3).lastBatch() |
1075 | + last = self.getBatch(99, 3).lastBatch() |
1076 | self.assertEqual(list(iter(last)), ['ten']) |
1077 | |
1078 | # We are going to test now the same, but when we get a request of 5 |
1079 | @@ -236,93 +260,133 @@ |
1080 | # size that is multiple of the item list length. |
1081 | |
1082 | # last batch when we are at the beginning of the batch. |
1083 | - last = _Batch(self.getData(), 0, 5).lastBatch() |
1084 | + last = self.getBatch(0, 5).lastBatch() |
1085 | self.assertEqual( |
1086 | list(iter(last)), ['six', 'seven', 'eight', 'nine', 'ten']) |
1087 | # last batch when we are in the second set of items of the batch. |
1088 | - last = _Batch(self.getData(), 5, 5).lastBatch() |
1089 | + last = self.getBatch(5, 5).lastBatch() |
1090 | self.assertEqual( |
1091 | list(iter(last)), ['six', 'seven', 'eight', 'nine', 'ten']) |
1092 | # last batch when we get a request for an out of range item. |
1093 | - last = _Batch(self.getData(), 99, 5).lastBatch() |
1094 | + last = self.getBatch(99, 5).lastBatch() |
1095 | self.assertEqual( |
1096 | list(iter(last)), ['six', 'seven', 'eight', 'nine', 'ten']) |
1097 | |
1098 | def test_batchRoundTrip(self): |
1099 | - batch = _Batch(self.getData(), 0, 3).nextBatch() |
1100 | + batch = self.getBatch(0, 3).nextBatch() |
1101 | self.assertEqual(list(iter(batch.nextBatch().prevBatch())), |
1102 | list(iter(batch))) |
1103 | |
1104 | def test_first_last(self): |
1105 | - batch = _Batch(self.getData(), 0, 3) |
1106 | + batch = self.getBatch(0, 3) |
1107 | self.assertEqual(batch.first(), 'one') |
1108 | self.assertEqual(batch.last(), 'three') |
1109 | - batch = _Batch(self.getData(), 9, 3) |
1110 | + batch = self.getBatch(9, 3) |
1111 | self.assertEqual(batch.first(), 'ten') |
1112 | self.assertEqual(batch.last(), 'ten') |
1113 | - batch = _Batch(self.getData(), 99, 3) |
1114 | + batch = self.getBatch(99, 3) |
1115 | self.assertRaises(IndexError, batch.first) |
1116 | self.assertRaises(IndexError, batch.last) |
1117 | |
1118 | def test_total(self): |
1119 | - batch = _Batch(self.getData(), 0, 3) |
1120 | - self.assertEqual(batch.total(), 10) |
1121 | - batch = _Batch(self.getData(), 6, 3) |
1122 | - self.assertEqual(batch.total(), 10) |
1123 | - batch = _Batch(self.getData(), 99, 3) |
1124 | + batch = self.getBatch(0, 3) |
1125 | + self.assertEqual(batch.total(), 10) |
1126 | + batch = self.getBatch(6, 3) |
1127 | + self.assertEqual(batch.total(), 10) |
1128 | + batch = self.getBatch(99, 3) |
1129 | self.assertEqual(batch.total(), 10) |
1130 | |
1131 | def test_trueSize_of_full_batch(self): |
1132 | # The .trueSize property reports how many items are in the batch. |
1133 | - batch = _Batch(self.getData(), 0, 3) |
1134 | + batch = self.getBatch(0, 3) |
1135 | self.assertEqual(batch.trueSize, 3) |
1136 | |
1137 | def test_trueSize_of_last_batch(self): |
1138 | # If the current batch contains fewer than a full batch, .trueSize |
1139 | # reports how many items are in the batch. |
1140 | - batch = _Batch(self.getData(), 9, 3) |
1141 | + batch = self.getBatch(9, 3) |
1142 | self.assertEqual(batch.trueSize, 1) |
1143 | |
1144 | def test_optimized_trueSize_of_full_batch(self): |
1145 | # If the unerlying items in the batch have been materialized, there is |
1146 | # a code path that avoids calling len() on the underlying collection. |
1147 | - batch = _Batch(ListWithExplosiveLen(self.getData()), 0, 3) |
1148 | + data = ListWithExplosiveLen(self.getData()) |
1149 | + batch = _Batch(data, ListRangeFactory(data), 0, 3) |
1150 | next(iter(batch)) # Materialize the items. |
1151 | self.assertEqual(batch.trueSize, 3) |
1152 | |
1153 | def test_optimized_trueSize_of_last_batch(self): |
1154 | # If the current batch contains fewer than a full batch, .trueSize |
1155 | # reports how many items are in the batch. |
1156 | - batch = _Batch(ListWithExplosiveLen(self.getData()), 9, 3) |
1157 | + data = ListWithExplosiveLen(self.getData()) |
1158 | + batch = _Batch(data, ListRangeFactory(data), 9, 3) |
1159 | next(iter(batch)) # Materialize the items. |
1160 | self.assertEqual(batch.trueSize, 1) |
1161 | |
1162 | def test_startNumber(self): |
1163 | - batch = _Batch(self.getData(), 0, 3) |
1164 | + batch = self.getBatch(0, 3) |
1165 | self.assertEqual(batch.startNumber(), 1) |
1166 | - batch = _Batch(self.getData(), 9, 3) |
1167 | + batch = self.getBatch(9, 3) |
1168 | self.assertEqual(batch.startNumber(), 10) |
1169 | - batch = _Batch(self.getData(), 99, 3) |
1170 | + batch = self.getBatch(99, 3) |
1171 | self.assertEqual(batch.startNumber(), 100) |
1172 | |
1173 | def test_endNumber(self): |
1174 | # If a batch of size 3 starts at index 0, the human-friendly numbering |
1175 | # of the last item should be 3. |
1176 | - batch = _Batch(self.getData(), 0, 3) |
1177 | + batch = self.getBatch(0, 3) |
1178 | self.assertEqual(batch.endNumber(), 3) |
1179 | # If a batch of size 3 starts at index 9 and has just 1 item in it |
1180 | # (because there are 10 items total), the human-friendly numbering |
1181 | # of the last item should be 10. |
1182 | - batch = _Batch(self.getData(), 9, 3) |
1183 | + batch = self.getBatch(9, 3) |
1184 | self.assertEqual(batch.endNumber(), 10) |
1185 | # If the batch start exceeds the number number of items, then the |
1186 | # endNumber will be None to indicate that it makes no sense to ask. |
1187 | - batch = _Batch(self.getData(), 99, 3) |
1188 | + batch = self.getBatch(99, 3) |
1189 | self.assertEqual(batch.endNumber(), None) |
1190 | |
1191 | - |
1192 | -def additional_tests(): |
1193 | - return unittest.TestSuite(( |
1194 | - unittest.makeSuite(BatchTest), |
1195 | - unittest.makeSuite(TestingInfrastructureTest), |
1196 | - )) |
1197 | + def test_sliced_list_uses_range_factory(self): |
1198 | + batch = self.getRecordingBatch(memo='', forwards=False) |
1199 | + list(batch) |
1200 | + self.assertEqual([('getSlice', '', False)], batch.range_factory.calls) |
1201 | + |
1202 | + def test_firstBatch_generates_memo_None(self): |
1203 | + batch = self.getRecordingBatch(start=3, size=3, memo='3') |
1204 | + first = batch.firstBatch() |
1205 | + self.assertEqual('', first.range_memo) |
1206 | + self.assertEqual(True, first.range_forwards) |
1207 | + self.assertEqual(0, first.start) |
1208 | + self.assertEqual(3, first.size) |
1209 | + self.assertEqual(['one', 'two', 'three'], list(first)) |
1210 | + |
1211 | + def test_lastBatch_generates_memo_None_backwards(self): |
1212 | + batch = self.getRecordingBatch(start=3, size=3, memo='3') |
1213 | + last = batch.lastBatch() |
1214 | + self.assertEqual('', last.range_memo) |
1215 | + self.assertEqual(False, last.range_forwards) |
1216 | + # len() is known, and size() is known, so the last batch starts at |
1217 | + # len-size |
1218 | + self.assertEqual(9, last.start) |
1219 | + self.assertEqual(3, last.size) |
1220 | + self.assertEqual(['ten'], list(last)) |
1221 | + |
1222 | + def test_nextBatch_generates_upper_memo(self): |
1223 | + batch = self.getRecordingBatch(start=3, size=3, memo='3') |
1224 | + next = batch.nextBatch() |
1225 | + self.assertEqual('6', next.range_memo) |
1226 | + self.assertEqual(True, next.range_forwards) |
1227 | + self.assertEqual(6, next.start) |
1228 | + self.assertEqual(3, next.size) |
1229 | + self.assertEqual(['seven', 'eight', 'nine'], list(next)) |
1230 | + |
1231 | + def test_prevBatch_generates_lower_memo_backwards(self): |
1232 | + batch = self.getRecordingBatch(start=6, size=3, memo='3') |
1233 | + prev = batch.prevBatch() |
1234 | + self.assertEqual('6', prev.range_memo) |
1235 | + self.assertEqual(False, prev.range_forwards) |
1236 | + # len() is known, and size() is known, so the prev batch starts at |
1237 | + # len-size |
1238 | + self.assertEqual(3, prev.start) |
1239 | + self.assertEqual(3, prev.size) |
1240 | + self.assertEqual(['four', 'five', 'six'], list(prev)) |
1241 | |
1242 | === modified file 'src/lazr/batchnavigator/version.txt' |
1243 | --- src/lazr/batchnavigator/version.txt 2010-08-18 11:38:56 +0000 |
1244 | +++ src/lazr/batchnavigator/version.txt 2011-04-06 03:20:48 +0000 |
1245 | @@ -1,1 +1,1 @@ |
1246 | -1.2.2 |
1247 | +1.2.3 |
1248 | |
1249 | === modified file 'src/lazr/batchnavigator/z3batching/batch.py' |
1250 | --- src/lazr/batchnavigator/z3batching/batch.py 2010-08-18 18:57:39 +0000 |
1251 | +++ src/lazr/batchnavigator/z3batching/batch.py 2011-04-06 03:20:48 +0000 |
1252 | @@ -76,7 +76,23 @@ |
1253 | return self.listlength - self.start |
1254 | return self.size |
1255 | |
1256 | - def __init__(self, results, start=0, size=None, _listlength=None): |
1257 | + def __init__(self, results, range_factory, start=0, size=None, |
1258 | + range_forwards=None, range_memo=None, _listlength=None): |
1259 | + """Create a _Batch. |
1260 | + |
1261 | + :param start: Cosmetic indicator of the start of the batch. Has *no* |
1262 | + effect on returned data. |
1263 | + :param range_factory: A factory used to construct efficient views of |
1264 | + the results. |
1265 | + :param range_forwards: True if the range memo is at or before the start |
1266 | + of the batch. |
1267 | + :param range_memo: An endpoint memo from the range factory describing |
1268 | + how to get a slice from the factory which will have offset 0 |
1269 | + contain the first element of the batch (or the list, if not |
1270 | + range_forwards). '' for the extreme edge of the range, None to |
1271 | + have the start parameter take precedence and cause actual |
1272 | + slicing of results. |
1273 | + """ |
1274 | if results is None: |
1275 | results = [] |
1276 | self.list = results |
1277 | @@ -93,6 +109,11 @@ |
1278 | |
1279 | self.start = start |
1280 | self.end = start + size |
1281 | + self.range_factory = range_factory |
1282 | + self.range_memo = range_memo |
1283 | + if range_forwards is None: |
1284 | + range_forwards = True |
1285 | + self.range_forwards = range_forwards |
1286 | |
1287 | def __len__(self): |
1288 | assert self.trueSize >= 0, ('The number of items in a batch should ' |
1289 | @@ -108,7 +129,12 @@ |
1290 | # __len__, raise a mysterious exception directly from python. |
1291 | if self.start < 0: |
1292 | raise IndexError, 'batch is empty' |
1293 | - return self.list[self.start+key] |
1294 | + # We delegate to self.sliced_list because: |
1295 | + # - usually folk will be walking the batch and sliced_list optimises |
1296 | + # that. |
1297 | + # - if they aren't, the overhead of duplicating one batch of results in |
1298 | + # memory is small |
1299 | + return self.sliced_list[key] |
1300 | |
1301 | @Lazy |
1302 | def sliced_list(self): |
1303 | @@ -116,12 +142,47 @@ |
1304 | # time; in certain cases (such as when database access is necessary) |
1305 | # this can be expensive. |
1306 | # |
1307 | - # We get one more item than we need, so that we can detect |
1308 | - # cases where the underlying list ends on a batch boundary |
1309 | - # without having to check the total size of the list (another |
1310 | - # expensive operation). |
1311 | - sliced = list(self.list[self.start:self.end+1]) |
1312 | - |
1313 | + if self.range_memo is None: |
1314 | + # Legacyy mode - use the slice protocol on results. |
1315 | + sliced = list(self.list[self.start:self.end+1]) |
1316 | + else: |
1317 | + # The range factory gives us a partition on results starting from |
1318 | + # the previous actual result. |
1319 | + partition = self.range_factory.getSlice( |
1320 | + self.range_memo, self.range_forwards) |
1321 | + if self.range_memo is '' and not self.range_forwards: |
1322 | + # As a special case, when getting the last batch (range_memo |
1323 | + # None, range_forwards False) we calculate the number of items |
1324 | + # we would expect from the size of the collection. This is done |
1325 | + # because that is what the older code did when range_factory |
1326 | + # was added. |
1327 | + size = self.listlength % self.size |
1328 | + if not size: |
1329 | + size = self.size |
1330 | + else: |
1331 | + # We get one more item than we need, so that we can detect |
1332 | + # cases where the underlying list ends on a batch boundary |
1333 | + # without having to check the total size of the list (an |
1334 | + # expensive operation). |
1335 | + size = self.size + 1 |
1336 | + sliced = list(partition[:size]) |
1337 | + if not self.range_forwards: |
1338 | + sliced.reverse() |
1339 | + if self.range_memo is not '' and len(sliced) <= size: |
1340 | + if len(sliced) == size: |
1341 | + # sliced is meant to have an extra element *after* the |
1342 | + # content for end-of-collection-detection, but the |
1343 | + # partitioner has just walked backwards, so shuffle things |
1344 | + # around to suit. If we didn't get as much data as we asked |
1345 | + # for we are at the beginning of the collection and all |
1346 | + # elements are needed. |
1347 | + sliced = sliced[1:] + sliced[:1] |
1348 | + else: |
1349 | + # If we got fewer than expected walking backwards, the |
1350 | + # range memo may have constrained us. So we need to get |
1351 | + # some more results: |
1352 | + partition = self.range_factory.getSlice(self.range_memo) |
1353 | + sliced = sliced + partition[:size - len(sliced)] |
1354 | # This is the first time we get an inkling of (approximately) |
1355 | # how many items are in the list. This is the appropriate time |
1356 | # to handle edge cases. |
1357 | @@ -175,8 +236,10 @@ |
1358 | if len(self.sliced_list) <= self.size: |
1359 | return None |
1360 | |
1361 | - return _Batch( |
1362 | - self.list, start, self.size, _listlength=self._listlength) |
1363 | + memos = self.range_factory.getEndpointMemos(self) |
1364 | + return _Batch(self.list, self.range_factory, start, self.size, |
1365 | + range_memo=memos[1], range_forwards=True, |
1366 | + _listlength=self._listlength) |
1367 | |
1368 | def prevBatch(self): |
1369 | # The only case in which we should /not/ offer a previous batch |
1370 | @@ -191,12 +254,14 @@ |
1371 | # in this case, hopping back one batch would be starting at |
1372 | # -5, which doesn't really make sense. |
1373 | start = 0 |
1374 | - return _Batch(self.list, start, self.size, |
1375 | - _listlength=self._listlength) |
1376 | + memos = self.range_factory.getEndpointMemos(self) |
1377 | + return _Batch(self.list, self.range_factory, start, self.size, |
1378 | + range_memo=memos[0], range_forwards=False, |
1379 | + _listlength=self._listlength) |
1380 | |
1381 | def firstBatch(self): |
1382 | - return _Batch(self.list, 0, size=self.size, |
1383 | - _listlength=self._listlength) |
1384 | + return _Batch(self.list, self.range_factory, 0, size=self.size, |
1385 | + range_memo='', range_forwards=True, _listlength=self._listlength) |
1386 | |
1387 | def lastBatch(self): |
1388 | # Return the last possible batch for this dataset, at the |
1389 | @@ -205,8 +270,9 @@ |
1390 | last_batch_start = last_index - (last_index % self.size) |
1391 | if last_batch_start < 0: |
1392 | last_batch_start = 0 |
1393 | - return _Batch(self.list, last_batch_start, size=self.size, |
1394 | - _listlength=self.listlength) |
1395 | + return _Batch(self.list, self.range_factory, last_batch_start, |
1396 | + size=self.size, range_memo='', range_forwards=False, |
1397 | + _listlength=self._listlength) |
1398 | |
1399 | def total(self): |
1400 | return self.listlength |
This looks great, if not a little like War and Peace to read.
I'd like to see some more real-world tests, batching something of 500-ish results, but I don't feel compelled to insist.