Merge lp:~abentley/launchpad/recipe-errors into lp:launchpad

Proposed by Aaron Bentley
Status: Rejected
Rejected by: Aaron Bentley
Proposed branch: lp:~abentley/launchpad/recipe-errors
Merge into: lp:launchpad
Diff against target: 126 lines (+48/-35)
2 files modified
lib/lp/code/browser/sourcepackagerecipe.py (+37/-35)
lib/lp/code/browser/tests/test_sourcepackagerecipe.py (+11/-0)
To merge this branch: bzr merge lp:~abentley/launchpad/recipe-errors
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) code Needs Fixing
Review via email: mp+42305@code.launchpad.net

Commit message

Handle bad branch for recipe editing

Description of the change

= Summary =
Fix bug #683321: Editing recipe oopses if invalid branch used

== Proposed fix ==
Unify error handling into a context manager

== Pre-implementation notes ==
None

== Implementation details ==
With this context manager, two operations (setRecipeText and
ISourcePackageRecipeSource).new) that produce the same exceptions have them
handled the same way. This ensures that adding and editing recipes has uniform
exception handling.

== Tests ==
bin/test -t edit_recipe_bad_base_branch sourcepackagerecipe

== Demo and Q/A ==
Create a recipe. Edit it to set the branch to an invalid value. You should
get an error message, not an OOPS.

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/code/browser/sourcepackagerecipe.py
  lib/lp/code/browser/tests/test_sourcepackagerecipe.py

./lib/lp/code/browser/sourcepackagerecipe.py
     338: Line exceeds 78 characters.
./lib/lp/code/browser/tests/test_sourcepackagerecipe.py
     370: E501 line too long (80 characters)
     752: E501 line too long (85 characters)
     766: E501 line too long (89 characters)
     778: E501 line too long (85 characters)
     793: E501 line too long (89 characters)
     809: E501 line too long (85 characters)
     370: Line exceeds 78 characters.
     752: Line exceeds 78 characters.
     766: Line exceeds 78 characters.
     778: Line exceeds 78 characters.
     793: Line exceeds 78 characters.
     796: Line exceeds 78 characters.
     809: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

This is inventive and thus a little shocking. I suppose the real problem is that the textual representations of these exception types are inadequate. Otherwise, all you needed here would be a tuple of exception types that represent input errors!

If you had such a tuple and usable error strings, you'd be able to do this in both use-cases instead of creating a context manager:

    try:

        pass # (or more likely, something useful)

    except recipe_input_errors, e:
        self.setFieldError('recipe_text', str(e))

But I'm sure you would have done that if you had the option of improving the text representations of these exception types.

Still, I can't help feeling that the solution is not the best one you could get here: on the one hand your context manager interacts with the view, which actually fits better in the calling code. On the other it acts as an overlay to a number of exception classes in a "poor man's dynamic dispatch" pattern. And on the gripping hand, it makes a three-way control-flow decision (raise, return, continue) that context managers weren't designed for. If that's one hand too many, well, that's my point!

The context-manager API forces you to let the control-flow decision spill back into the calling code. Which obscures control flow: a "raise" happens in the "with" body, an "except" may happen in the context manager, an exception may be re-raised in the context-manager language support based on the exit handler's return value, and then the caller either returns or continues based on a variable set in the exit handler. That's a lot for a reader to follow when trying to figure out what the view code will do—or what effects a prospective code change will have. Be nice to the future hacker who's in unfamiliar with the code and in a hurry to fix something: it may be you two years from now.

As either an alternative to the context manager or a refinement of it, have you considered extracting just the identification and representation of the input errors?

    try:

        pass # (view work here)

    except Exception, e:
        input_error = represent_recipe_input_error(e)
        if input_error is None:
            # Unexpected problem.
            raise
        else:
            # Known problem with the input.
            self.setFieldError('recipe_text', str(e))
            return

Even in combination with the context manager, separating the representation of the errors would make the control flow more concise.

If you do prefer to continue with the context-manager approach, I would ask for a few changes:
 * Unit-test the context manager.
 * Do exception representation in exception classes where possible.
 * Rename return_now so it doesn't make assumptions about the calling code.
 * Address the XXX one way or another.

Thanks for including "lint" output, by the way. Fixing the lint after review helps keep the diff clean.

review: Needs Fixing (code)

Unmerged revisions

12002. By Aaron Bentley

