Merge lp:~lifeless/lazr.batchnavigator/keyoffset into lp:lazr.batchnavigator

Proposed by Robert Collins
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
Reviewer Review Type Date Requested Status
Steve Kowalik (community) code Approve
Review via email: mp+56499@code.launchpad.net

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.

To post a comment you must log in.
Revision history for this message
Steve Kowalik (stevenk) wrote :

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.

review: Approve (code)
Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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

Subscribers

People subscribed via source and target branches