Merge lp:~james-w/python-oops/refactor-publishing into lp:python-oops

Proposed by James Westby on 2012-06-08
Status: Merged
Approved by: Robert Collins on 2012-06-26
Approved revision: 41
Merged at revision: 29
Proposed branch: lp:~james-w/python-oops/refactor-publishing
Merge into: lp:python-oops
Diff against target: 503 lines (+324/-50)
5 files modified
oops/__init__.py (+9/-1)
oops/config.py (+62/-38)
oops/publishers.py (+72/-1)
oops/tests/test_config.py (+43/-5)
oops/tests/test_publishers.py (+138/-5)
To merge this branch: bzr merge lp:~james-w/python-oops/refactor-publishing
Reviewer Review Type Date Requested Status
Richard Harding (community) 2012-06-08 Approve on 2012-06-19
Robert Collins 2012-06-21 Pending
Review via email: mp+109428@code.launchpad.net

Commit Message

Refactor publication to better support publish_only_new.

Description of the Change

Hi,

Here's a stab at the approach we discussed on IRC, what do you think?

I think that maybe the publish method on Config should still do some looking
at the returned ids so that it can update the report, as the current docstring
says it is useful when there's only one publisher.

The approach taken was to leave things pretty much as they are for current
clients, but allow anyone that wants to allocate ids to have APIs they can use
to do it without losing fallback. Once clients have transitioned to the new
API then some cleanup can be done.

Thanks,

James

To post a comment you must log in.
James Westby (james-w) wrote :

Hi,

I'm going to ask for a review from any of the reviewers so that I
can move forwards with this.

Thanks,

James

Richard Harding (rharding) wrote :

Couple of nitpicks.

Typo in the comment 'publihers' in two cases there.

The 'only_new' terminology kind of confused me as I was expected it to refer to newly published items, but it seems it's more of a flag to only pass along items not already published. So I wonder if s/only_new/only_unpublished might be more explicit to the check going on.

For the deprecated paths, should we toss out deprecation warnings and use the normal XXX: policy to file a bug that this needs to get cleaned out?

https://dev.launchpad.net/PolicyandProcess/XXXPolicy

review: Approve
31. By James Westby on 2012-06-19

Small fixes from review. Thanks Rick.

32. By James Westby on 2012-06-19

Add a deprecation warning and XXX. Thanks Rich.

Robert Collins (lifeless) wrote :

The publish attribute needs more docs - what should it return, what is the protocol for a publisher with it. That will let the PublishToMany docs be smaller and more compact.

The results of .publish and .publishers should be compatible, so it doesn't need to be either or. Just call one then the other (eases migration).

I'm not sure about the 'and it picks one id' bit; that discards data. I'd be a lot happier without it. We can have a trivial publisher to choose a single result, to save folk reimplementing that.
def publish_one_id(publisher):
    def publish_one_id(report):
        ret = publisher(report)
        if not ret:
          return ret
        return ret[-1]
    return publish_one_id

You might like to extend list instead of composing a list; that would make introspection a little easier, give a default repr thats useful and so on.

Rather than a publish() method, define __call__, as the contract for publishers is that of simple callables, see what oops_amqp does, for instance.