Unify error handling for recipe text

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
2--- lib/lp/code/browser/sourcepackagerecipe.py 2010-11-28 23:32:25 +0000
3+++ lib/lp/code/browser/sourcepackagerecipe.py 2010-11-30 20:43:10 +0000
4@@ -315,6 +315,38 @@
5 except RecipeParseError, error:
6 self.setFieldError('recipe_text', str(error))
7
8+ class HandleRecipeErrors():
9+ """Handle errors that may be encountered setting a new recipe text."""
10+
11+ def __init__(self, view):
12+ self.view = view
13+ self.return_now = False
14+
15+ def __enter__(self):
16+ return self
17+
18+ def __exit__(self, exc_type, exc_val, exc_tb):
19+ if exc_type is None:
20+ return True
21+ elif isinstance(exc_val, TooNewRecipeFormat):
22+ self.view.setFieldError(
23+ 'recipe_text',
24+ 'The recipe format version specified is not available.')
25+ elif isinstance(exc_val, ForbiddenInstructionError):
26+ self.view.setFieldError(
27+ 'recipe_text',
28+ 'The bzr-builder instruction "run" is not permitted here.')
29+ elif isinstance(exc_val, NoSuchBranch):
30+ self.view.setFieldError(
31+ 'recipe_text', '%s is not a branch on Launchpad.' %
32+ exc_val.name)
33+ elif isinstance(exc_val, PrivateBranchRecipe):
34+ self.view.setFieldError('recipe_text', str(exc_val))
35+ else:
36+ return False
37+ self.return_now = True
38+ return True
39+
40
41 class SourcePackageRecipeAddView(RecipeTextValidatorMixin, LaunchpadFormView):
42 """View for creating Source Package Recipes."""
43@@ -344,30 +376,14 @@
44
45 @action('Create Recipe', name='create')
46 def request_action(self, action, data):
47- try:
48+ with self.HandleRecipeErrors(self) as handler:
49 source_package_recipe = getUtility(
50 ISourcePackageRecipeSource).new(
51 self.user, data['owner'], data['name'],
52 data['recipe_text'], data['description'], data['distros'],
53 data['daily_build_archive'], data['build_daily'])
54 Store.of(source_package_recipe).flush()
55- except TooNewRecipeFormat:
56- self.setFieldError(
57- 'recipe_text',
58- 'The recipe format version specified is not available.')
59- return
60- except ForbiddenInstructionError:
61- # XXX: bug=592513 We shouldn't be hardcoding "run" here.
62- self.setFieldError(
63- 'recipe_text',
64- 'The bzr-builder instruction "run" is not permitted here.')
65- return
66- except NoSuchBranch, e:
67- self.setFieldError(
68- 'recipe_text', '%s is not a branch on Launchpad.' % e.name)
69- return
70- except PrivateBranchRecipe, e:
71- self.setFieldError('recipe_text', str(e))
72+ if handler.return_now:
73 return
74
75 self.next_url = canonical_url(source_package_recipe)
76@@ -437,25 +453,11 @@
77 parser = RecipeParser(recipe_text)
78 recipe = parser.parse()
79 if self.context.builder_recipe != recipe:
80- try:
81+ with self.HandleRecipeErrors(self) as handler:
82 self.context.setRecipeText(recipe_text)
83 changed = True
84- except TooNewRecipeFormat:
85- self.setFieldError(
86- 'recipe_text',
87- 'The recipe format version specified is not available.')
88- return
89- except ForbiddenInstructionError:
90- # XXX: bug=592513 We shouldn't be hardcoding "run" here.
91- self.setFieldError(
92- 'recipe_text',
93- 'The bzr-builder instruction "run" is not permitted'
94- ' here.')
95- return
96- except PrivateBranchRecipe, e:
97- self.setFieldError('recipe_text', str(e))
98- return
99-
100+ if handler.return_now:
101+ return
102
103 distros = data.pop('distros')
104 if distros != self.context.distroseries:
105
106=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
107--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2010-11-28 22:38:48 +0000
108+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2010-11-30 20:43:10 +0000
109@@ -557,6 +557,17 @@
110 extract_text(find_tags_by_class(browser.contents, 'message')[1]),
111 'The bzr-builder instruction "run" is not permitted here.')
112
113+ def test_edit_recipe_bad_base_branch(self):
114+ # If a user tries to create source package recipe with a bad base
115+ # branch location, they should get an error.
116+ recipe = self.factory.makeSourcePackageRecipe(owner=self.chef)
117+ browser = self.getViewBrowser(recipe, '+edit')
118+ browser.getControl('Recipe text').value = (
119+ MINIMAL_RECIPE_TEXT % 'foo')
120+ browser.getControl('Update Recipe').click()
121+ self.assertEqual(
122+ get_message_text(browser, 1), 'foo is not a branch on Launchpad.')
123+
124 def test_edit_recipe_format_too_new(self):
125 # If the recipe's format version is too new, we should notify the
126 # user.