Looking at this, I think we can make it simpler and remove the need for a bunch of tests; if PublishToMany were instead called 'publish_with_fallback', it could be:
def publish_with_fallback(*publishers):
    def result(report):
        ret = []
        for publisher in publishers:
            ret.extend(publisher(report)
            if ret:
                break
        return ret
    return result

publish_to_many then is just:
def publish_to_many(*publishers):
    def result(report):
        ret = []
        for publisher in publishers:
            ret.extend(publisher(report)
        return ret
    return result

What do you think?

33. By James Westby on 2012-06-20

Improve the docstrings. Thanks Rob.

34. By James Westby on 2012-06-20

Call both `publisher` and `publishers`.

35. By James Westby on 2012-06-20

Subclass list rather than compose it. Thanks Rob.

36. By James Westby on 2012-06-20

Use `__call__` rather than `publish`. Thanks Rob.

37. By James Westby on 2012-06-20

Use a couple of functions to do publish_to_many and publish_with_fallback. Thanks Rob.

38. By James Westby on 2012-06-20

Add a converter for old-style publishers.

James Westby (james-w) wrote :

Hi,

I've made the suggested changes.

Thanks,

James

Robert Collins (lifeless) wrote :

112 + allocated-or-used for the report. The `publishers` should return any
would read better as 'should each return'

116 + non-True (that is None, 0, False, ''), it indicate s that the
excess ' '

What do you think about not warning in this release? Give folk a chance to update without nags, and then nag later?

+ to return a list of ids. The last id in the returned list
51 + will be assigned to the reports 'id' key. See the publish() method
52 + for more information.

This makes a false claim AFAICT: nothing ensures that that assignment takes place.

The handling of publishers could be delegated to publish_to_many:

def publish(self, report):
    publishers = publish_to_many(*map(convert_result_to_list, self.publishers))
    if self.publisher:
        publisher = publish_to_many(self.publisher, publishers)
    else:
        publisher = publishers
    return publisher(report)

convert_result_to_list seems overly smart to me: I see the convenience (just wrap any publisher and its ok), but it makes the code more complex; it makes it significantly harder to deprecate as it doesn't tell you when a publisher has been migrated - though we can obviously add that).
I think I'd rather see:
convert_result_to_list = lambda x:[x] if x else []
where we have a crisp function and as folk update their dependencies they migrate from publishers to publisher.

This is a little confused about plurals:
190 + When publishing oopses to this publisher they will be in turn published
191 + to all publisher registered to this instance until one of the publishers
192 + reports successful publication, at which point no further publishers
193 + will be tried.
Perhaps:
This publisher will attempt to publish to each of the supplied publishers in turn until one of them successfully publishes the report.

"accumulated list of all lists " is a bit ambiguous (does it mean [[], [], []] or [], for all empty responses. Uhm. Perhaps the problem is that the docs are duplicating the definition of publisher at each place. it might be simpler to say 'this is a publisher, see Config.publish for the calling and return conventions. This publisher delegates to the supplied publishers by calling them all, and aggregates the results.' (Similarly for the first-successful case). What do you think?

TestPublishToMany is missing a corner case (no publishers) for its return value assertions.
Also, test_adds_ids_from_publishers is sufficient to test the 1-item case (if 0 works and 2 works, one really can't not).

test_doesnt_publish_old might be better as test_publish_stops_when_a_publisher_succeeds (as age isn't really the issue); also, its easier to prove a positive :)

39. By James Westby on 2012-06-26

More fixes from review. Thanks Rob.

James Westby (james-w) wrote :

Done.

> What do you think about not warning in this release? Give folk a chance to
> update without nags, and then nag later?

I'm going to leave the warning as instructed by #launchpad-dev. If you feel strongly
I'll take it out.

> The handling of publishers could be delegated to publish_to_many:
>
> def publish(self, report):
> publishers = publish_to_many(*map(convert_result_to_list,
> self.publishers))
> if self.publisher:
> publisher = publish_to_many(self.publisher, publishers)
> else:
> publisher = publishers
> return publisher(report)

Done, but this changes the behaviour, as nothing puts the id in the report
now, and so old style fallbacks won't work, and neither will any code that
expects to find the id in the report.

Thanks,

James

Robert Collins (lifeless) wrote :

Ah, we covered this on IRC, but I forgot to mention here.

I was proposing that:
 - publish(self, report): assign the last id to the report, for callers convenience.
 - publish_to_many(report) assign the id to the report as it goes, to allow multiple publishers to agree on the id.

40. By James Westby on 2012-06-26

Revert to putting ids in the report.

41. By James Westby on 2012-06-26

Document the putting of the id in to the report.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'oops/__init__.py'
2--- oops/__init__.py 2011-11-13 21:28:06 +0000
3+++ oops/__init__.py 2012-06-26 20:59:19 +0000
4@@ -29,8 +29,16 @@
5
6 __all__ = [
7 'Config',
8+ 'convert_result_to_list',
9 'publish_new_only',
10+ 'publish_to_many',
11+ 'publish_with_fallback',
12 ]
13
14 from oops.config import Config
15-from oops.publishers import publish_new_only
16+from oops.publishers import (
17+ convert_result_to_list,
18+ publish_new_only,
19+ publish_to_many,
20+ publish_with_fallback,
21+ )
22
23=== modified file 'oops/config.py'
24--- oops/config.py 2011-11-13 21:24:42 +0000
25+++ oops/config.py 2012-06-26 20:59:19 +0000
26@@ -24,7 +24,7 @@
27 >>> config = Config()
28 >>> def demo_publish(report):
29 ... return 'id 1'
30- >>> config.publishers.append(demo_publish)
31+ >>> config.publisher = demo_publish
32
33 This allows aggregation of oops reports from different programs into one
34 oops-tools install.
35@@ -100,33 +100,46 @@
36 __metaclass__ = type
37
38 from copy import deepcopy
39+import warnings
40
41 from createhooks import default_hooks
42+from publishers import (
43+ convert_result_to_list,
44+ publish_to_many,
45+ )
46
47
48 class Config:
49 """The configuration for the OOPS system.
50-
51+
52 :ivar on_create: A list of callables to call when making a new report. Each
53 will be called in series with the new report and a creation context
54 dict. The return value of the callbacks is ignored.
55 :ivar filters: A list of callables to call when filtering a report. Each
56 will be called in series with a report that is about to be published.
57- If the filter returns true (that is not None, 0, '' or False), then
58+ If the filter returns true (that is not None, 0, '' or False), then
59 the report will not be published, and the call to publish will return
60 None to the user.
61+ :ivar publisher: A callable to call when publishing a report.
62+ It will be called in series with the report to publish. It is expected
63+ to return a list of ids. See the publish() method for more
64+ information.
65 :ivar publishers: A list of callables to call when publishing a report.
66 Each will be called in series with the report to publish. Their return
67 value will be assigned to the reports 'id' key : if a publisher
68 allocates a different id than a prior publisher, only the last
69 publisher in the list will have its id present in the report at the
70- end. See the publish() method for more information.
71+ end. See the publish() method for more information. This attribute
72+ is deprecated, Use the `publisher` attribute instead, and see
73+ `oops.publishers.publish_to_many` if you want to publish to multiple
74+ publishers.
75 """
76
77 def __init__(self):
78 self.filters = []
79 self.on_create = list(default_hooks)
80 self.template = {}
81+ self.publisher = None
82 self.publishers = []
83
84 def create(self, context=None):
85@@ -153,43 +166,54 @@
86 def publish(self, report):
87 """Publish a report.
88
89- Each publisher is passed the report to publish. Publishers should
90- return the id they allocated-or-used for the report, which gets
91- automatically put into the report for them. All of the ids are also
92- returned to the caller, allowing them to handle the case when multiple
93- publishers allocated different ids. The id from the last publisher is
94- left in the report's id key. This is done for three reasons:
95-
96- * As a convenience for the common case when only one publisher is
97- present.
98-
99- * This allows the next publisher in the chain to reuse that id if it
100- wishes.
101-
102- * Showing users that a report was made only needs one id and this means
103- other code doesn't need to arbitrarily pick one - it is done by
104- publish().
105-
106- If a publisher raises an exception, that will propagate to the caller.
107- If a publisher returns anything non-True (that is None, 0, False, ''),
108- it indicates that the publisher did not publish the report and no change
109- will be made to the report's 'id' key for that publisher.
110- This permits fallback chains. For instance the first publisher may
111- choose not to publish a report (e.g. the place it would publish to is
112- offline) and the second publisher can detect that by checking if there
113- is an id key.
114+ The report will be passed through any filters, and then handed
115+ off to the callable assigned to the `publisher` instance variable,
116+ if any, and then passed through any publishers in the `publishers`
117+ list instance variable. The return value will be the list returned
118+ by `publisher` method, with the ids returned by the `publishers`
119+ appended. The `publishers` list is deprecated, and the `publisher`
120+ attribute should be used instead, with
121+ `oops.publishers.publish_to_many` used if needed.
122+
123+ The `publisher` should return a list of ids that were
124+ allocated-or-used for the report. The `publishers` should each return
125+ any id that was allocated-or-used for the report. The return value of
126+ the callables in the `publishers` list will be assigned to the `id`
127+ key of the report. If a publisher in the `publishers` list returns
128+ anything non-True (that is None, 0, False, ''), it indicates that the
129+ publisher did not publish the report.
130+
131+ The last entry in the list of ids, if any, will be assigned to the 'id'
132+ key of the report before returning, so that clients that only care to
133+ deal with one id don't have to pick one themselves.
134+
135+ The whole list of ids will be returned to the caller to allow them
136+ to handle the case where multiple ids were used for a report.
137+
138+ If any publisher raises an exception, that will propagate to the caller.
139
140 :return: A list of the allocated ids.
141 """
142 for report_filter in self.filters:
143 if report_filter(report):
144 return None
145- result = []
146- for publisher in self.publishers:
147- id = publisher(report)
148- if id:
149- report['id'] = id
150- result.append(id)
151- else:
152- result.append(None)
153- return result
154+ # XXX: james_w 2012-06-19 bug=1015293: Deprecated code path,
155+ # this should be removed once users have had a chance
156+ # to migrate. The constructor and docstrings should
157+ # also be cleaned up at the same time.
158+ if self.publishers:
159+ warnings.warn(
160+ "Using the oops.Config.publishers attribute is "
161+ "deprecated. Use the oops.Config.publisher attribute "
162+ "instead, with an oops.publishers.publish_to_many object "
163+ "if multiple publishers are needed",
164+ DeprecationWarning, stacklevel=2)
165+ old_publishers = map(convert_result_to_list, self.publishers)
166+ if self.publisher:
167+ publisher = publish_to_many(self.publisher, *old_publishers)
168+ else:
169+ publisher = publish_to_many(*old_publishers)
170+ ret = publisher(report)
171+ if ret:
172+ report['id'] = ret[-1]
173+ return ret
174
175=== modified file 'oops/publishers.py'
176--- oops/publishers.py 2011-11-13 21:24:42 +0000
177+++ oops/publishers.py 2012-06-26 20:59:19 +0000
178@@ -21,9 +21,10 @@
179 'publish_new_only',
180 ]
181
182+
183 def publish_new_only(publisher):
184 """Wraps a publisher with a check that the report has not had an id set.
185-
186+
187 This permits having fallback publishers that only publish if the earlier
188 one failed.
189
190@@ -31,9 +32,79 @@
191
192 >>> config.publishers.append(amqp_publisher)
193 >>> config.publishers.append(publish_new_only(datedir_repo.publish))
194+
195+ This function is deprecated. Instead please use publish_with_fallback.
196 """
197 def result(report):
198 if report.get('id'):
199 return None
200 return publisher(report)
201 return result
202+
203+
204+def publish_with_fallback(*publishers):
205+ """A publisher to fallback publishing through a list of publishers
206+
207+ This is a publisher, see Config.publish for the calling and return
208+ conventions. This publisher delegates to the supplied publishers
209+ by calling them all until one reports that it has published the
210+ report, and aggregates the results.
211+
212+ :param *publishers: a list of callables to publish oopses to.
213+ :return: a callable that will publish a report to each
214+ of the publishers when called.
215+ """
216+ def result(report):
217+ ret = []
218+ for publisher in publishers:
219+ ret.extend(publisher(report))
220+ if ret:
221+ break
222+ return ret
223+ return result
224+
225+
226+def publish_to_many(*publishers):
227+ """A fan-out publisher of oops reports.
228+
229+ This is a publisher, see Config.publish for the calling and return
230+ conventions. This publisher delegates to the supplied publishers
231+ by calling them all, and aggregates the results.
232+
233+ If a publisher returns a non-emtpy list (indicating that the report was
234+ published) then the last item of this list will be set as the 'id' key
235+ in the report before the report is passed to the next publisher. This
236+ makes it possible for publishers later in the chain to re-use the id.
237+
238+ :param *publishers: a list of callables to publish oopses to.
239+ :return: a callable that will publish a report to each
240+ of the publishers when called.
241+ """
242+ def result(report):
243+ ret = []
244+ for publisher in publishers:
245+ if ret:
246+ report['id'] = ret[-1]
247+ ret.extend(publisher(report))
248+ return ret
249+ return result
250+
251+
252+def convert_result_to_list(publisher):
253+ """Ensure that a publisher returns a list.
254+
255+ The old protocol for publisher callables was to return an id, or
256+ a False value if the report was not published. The new protocol
257+ is to return a list, which is empty if the report was not
258+ published.
259+
260+ This function coverts a publisher using the old protocol in to one that
261+ uses the new protocol, translating values as needed.
262+ """
263+ def publish(report):
264+ ret = publisher(report)
265+ if ret:
266+ return [ret]
267+ else:
268+ return []
269+ return publish
270
271=== modified file 'oops/tests/test_config.py'
272--- oops/tests/test_config.py 2011-11-13 21:24:42 +0000
273+++ oops/tests/test_config.py 2012-06-26 20:59:19 +0000
274@@ -72,14 +72,12 @@
275 def pub_1(report):
276 return '1'
277 def pub_2(report):
278- self.assertEqual('1', report['id'])
279 return '2'
280 config = Config()
281 config.publishers.append(pub_1)
282 config.publishers.append(pub_2)
283 report = {}
284 self.assertEqual(['1', '2'], config.publish(report))
285- self.assertEqual({'id': '2'}, report)
286
287 def test_publish_filters_first(self):
288 calls = []
289@@ -101,7 +99,7 @@
290 self.assertEqual(['ok', 'block'], calls)
291
292 def test_publishers_returning_not_published_no_change_to_report(self):
293- # If a publisher returns non-zero (indicating it did not publish) no
294+ # If a publisher returns False (indicating it did not publish) no
295 # change is made to the report - this permits chaining publishers as a
296 # primary and fallback: The fallback can choose to do nothing if there
297 # is an id already assigned (and returns None to signal what it did);
298@@ -114,5 +112,45 @@
299 config.publishers.append(pub_succeed)
300 config.publishers.append(pub_noop)
301 report = {}
302- self.assertEqual(['1', None], config.publish(report))
303- self.assertEqual({'id': '1'}, report)
304+ self.assertEqual(['1'], config.publish(report))
305+
306+ def test_publish_to_publisher(self):
307+ calls = []
308+ config = Config()
309+ def succeed(report):
310+ calls.append(report.copy())
311+ return ['a']
312+ config.publisher = succeed
313+ report = dict(foo='bar')
314+ config.publish(report)
315+ self.assertEqual([dict(foo='bar')], calls)
316+
317+ def test_returns_return_value_of_publisher(self):
318+ ids = ['a', 'b']
319+ def succeed(report):
320+ return ids
321+ config = Config()
322+ config.publisher = succeed
323+ report = dict(foo='bar')
324+ self.assertEqual(ids, config.publish(report))
325+
326+ def test_publisher_and_publishers_used_together(self):
327+ calls = []
328+ def nopublish(report):
329+ calls.append(report)
330+ return []
331+ config = Config()
332+ config.publisher = nopublish
333+ config.publishers.append(nopublish)
334+ config.publish({})
335+ self.assertEqual(2, len(calls))
336+
337+ def test_puts_id_in_report(self):
338+ the_id = 'b'
339+ def succeed(report):
340+ return ['a', the_id]
341+ config = Config()
342+ config.publisher = succeed
343+ report = dict(foo='bar')
344+ config.publish(report)
345+ self.assertEqual(the_id, report['id'])
346
347=== modified file 'oops/tests/test_publishers.py'
348--- oops/tests/test_publishers.py 2011-11-13 21:24:42 +0000
349+++ oops/tests/test_publishers.py 2012-06-26 20:59:19 +0000
350@@ -18,11 +18,16 @@
351 __metaclass__ = type
352
353 import testtools
354-from testtools.matchers import Is
355-
356-from oops import publish_new_only
357-
358-class TestPublishers(testtools.TestCase):
359+
360+from oops import (
361+ convert_result_to_list,
362+ publish_new_only,
363+ publish_to_many,
364+ publish_with_fallback,
365+ )
366+
367+
368+class TestPublisherNewOnly(testtools.TestCase):
369
370 def test_publish_new_only_id_in_report(self):
371 def pub_fail(report):
372@@ -35,3 +40,131 @@
373 publisher = publish_new_only(calls.append)
374 publisher({'foo': 'bar'})
375 self.assertEqual([{'foo': 'bar'}], calls)
376+
377+
378+class TestPublishToMany(testtools.TestCase):
379+
380+ def test_publishes_to_one(self):
381+ calls = []
382+ def capture(report):
383+ calls.append(report)
384+ return []
385+ publisher = publish_to_many(capture)
386+ publisher(dict(foo='bar'))
387+ self.assertEqual([dict(foo='bar')], calls)
388+
389+ def test_publishes_to_two(self):
390+ calls1 = []
391+ calls2 = []
392+ def capture1(report):
393+ calls1.append(report)
394+ return []
395+ def capture2(report):
396+ calls2.append(report)
397+ return []
398+ publisher = publish_to_many(capture1, capture2)
399+ publisher(dict(foo='bar'))
400+ self.assertEqual([dict(foo='bar')], calls1)
401+ self.assertEqual([dict(foo='bar')], calls2)
402+
403+ def test_returns_empty_list_with_no_publishers(self):
404+ publisher = publish_to_many()
405+ self.assertEqual([], publisher({}))
406+
407+ def test_adds_ids_from_publishers(self):
408+ first_ids = ['a', 'b']
409+ def first_success(report):
410+ return first_ids
411+ second_ids = ['c', 'd']
412+ def second_success(report):
413+ return second_ids
414+ publisher = publish_to_many(first_success, second_success)
415+ self.assertEqual(first_ids + second_ids, publisher({}))
416+
417+ def test_puts_id_in_report(self):
418+ the_id = 'b'
419+ def first(report):
420+ return ['a', the_id]
421+ def second(report):
422+ self.assertEqual(the_id, report['id'])
423+ return []
424+ publisher = publish_to_many(first, second)
425+ publisher({})
426+
427+ def test_puts_nothing_in_report_for_unpublished(self):
428+ def first(report):
429+ return []
430+ def second(report):
431+ if 'id' in report:
432+ self.fail("id set to %s when previous publisher "
433+ "didn't publish" % report['id'])
434+ return []
435+ publisher = publish_to_many(first, second)
436+ publisher({})
437+
438+
439+class TestPublishWithFallback(testtools.TestCase):
440+
441+ def test_publishes_to_one(self):
442+ calls = []
443+ def capture(report):
444+ calls.append(report)
445+ return []
446+ publisher = publish_with_fallback(capture)
447+ publisher(dict(foo='bar'))
448+ self.assertEqual([dict(foo='bar')], calls)
449+
450+ def test_publishes_to_two(self):
451+ calls1 = []
452+ calls2 = []
453+ def capture1(report):
454+ calls1.append(report)
455+ return []
456+ def capture2(report):
457+ calls2.append(report)
458+ return []
459+ publisher = publish_with_fallback(capture1, capture2)
460+ publisher(dict(foo='bar'))
461+ self.assertEqual([dict(foo='bar')], calls1)
462+ self.assertEqual([dict(foo='bar')], calls2)
463+
464+ def test_returns_ids_from_publisher(self):
465+ ids = ['a', 'b']
466+ def success(report):
467+ return ids
468+ publisher = publish_with_fallback(success)
469+ self.assertEqual(ids, publisher({}))
470+
471+ def test_publishes_new(self):
472+ def failure(report):
473+ return []
474+ calls = []
475+ def capture(report):
476+ calls.append(report)
477+ return []
478+ publisher = publish_with_fallback(failure, capture)
479+ self.assertEqual([], publisher({}))
480+ self.assertEqual(1, len(calls))
481+
482+ def test_publish_stops_when_a_publisher_succeeds(self):
483+ def success(report):
484+ return ['the id']
485+ def fail(report):
486+ self.fail("Called fallback when primary succeeded.")
487+ publisher = publish_with_fallback(success, fail)
488+ self.assertEqual(['the id'], publisher({}))
489+
490+
491+class ConvertResultToListTests(testtools.TestCase):
492+
493+ def test_converts_False_to_empty_list(self):
494+ # A false-ish value gets turned in to an empty list
495+ def falseish(report):
496+ return False
497+ self.assertEqual([], convert_result_to_list(falseish)({}))
498+
499+ def test_converts_True_to_list(self):
500+ # A true-ish value gets turned in to an empty list
501+ def trueish(report):
502+ return "aaa"
503+ self.assertEqual(["aaa"], convert_result_to_list(trueish)({}))

Subscribers

People subscribed via source and target branches

to all changes: