Merge lp:~javier.collado/utah/preseed_tests into lp:utah
- preseed_tests
- Merge into dev
Status: | Merged |
---|---|
Approved by: | Javier Collado |
Approved revision: | 742 |
Merged at revision: | 734 |
Proposed branch: | lp:~javier.collado/utah/preseed_tests |
Merge into: | lp:utah |
Diff against target: |
1174 lines (+807/-129) 4 files modified
docs/source/conf.py (+4/-1) docs/source/reference.rst (+7/-0) tests/test_preseed.py (+391/-25) utah/preseed.py (+405/-103) |
To merge this branch: | bzr merge lp:~javier.collado/utah/preseed_tests |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Max Brustkern (community) | Approve | ||
Joe Talbott (community) | Approve | ||
Review via email: mp+130851@code.launchpad.net |
Commit message
Description of the change
This branch:
- adds new test cases to the utah.preseed module
- fixes a couple of bugs found when the test cases were added
- applies some small refactoring to make the module easier to user
- updates the documentation strings
- adds the module to the reference section in the documentation
- fixes pep8 and pep257 warnings/errors
- 719. By Joe Talbott
-
Add 'run_as' to phoenix created tc_control files
- 720. By Joe Talbott
-
Make the default tests run as user 'nobody' since it's always there.
- 721. By Joe Talbott
-
phoenix - Adjust default ts_control to be more informative
- 722. By Joe Talbott
-
phoenix - Fix comment for fetch_method.
- 723. By Javier Collado
-
Merged changes to serialize long strings as literal strings in yaml
Literal strings were already used, but trailing whitespace wasn't removed and
that caused pyyaml to use double quoted strings instead. For more information:
http://pyyaml. org/ticket/ 240 Source branch: lp:~javier.collado/utah/bug1071265
- 724. By Javier Collado
-
Merged changes to execute utah client as root
Source branch: lp:~javier.collado/utah/bug1068664-2
- 725. By Javier Collado
-
Merged documentation updates from Joe
Source branch: lp:~joetalbott/utah/utah-dev_doc-updates
Small changes added as part of the merge:
- FAQ section moved to its own file (faq.rst)
- Fixed typo (make is possible -> make it possible)
- Reformatted paragraphs with long lines to fixed width
- Changed some single quotes that were still there (from other commits) to
double back quotes. - 726. By Javier Collado
-
Merged changes to add product_uuid to results
Source branch: lp:~joetalbott/utah/add_product_uuid
- 727. By Javier Collado
-
Merge Joe's changes to to support `dev` as a `fetch_method`
Source branch: lp:~joetalbott/utah/add_dev_method
- 728. By Max Brustkern
-
Pushing version to 0.5 to create a new stable version before UDS
- 729. By Nuclear Bob <max@daedelus>
-
Adding in config option was casting string into unicode and breaking pipe append
- 730. By Max Brustkern
-
Pass through name argument when getting machine
- 731. By Nuclear Bob <max@daedelus>
-
Merged initial arm support
- 732. By Javier Collado
-
Merged fix to avoid json parsing errors on empty configuration files
Source branch: lp:~javier.collado/utah/bug1075620
- 738. By Javier Collado
-
Updated documentation order to use that same one as in the source file.
- 739. By Javier Collado
-
Improved documentation
- Fixed markup to display cross-reference links
- Added doctest to source to provide working examples about how to use the
module - 740. By Javier Collado
-
Updated doctests
Added print to a couple of .dump methods to make the documentation more
readable. - 741. By Javier Collado
-
Added ability to run doctests
- 742. By Javier Collado
-
Fixed small problems when running doctests within the sphinx environment.
Javier Collado (javier.collado) wrote : | # |
This branch got a little bit old, so I rebased the changes to make them easier to merge.
Max, I know it's a big change, but it just adds testcases and documentation. If you want to run them, please try the following:
$ nosetests --with-doctest utah/preseed.py tests/
.......
-------
Ran 50 tests in 0.022s
OK
Max Brustkern (nuclearbob) wrote : | # |
I've looked over all of this, and it looks reasonable to me. I haven't tested it yet. If you have, go ahead and merge it, otherwise, I can try to test it today.
Max Brustkern (nuclearbob) wrote : | # |
I ran the tests as you specified. I didn't initially have python-mock installed, so it failed. Is there anywhere we ought to note that that's required for the tests, since it's not required to actually use the regular functionality? Either way, I still approve.
Javier Collado (javier.collado) wrote : | # |
@Max
The tests directory in the client has a README file. Probably there should be something similar for the server test cases.
Alternatively, a try/except block could be used to print a message when an ImportError exception is raised. Anyway, I don't think we should have that kind of detailed information for the test cases.
Preview Diff
1 | === modified file 'docs/source/conf.py' |
2 | --- docs/source/conf.py 2012-10-18 15:12:59 +0000 |
3 | +++ docs/source/conf.py 2012-11-07 09:32:22 +0000 |
4 | @@ -143,7 +143,10 @@ |
5 | |
6 | # Add any Sphinx extension module names here, as strings. They can be |
7 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. |
8 | -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] |
9 | +extensions = ['sphinx.ext.autodoc', # Include documentation from docstrings |
10 | + 'sphinx.ext.viewcode', # Add links to highlight source code |
11 | + 'sphinx.ext.doctest', # Test snippets in the documentation |
12 | + ] |
13 | |
14 | # Add any paths that contain templates here, relative to this directory. |
15 | templates_path = ['_templates'] |
16 | |
17 | === modified file 'docs/source/reference.rst' |
18 | --- docs/source/reference.rst 2012-09-03 09:27:10 +0000 |
19 | +++ docs/source/reference.rst 2012-11-07 09:32:22 +0000 |
20 | @@ -22,6 +22,13 @@ |
21 | .. automodule:: utah.iso |
22 | :members: |
23 | |
24 | +``utah.preseed`` |
25 | +---------------- |
26 | + |
27 | +.. automodule:: utah.preseed |
28 | + :members: |
29 | + :member-order: bysource |
30 | + |
31 | .. automodule:: utah.process |
32 | :members: |
33 | |
34 | |
35 | === modified file 'tests/test_preseed.py' |
36 | --- tests/test_preseed.py 2012-10-08 13:27:52 +0000 |
37 | +++ tests/test_preseed.py 2012-11-07 09:32:22 +0000 |
38 | @@ -1,46 +1,412 @@ |
39 | -from utah.preseed import ( |
40 | - Preseed, |
41 | - Section, |
42 | - BlankSection, |
43 | - CommentSection, |
44 | - ConfigurationSection, |
45 | - DuplicatedQuestionName, |
46 | - ) |
47 | +""" |
48 | +Test utah.preseed module |
49 | +""" |
50 | +from utah.preseed import (Preseed, |
51 | + Section, |
52 | + BlankSection, |
53 | + CommentSection, |
54 | + ConfigurationSection, |
55 | + DuplicatedQuestionName, |
56 | + ) |
57 | |
58 | import unittest |
59 | +from mock import Mock |
60 | |
61 | |
62 | class TestPreseedLoadDump(unittest.TestCase): |
63 | - """ |
64 | - Minimal load/dump test cases |
65 | - """ |
66 | - BASIC_PRESEED = """# Comment |
67 | + |
68 | + """Test preseed load/dump.""" |
69 | + |
70 | + PRESEED = """# Comment |
71 | |
72 | d-i passwd/username string utah |
73 | """ |
74 | |
75 | - DUPLICATED_QUESTION_NAME_PRESEED = """d-i passwd/username string utah |
76 | + DUPLICATED_LINE_PRESEED = """d-i passwd/username string utah |
77 | d-i passwd/username string utah |
78 | """ |
79 | |
80 | def test_load(self): |
81 | - """Load basic preseed""" |
82 | - preseed = Preseed(self.BASIC_PRESEED.splitlines()) |
83 | + """Load basic preseed.""" |
84 | + preseed = Preseed(self.PRESEED.splitlines()) |
85 | |
86 | self.assertEqual(len(preseed.sections), 3) |
87 | section_types = (CommentSection, BlankSection, ConfigurationSection) |
88 | for index, section_type in enumerate(section_types): |
89 | self.assertIsInstance(preseed.sections[index], section_type) |
90 | |
91 | + def test_duplicated_question_name(self): |
92 | + """Exception raised on duplicated question name.""" |
93 | + with self.assertRaises(DuplicatedQuestionName): |
94 | + Preseed(self.DUPLICATED_LINE_PRESEED.splitlines()) |
95 | + |
96 | def test_dump(self): |
97 | - """Dump basic preseed""" |
98 | + """Dump basic preseed.""" |
99 | preseed = Preseed() |
100 | - preseed.append(Section.new(preseed, '# Comment')) |
101 | - preseed.append(Section.new(preseed, '')) |
102 | - preseed.append(Section.new(preseed, 'd-i passwd/username string utah')) |
103 | - self.assertEqual(preseed.dump(), self.BASIC_PRESEED) |
104 | - |
105 | - def test_duplicated_question_name(self): |
106 | - """Exception raised on duplicated question name""" |
107 | - with self.assertRaises(DuplicatedQuestionName): |
108 | - Preseed(self.DUPLICATED_QUESTION_NAME_PRESEED.splitlines()) |
109 | + preseed.append(Section.new('# Comment\n'.splitlines())) |
110 | + preseed.append(Section.new('\n'.splitlines())) |
111 | + preseed.append(Section.new('d-i passwd/username string utah\n' |
112 | + .splitlines())) |
113 | + self.assertEqual(preseed.dump(), self.PRESEED) |
114 | + |
115 | + |
116 | +class TestPreseedIndexing(unittest.TestCase): |
117 | + |
118 | + """Test preseed indexing.""" |
119 | + |
120 | + PRESEED = """# Comment |
121 | + |
122 | +d-i passwd/username string utah |
123 | +""" |
124 | + |
125 | + def setUp(self): |
126 | + """Set preseed object for test cases.""" |
127 | + self.preseed = Preseed(self.PRESEED.splitlines()) |
128 | + |
129 | + def test_get_by_question_name(self): |
130 | + """Question can be retrieved by its name.""" |
131 | + qname = 'passwd/username' |
132 | + question = self.preseed[qname] |
133 | + self.assertEqual(question.qname, qname) |
134 | + |
135 | + def test_key_error_unknown_question_name(self): |
136 | + """Key error raised when trying to access an unknown question name.""" |
137 | + qname = 'unknown/unknwon' |
138 | + |
139 | + with self.assertRaises(KeyError): |
140 | + self.preseed[qname] |
141 | + |
142 | + def test_type_error_question_is_not_string(self): |
143 | + """Type error is raised when not using a string as key.""" |
144 | + with self.assertRaises(TypeError): |
145 | + self.preseed[0] |
146 | + |
147 | + def test_index_updated_on_question_name_update(self): |
148 | + """Index is updated when question name is updated.""" |
149 | + qname = 'passwd/username' |
150 | + new_qname = 'new/new' |
151 | + question = self.preseed[qname] |
152 | + question.qname = new_qname |
153 | + |
154 | + # Question can no longer be accessed using old qname |
155 | + with self.assertRaises(KeyError): |
156 | + self.preseed[qname] |
157 | + |
158 | + # Question can now be accessed using new qname |
159 | + new_question = self.preseed[new_qname] |
160 | + self.assertEqual(new_question.qname, new_qname) |
161 | + self.assertIs(new_question, question) |
162 | + |
163 | + |
164 | +class TestPreseedMembership(unittest.TestCase): |
165 | + |
166 | + """Test preseed membership.""" |
167 | + |
168 | + PRESEED = """# Comment |
169 | + |
170 | +d-i passwd/username string utah |
171 | +""" |
172 | + |
173 | + def setUp(self): |
174 | + """Set preseed object for test cases.""" |
175 | + self.preseed = Preseed(self.PRESEED.splitlines()) |
176 | + |
177 | + def test_contains(self): |
178 | + """True is returned if question name is in preseed.""" |
179 | + qname = 'passwd/username' |
180 | + self.assertIn(qname, self.preseed) |
181 | + |
182 | + def test_not_contains(self): |
183 | + """False is returned if question name is not in preseed.""" |
184 | + qname = 'unknown/unknown' |
185 | + self.assertNotIn(qname, self.preseed) |
186 | + |
187 | + |
188 | +class TestPreseedAppendPrepend(unittest.TestCase): |
189 | + |
190 | + """Test addition of new sections to a preseed.""" |
191 | + |
192 | + TIMES = 10 # Used as loop counter when adding multiple sections |
193 | + |
194 | + def setUp(self): |
195 | + """Set preseed object for test cases and a few sections.""" |
196 | + self.preseed = Preseed() |
197 | + |
198 | + def test_prepend_section(self): |
199 | + """Prepend section.""" |
200 | + for _ in range(self.TIMES): |
201 | + self.preseed.prepend(Section()) |
202 | + self.assertEqual(len(self.preseed.sections), self.TIMES) |
203 | + self.assertTrue(all([isinstance(section, Section) |
204 | + for section in self.preseed.sections])) |
205 | + |
206 | + def test_prepend_with_ref_section(self): |
207 | + """Prepend before a given section.""" |
208 | + sections = [Section() |
209 | + for section in range(self.TIMES)] |
210 | + for section in sections: |
211 | + self.preseed.prepend(section) |
212 | + |
213 | + index = len(sections) / 2 |
214 | + ref_section = sections[index] |
215 | + new_section = Section() |
216 | + self.preseed.prepend(new_section, ref_section) |
217 | + self.assertIs(self.preseed.sections[index - 1], new_section) |
218 | + |
219 | + def test_prepend_string(self): |
220 | + """Make a section from the given string and prepend it.""" |
221 | + line = '# Comment\n' |
222 | + self.preseed.prepend(line) |
223 | + self.assertEqual(str(self.preseed.sections[0]), line) |
224 | + |
225 | + def test_prepend_with_unknown_ref_section(self): |
226 | + """Exception raised if prepending to a section not in the preseed.""" |
227 | + ref_section = Section() |
228 | + new_section = Section() |
229 | + with self.assertRaises(ValueError): |
230 | + self.preseed.prepend(new_section, ref_section) |
231 | + |
232 | + def test_prepend_grouping_blank_section(self): |
233 | + """Prepend groups blank sections.""" |
234 | + # Prepending many blank section results in a single blank section |
235 | + # with all the empty lines joined together |
236 | + for _ in range(self.TIMES): |
237 | + self.preseed.prepend(BlankSection(1)) |
238 | + self.assertEqual(len(self.preseed.sections), 1) |
239 | + self.assertIsInstance(self.preseed.sections[0], Section) |
240 | + |
241 | + def test_prepend_grouping_comment_section(self): |
242 | + """Prepend groups comment sections.""" |
243 | + # Prepending many comments section results in a single comment section |
244 | + # with all the comment lines joined together |
245 | + for _ in range(self.TIMES): |
246 | + self.preseed.prepend(CommentSection('# Comment\n'.splitlines())) |
247 | + self.assertEqual(len(self.preseed.sections), 1) |
248 | + self.assertIsInstance(self.preseed.sections[0], Section) |
249 | + |
250 | + def test_append_section(self): |
251 | + """Append section.""" |
252 | + for _ in range(self.TIMES): |
253 | + self.preseed.append(Section()) |
254 | + self.assertEqual(len(self.preseed.sections), self.TIMES) |
255 | + self.assertTrue(all([isinstance(section, Section) |
256 | + for section in self.preseed.sections])) |
257 | + |
258 | + def test_append_with_ref_section(self): |
259 | + """Append after a given section.""" |
260 | + sections = [Section() |
261 | + for section in range(self.TIMES)] |
262 | + for section in sections: |
263 | + self.preseed.append(section) |
264 | + |
265 | + index = len(sections) / 2 |
266 | + ref_section = sections[index] |
267 | + new_section = Section() |
268 | + self.preseed.append(new_section, ref_section) |
269 | + self.assertIs(self.preseed.sections[index + 1], new_section) |
270 | + |
271 | + def test_append_string(self): |
272 | + """Make a section from the given string and append it.""" |
273 | + line = '# Comment\n' |
274 | + self.preseed.append(line) |
275 | + self.assertEqual(str(self.preseed.sections[0]), line) |
276 | + |
277 | + def test_append_with_unknown_ref_section(self): |
278 | + """Exception raised if appending to a section not in the preseed.""" |
279 | + ref_section = Section() |
280 | + new_section = Section() |
281 | + with self.assertRaises(ValueError): |
282 | + self.preseed.append(new_section, ref_section) |
283 | + |
284 | + def test_append_grouping_blank_section(self): |
285 | + """Append groups blank sections.""" |
286 | + # Appending many blank section results in a single blank section |
287 | + # with all the empty lines joined together |
288 | + for _ in range(self.TIMES): |
289 | + self.preseed.append(BlankSection(1)) |
290 | + self.assertEqual(len(self.preseed.sections), 1) |
291 | + self.assertIsInstance(self.preseed.sections[0], Section) |
292 | + |
293 | + def test_prepend_append_comment_section(self): |
294 | + """Append groups comment sections.""" |
295 | + # Appending many comments section results in a single comment section |
296 | + # with all the comment lines joined together |
297 | + for _ in range(self.TIMES): |
298 | + self.preseed.prepend(CommentSection('# Comment\n'.splitlines())) |
299 | + self.assertEqual(len(self.preseed.sections), 1) |
300 | + self.assertIsInstance(self.preseed.sections[0], Section) |
301 | + |
302 | + |
303 | +class TestSection(unittest.TestCase): |
304 | + |
305 | + """Test section object instantiation.""" |
306 | + |
307 | + def test_new_blank_section(self): |
308 | + """Convert empty lines to a blank section.""" |
309 | + lines_count = 5 |
310 | + lines = '\n' * lines_count |
311 | + section = Section.new(lines.splitlines()) |
312 | + self.assertIsInstance(section, BlankSection) |
313 | + self.assertEqual(str(section), lines) |
314 | + |
315 | + def test_new_comment_section(self): |
316 | + """Convert lines that start with a hash char to a comment section.""" |
317 | + lines = """# this is a comment |
318 | +# this is another comment |
319 | +""" |
320 | + section = Section.new(lines.splitlines()) |
321 | + self.assertIsInstance(section, CommentSection) |
322 | + self.assertEqual(str(section), lines) |
323 | + |
324 | + def test_new_configuration_section_single_line(self): |
325 | + """Question line => configuration section.""" |
326 | + lines = """d-i passwd/username string utah |
327 | +""" |
328 | + section = Section.new(lines.splitlines()) |
329 | + self.assertIsInstance(section, ConfigurationSection) |
330 | + self.assertEqual(str(section), lines) |
331 | + |
332 | + def test_new_configuration_section_multiple_lines(self): |
333 | + """Question lines => configuration section.""" |
334 | + lines = """d-i preseed/late_command string first_command;\\ |
335 | +second_command;\\ |
336 | +third_command; |
337 | +""" |
338 | + section = Section.new(lines.splitlines()) |
339 | + self.assertIsInstance(section, ConfigurationSection) |
340 | + |
341 | + # Note that ConfigurationSection object doesn't dump the exat input |
342 | + # but a one line version of it |
343 | + self.assertEqual(str(section), lines.replace('\\\n', ' ')) |
344 | + |
345 | + |
346 | +class TestBlankSection(unittest.TestCase): |
347 | + |
348 | + """Test blank section.""" |
349 | + |
350 | + def test_str(self): |
351 | + """Blank section string representation.""" |
352 | + lines_count = 10 |
353 | + lines = '\n' * lines_count |
354 | + section = BlankSection(lines_count) |
355 | + self.assertEqual(section.lines_count, lines_count) |
356 | + self.assertEqual(str(section), lines) |
357 | + |
358 | + def test_add(self): |
359 | + """Add two blank sections.""" |
360 | + section1 = BlankSection(3) |
361 | + section2 = BlankSection(5) |
362 | + section = section1 + section2 |
363 | + self.assertEqual(section.lines_count, |
364 | + section1.lines_count + section2.lines_count) |
365 | + |
366 | + def test_iadd(self): |
367 | + """Add two blank sections with side effect.""" |
368 | + lines1 = 3 |
369 | + lines2 = 5 |
370 | + section1 = BlankSection(lines1) |
371 | + section2 = BlankSection(lines2) |
372 | + section1 += section2 |
373 | + self.assertEqual(section1.lines_count, |
374 | + lines1 + lines2) |
375 | + |
376 | + |
377 | +class TestCommentSection(unittest.TestCase): |
378 | + |
379 | + """Test comment section.""" |
380 | + |
381 | + def test_str(self): |
382 | + """Comment section string representation.""" |
383 | + comment = '# Comment\n' |
384 | + section = CommentSection(comment.splitlines()) |
385 | + self.assertEqual(str(section), comment) |
386 | + |
387 | + def test_add(self): |
388 | + """Add two comment sections.""" |
389 | + comment1 = '# Comment 1\n' |
390 | + comment2 = '# Comment 1\n' |
391 | + section1 = CommentSection(comment1.splitlines()) |
392 | + section2 = CommentSection(comment2.splitlines()) |
393 | + section = section1 + section2 |
394 | + self.assertEqual(str(section), comment1 + comment2) |
395 | + |
396 | + def test_iadd(self): |
397 | + """Add two comment sections with side effect.""" |
398 | + comment1 = '# Comment 1\n' |
399 | + comment2 = '# Comment 1\n' |
400 | + section1 = CommentSection(comment1.splitlines()) |
401 | + section2 = CommentSection(comment2.splitlines()) |
402 | + section1 += section2 |
403 | + self.assertEqual(str(section1), comment1 + comment2) |
404 | + |
405 | + |
406 | +class TestConfigurationSection(unittest.TestCase): |
407 | + |
408 | + """Test configuration section.""" |
409 | + |
410 | + def setUp(self): |
411 | + """Set section and preseed for test cases.""" |
412 | + self.line = 'd-i passwd/username string utah\n' |
413 | + self.preseed = Preseed() |
414 | + self.section = ConfigurationSection(self.line.splitlines()) |
415 | + self.preseed.append(self.section) |
416 | + |
417 | + def test_str(self): |
418 | + """Configuration section string representation.""" |
419 | + self.assertEqual(str(self.section), self.line) |
420 | + |
421 | + def test_malformed_input(self): |
422 | + """Raise exception when line cannot be parsed correctly.""" |
423 | + with self.assertRaises(ValueError): |
424 | + ConfigurationSection('not valid\n'.splitlines()) |
425 | + |
426 | + def test_prepend(self): |
427 | + """Prepend new section to this one in the preseed.""" |
428 | + self.preseed.prepend = Mock() |
429 | + new_section = Section() |
430 | + self.section.prepend(new_section) |
431 | + self.preseed.prepend.assert_called_once_with(new_section, self.section) |
432 | + |
433 | + def test_prepend_string(self): |
434 | + """Make a section from the given string and prepend it.""" |
435 | + self.preseed.prepend = Mock() |
436 | + line = '# Comment\n' |
437 | + self.section.prepend(line) |
438 | + args, _kwargs = self.preseed.prepend.call_args |
439 | + self.assertEqual(str(args[0]), line) |
440 | + self.assertEqual(args[1], self.section) |
441 | + |
442 | + def test_append(self): |
443 | + """Append new section to this one in the preseed.""" |
444 | + self.preseed.append = Mock() |
445 | + new_section = Section() |
446 | + self.section.append(new_section) |
447 | + self.preseed.append.assert_called_once_with(new_section, self.section) |
448 | + |
449 | + def test_append_string(self): |
450 | + """Make a section from the given string and append it.""" |
451 | + self.preseed.append = Mock() |
452 | + line = '# Comment\n' |
453 | + self.section.append(line) |
454 | + args, _kwargs = self.preseed.append.call_args |
455 | + self.assertEqual(str(args[0]), line) |
456 | + self.assertEqual(args[1], self.section) |
457 | + |
458 | + def test_properties(self): |
459 | + """Configuration section splits line into properties.""" |
460 | + self.assertEqual(self.section.owner, 'd-i') |
461 | + self.assertEqual(self.section.qname, 'passwd/username') |
462 | + self.assertEqual(self.section.qtype, 'string') |
463 | + self.assertEqual(self.section.value, 'utah') |
464 | + |
465 | + def test_section_updated(self): |
466 | + """Call section_updated in preseed when a propery is updated.""" |
467 | + method_mock = Mock() |
468 | + self.preseed.section_updated = method_mock |
469 | + # qname is tested because is the property that the preseed |
470 | + # uses to provide index based access |
471 | + self.section.qname = 'new_qname' |
472 | + method_mock.assert_called_with(self.section, |
473 | + 'qname', |
474 | + 'passwd/username', |
475 | + 'new_qname') |
476 | |
477 | === modified file 'utah/preseed.py' |
478 | --- utah/preseed.py 2012-10-11 13:41:49 +0000 |
479 | +++ utah/preseed.py 2012-11-07 09:32:22 +0000 |
480 | @@ -1,13 +1,66 @@ |
481 | -""" |
482 | -Pressed files handling |
483 | +r"""This module provides all the classes needed to: |
484 | + - Parse a preseed file |
485 | + - Update some values |
486 | + - Add new sections |
487 | + - Write the changes back to a file |
488 | + |
489 | +The expected way to use it is by passing a file-like object or an iterable that |
490 | +yields one line of the preseed at a time: |
491 | + |
492 | +>>> from utah.preseed import Preseed |
493 | +>>> from StringIO import StringIO |
494 | +>>> preseed_text = StringIO( |
495 | +... '# Comment\n' |
496 | +... '\n' |
497 | +... 'd-i passwd/username string utah\n') |
498 | +>>> preseed = Preseed(preseed_text) |
499 | + |
500 | +After that, any of the configuration sections can be accessed by the question |
501 | +name: |
502 | + |
503 | +>>> section = preseed['passwd/username'] |
504 | +>>> section |
505 | +<ConfigurationSection: 'd-i passwd/username string utah\n'> |
506 | + |
507 | +and values can be updated by setting them directly in the section objects: |
508 | + |
509 | +>>> section.value = 'ubuntu' |
510 | +>>> section |
511 | +<ConfigurationSection: 'd-i passwd/username string ubuntu\n'> |
512 | + |
513 | +In addition to this, if a new section is needed, it can be appended/prepended |
514 | +to the preseed by calling directly the :class:`Preseed` methods or the |
515 | +:class:`ConfigurationSection` methods to use the section as a reference, that |
516 | +is, append/prepend after/before the given section. |
517 | + |
518 | +>>> section.append('d-i passwd/user-password password\n') |
519 | +>>> section.append('d-i passwd/user-password-again password\n') |
520 | + |
521 | +Once the desired changes have been applied, the :meth:`Preseed.dump` method can |
522 | +be used to write the output to a new file: |
523 | + |
524 | +>>> print preseed.dump() |
525 | +# Comment |
526 | +<BLANKLINE> |
527 | +d-i passwd/username string ubuntu |
528 | +d-i passwd/user-password-again password |
529 | +d-i passwd/user-password password |
530 | +<BLANKLINE> |
531 | + |
532 | """ |
533 | import string |
534 | |
535 | |
536 | class Preseed(object): |
537 | - """ |
538 | - Read/Write preseed files easily |
539 | - """ |
540 | + |
541 | + """Read/Write preseed files easily. |
542 | + |
543 | + :param lines: File-like object or iterable that yields one line from the |
544 | + preseed at a time. |
545 | + :type lines: iterable |
546 | + |
547 | + """ |
548 | + |
549 | def __init__(self, lines=None): |
550 | # Used to access quickly to configuration sections by question name |
551 | self._qnames = {} |
552 | @@ -17,8 +70,13 @@ |
553 | self.sections = [] |
554 | |
555 | def __getitem__(self, key): |
556 | - """ |
557 | - Access lines directly by their question name |
558 | + """Access lines directly by their question name. |
559 | + |
560 | + :param key: Question name |
561 | + :type: `basestring` | :class:`TextPropertyValue` |
562 | + :returns: Section in the preseed that matches the passed question name |
563 | + :rtype: :class:`Section` |
564 | + |
565 | """ |
566 | if isinstance(key, TextPropertyValue): |
567 | key = key.text |
568 | @@ -28,8 +86,14 @@ |
569 | return self._qnames[key] |
570 | |
571 | def __contains__(self, key): |
572 | - """ |
573 | - Use in operator with question names |
574 | + """Use in operator with question names. |
575 | + |
576 | + :param key: Question name |
577 | + :type: `basestring` | `TextPropertyValue` |
578 | + :returns: Whether a section that matches the question name is in the |
579 | + preseed or not |
580 | + :rtype: `bool` |
581 | + |
582 | """ |
583 | if isinstance(key, TextPropertyValue): |
584 | key = key.text |
585 | @@ -39,11 +103,17 @@ |
586 | return key in self._qnames |
587 | |
588 | def load(self, lines): |
589 | - """ |
590 | - Parse preseed configuration lines |
591 | + """Parse preseed configuration lines. |
592 | + |
593 | + This method is automatically called at initialization time if the |
594 | + `lines` parameter is passed to the constructor, so it's not really |
595 | + expected to be used directly. |
596 | |
597 | :param lines: Any iterable that yields preseed file configuration lines |
598 | :type lines: iterable |
599 | + :return: Preseed file object with information parsed |
600 | + :rtype: :class:`Preseed` |
601 | + |
602 | """ |
603 | self.sections = [] |
604 | # One line might be made of multiple lines |
605 | @@ -55,27 +125,99 @@ |
606 | |
607 | # Line is finished only when no continuation character is found |
608 | if not input_line.endswith('\\'): |
609 | - new_section = Section.new(self, output_lines) |
610 | + new_section = Section.new(output_lines) |
611 | self.append(new_section) |
612 | output_lines = [] |
613 | |
614 | return self |
615 | |
616 | def dump(self): |
617 | - """ |
618 | - Dump preseed configuration statements |
619 | - Write the modified file to the same or a new location |
620 | + r"""Dump preseed configuration statements. |
621 | + |
622 | + This method returns the contents of the preseed after the changes |
623 | + applied. The string returned is normally used to write the changes back |
624 | + to a file that can be used as the new preseed to provision a system. |
625 | |
626 | :returns: Formatted preseed configuration lines |
627 | - :rtype: string |
628 | + :rtype: `string` |
629 | + |
630 | + >>> preseed = Preseed('# Comment\n'.splitlines()) |
631 | + >>> preseed.dump() |
632 | + '# Comment\n' |
633 | + |
634 | """ |
635 | return ''.join(str(section) for section in self.sections) |
636 | |
637 | + def prepend(self, new_section, ref_section=None): |
638 | + r"""Prepend a new section to the preseed. |
639 | + |
640 | + :param new_section: The new section to be prepended. If a string is |
641 | + passed instead, a new section will be created from the string. |
642 | + :type new_section: :class:`Section` | `basestring` |
643 | + :param ref_section: A section to be used as a reference, meaning that |
644 | + the new section will be prepended after the reference section. If no |
645 | + reference section is passed, then the new section will be prepended |
646 | + just to the beginning of the preseed. |
647 | + :type ref_section: :class:`Section` |
648 | + |
649 | + >>> preseed = Preseed('d-i passwd/username string utah\n'.splitlines()) |
650 | + >>> preseed.prepend('# Comment') |
651 | + >>> print preseed.dump() |
652 | + # Comment |
653 | + d-i passwd/username string utah |
654 | + <BLANKLINE> |
655 | + |
656 | + """ |
657 | + if isinstance(new_section, basestring): |
658 | + new_section = Section.new(new_section.splitlines()) |
659 | + assert isinstance(new_section, Section) |
660 | + assert new_section.parent is None |
661 | + if ref_section is None: |
662 | + index = 0 |
663 | + else: |
664 | + for index, section in enumerate(self.sections): |
665 | + if section is ref_section: |
666 | + break |
667 | + else: |
668 | + raise ValueError('Reference section not found: {}' |
669 | + .format(ref_section)) |
670 | + |
671 | + if (self.sections and |
672 | + isinstance(new_section, (BlankSection, CommentSection)) and |
673 | + type(new_section) == type(self.sections[index])): |
674 | + # Old section to be replaced, won't have a parent anymore |
675 | + self.sections[index].parent = None |
676 | + grouped_section = new_section + self.sections[index] |
677 | + self.sections[index] = grouped_section |
678 | + # New section is now included in the preseed |
679 | + grouped_section.parent = self |
680 | + else: |
681 | + self._insert(index, new_section) |
682 | + |
683 | def append(self, new_section, ref_section=None): |
684 | - """ |
685 | - Append a new section |
686 | - """ |
687 | + r"""Append a new section to the preseed. |
688 | + |
689 | + :param new_section: The new section to be appended. If a string is |
690 | + passed instead, a new section will be created from the string. |
691 | + :type new_section: :class:`Section` | `basestring` |
692 | + :param ref_section: A section to be used as a reference, meaning that |
693 | + the new section will be appended after the reference section. If no |
694 | + reference section is passed, then the new section will be appended |
695 | + just to the end of the preseed. |
696 | + :type ref_section: :class:`Section` |
697 | + |
698 | + >>> preseed = Preseed('# Comment\n'.splitlines()) |
699 | + >>> preseed.append('d-i passwd/username string utah\n') |
700 | + >>> print preseed.dump() |
701 | + # Comment |
702 | + d-i passwd/username string utah |
703 | + <BLANKLINE> |
704 | + |
705 | + """ |
706 | + if isinstance(new_section, basestring): |
707 | + new_section = Section.new(new_section.splitlines()) |
708 | assert isinstance(new_section, Section) |
709 | + assert new_section.parent is None |
710 | if ref_section is None: |
711 | index = len(self.sections) - 1 |
712 | else: |
713 | @@ -87,46 +229,46 @@ |
714 | .format(ref_section)) |
715 | |
716 | if (self.sections and |
717 | - isinstance(new_section, (BlankSection, CommentSection)) and |
718 | - type(new_section) == type(self.sections[index])): |
719 | + isinstance(new_section, (BlankSection, CommentSection)) and |
720 | + type(new_section) == type(self.sections[index])): |
721 | self.sections[index] += new_section |
722 | else: |
723 | index += 1 |
724 | self._insert(index, new_section) |
725 | |
726 | - def prepend(self, new_section, ref_section=None): |
727 | - """ |
728 | - Prepend a new section |
729 | - """ |
730 | - assert isinstance(new_section, Section) |
731 | - if ref_section is None: |
732 | - index = 0 |
733 | - else: |
734 | - for index, section in enumerate(self.sections): |
735 | - if section is ref_section: |
736 | - break |
737 | - else: |
738 | - raise ValueError('Reference section not found: {}' |
739 | - .format(ref_section)) |
740 | - |
741 | - if (self.sections and |
742 | - isinstance(new_section, (BlankSection, CommentSection)) and |
743 | - type(new_section) == type(self.sections[index])): |
744 | - self.sections[index] = new_section + self.sections[index] |
745 | - else: |
746 | - self._insert(index, new_section) |
747 | - |
748 | def _insert(self, index, new_section): |
749 | - """ |
750 | - Insert section or join it with another one of the same type |
751 | - """ |
752 | + """Insert section or join it with another one of the same type.""" |
753 | + assert new_section.parent is None |
754 | + # Take ownership of the section |
755 | + new_section.parent = self |
756 | + |
757 | self.sections.insert(index, new_section) |
758 | |
759 | + # Update question name index |
760 | + if isinstance(new_section, ConfigurationSection): |
761 | + self.section_updated(new_section, |
762 | + 'qname', |
763 | + None, |
764 | + new_section.qname) |
765 | + |
766 | def section_updated(self, section, property_name, old_value, new_value): |
767 | - """ |
768 | - Callback called any time a section property is updated |
769 | - |
770 | - Used to maintain question names index integrity |
771 | + """Update question names index. |
772 | + |
773 | + This is a callback called every time a section property is updated and |
774 | + used to maintain question names index integrity |
775 | + |
776 | + :param section: Section object calling the callback |
777 | + :type section: :class:`Section` |
778 | + :param property_name: Name of the updated property |
779 | + :type property_name: `string` |
780 | + :param old_value: Old property value |
781 | + :type old_value: `string` | `None` |
782 | + :param new_value: New property value |
783 | + :type new_value: `string` |
784 | + :throws DuplicatedQuestionName: If the updated property is `qname` and |
785 | + the new value is already taken by other section which would break the |
786 | + access to a section by the question name. |
787 | + |
788 | """ |
789 | if property_name == 'qname': |
790 | new_text = new_value.text |
791 | @@ -143,41 +285,68 @@ |
792 | |
793 | |
794 | class Section(object): |
795 | - """ |
796 | - Any kind of line (blank, comment or configuration) |
797 | - """ |
798 | - def __init__(self, parent): |
799 | - self.parent = parent |
800 | + |
801 | + """Any kind of preseed section (blank, comment or configuration).""" |
802 | + |
803 | + def __init__(self): |
804 | + self.parent = None |
805 | |
806 | def __repr__(self): |
807 | return '<{}: {!r}>'.format(self.__class__.__name__, str(self)) |
808 | |
809 | + def __str__(self): |
810 | + return '<Section>' |
811 | + |
812 | @classmethod |
813 | - def new(cls, parent, lines): |
814 | - """ |
815 | - Create new section subclass based on the lines in the preseed |
816 | - """ |
817 | - if isinstance(lines, basestring): |
818 | - lines = [lines] |
819 | + def new(cls, lines): |
820 | + r"""Create new section subclass based on the lines in the preseed. |
821 | + |
822 | + This method is used by the :meth:`Preseed.load` method to create new |
823 | + sections while parsing a preseed file. |
824 | + |
825 | + :param lines: Lines to be parsed for this particular section |
826 | + :type lines: `list` |
827 | + :returns: Section object the properly represents the lines passed |
828 | + :rtype: subclass of :class:`Section` |
829 | + |
830 | + >>> from utah.preseed import Section |
831 | + >>> Section.new('\n'.splitlines()) |
832 | + <BlankSection: '\n'> |
833 | + >>> Section.new('# Comment\n'.splitlines()) |
834 | + <CommentSection: '# Comment\n'> |
835 | + >>> Section.new('d-i passwd/username string utah\n'.splitlines()) |
836 | + <ConfigurationSection: 'd-i passwd/username string utah\n'> |
837 | + |
838 | + """ |
839 | assert isinstance(lines, list) |
840 | |
841 | if all(not line for line in lines): |
842 | - return BlankSection(parent, len(lines)) |
843 | + return BlankSection(len(lines)) |
844 | |
845 | if all(line.startswith('#') for line in lines): |
846 | - return CommentSection(parent, lines) |
847 | + return CommentSection(lines) |
848 | |
849 | assert all(line and not line.startswith('#') |
850 | for line in lines) |
851 | - return ConfigurationSection(parent, lines) |
852 | + return ConfigurationSection(lines) |
853 | |
854 | |
855 | class BlankSection(Section): |
856 | - """ |
857 | - Any number of consecutive blank lines |
858 | - """ |
859 | - def __init__(self, parent, lines_count): |
860 | - super(BlankSection, self).__init__(parent) |
861 | + |
862 | + """A pressed section that represents a group of consecutive blank lines. |
863 | + |
864 | + :param lines_count: Number of blank lines represented by this section |
865 | + :type lines_count: `int` |
866 | + |
867 | + >>> from utah.preseed import BlankSection |
868 | + >>> section = BlankSection(3) |
869 | + >>> section.lines_count |
870 | + 3 |
871 | + |
872 | + """ |
873 | + |
874 | + def __init__(self, lines_count): |
875 | + super(BlankSection, self).__init__() |
876 | assert isinstance(lines_count, int) |
877 | self.lines_count = lines_count |
878 | |
879 | @@ -186,6 +355,7 @@ |
880 | |
881 | def __add__(self, other): |
882 | assert isinstance(other, BlankSection) |
883 | + assert self.parent == other.parent |
884 | return BlankSection(self.lines_count + other.lines_count) |
885 | |
886 | def __iadd__(self, other): |
887 | @@ -195,11 +365,22 @@ |
888 | |
889 | |
890 | class CommentSection(Section): |
891 | - """ |
892 | - Any number of consecutive comment lines |
893 | - """ |
894 | - def __init__(self, parent, lines): |
895 | - super(CommentSection, self).__init__(parent) |
896 | + |
897 | + r"""A preseed section that represents a group consecutive comment lines. |
898 | + |
899 | + :param lines: An iterable that yields one line at a time |
900 | + :type lines: `iterable` |
901 | + |
902 | + >>> from utah.preseed import CommentSection |
903 | + >>> comment_str = '# Comment\n' |
904 | + >>> section = CommentSection(comment_str.splitlines()) |
905 | + >>> section.lines |
906 | + ['# Comment'] |
907 | + |
908 | + """ |
909 | + |
910 | + def __init__(self, lines): |
911 | + super(CommentSection, self).__init__() |
912 | assert isinstance(lines, list) |
913 | assert all(line.startswith('#') for line in lines) |
914 | self.lines = lines |
915 | @@ -209,6 +390,7 @@ |
916 | |
917 | def __add__(self, other): |
918 | assert isinstance(other, CommentSection) |
919 | + assert self.parent == other.parent |
920 | return CommentSection(self.lines + other.lines) |
921 | |
922 | def __iadd__(self, other): |
923 | @@ -218,6 +400,9 @@ |
924 | |
925 | |
926 | class TextProperty(object): |
927 | + |
928 | + """A text property used in :class:`ConfigurationSection` objects.""" |
929 | + |
930 | def __init__(self, name): |
931 | self.name = name |
932 | self.obj_name = '_{}'.format(name) |
933 | @@ -242,6 +427,14 @@ |
934 | |
935 | |
936 | class TextPropertyValue(object): |
937 | + |
938 | + """A text value used in :class:`TextProperty` objects. |
939 | + |
940 | + The value being stored is just a text string, so there's currently no type |
941 | + even if the configuration sections in a preseed use types for values. |
942 | + |
943 | + """ |
944 | + |
945 | def __init__(self, parent, obj, text=''): |
946 | self.parent = parent |
947 | self.obj = obj |
948 | @@ -266,16 +459,46 @@ |
949 | raise ValueError |
950 | |
951 | def prepend(self, other_text): |
952 | - """ |
953 | - Prepend a string to the stored value |
954 | + r"""Prepend a string to the stored value. |
955 | + |
956 | + :param other_text: The text to be prepended |
957 | + :type other_text: `basestring` |
958 | + :returns: The updated value |
959 | + :rtype: :class:`TextPropertyValue` |
960 | + |
961 | + Note that the change happens in place, so there's no need to assign any |
962 | + result back to the :class:`TextProperty` object: |
963 | + |
964 | + >>> late_command_str = 'd-i preseed/late_command string some_command\n' |
965 | + >>> section = Section.new(late_command_str.splitlines()) |
966 | + >>> section.value |
967 | + <TextPropertyValue: 'some_command'> |
968 | + >>> section.value.prepend('another_command; ') |
969 | + <TextPropertyValue: 'another_command; some_command'> |
970 | + |
971 | """ |
972 | assert isinstance(other_text, basestring) |
973 | self.text = other_text + self.text |
974 | return self |
975 | |
976 | def append(self, other_text): |
977 | - """ |
978 | - Append a string to the stored value |
979 | + r"""Append a string to the stored value. |
980 | + |
981 | + :param other_text: The text to be appended |
982 | + :type other_text: `basestring` |
983 | + :returns: The updated value |
984 | + :rtype: :class:`TextPropertyValue` |
985 | + |
986 | + Note that the change happens in place, so there's no need to assign any |
987 | + result back to the :class:`TextProperty` object: |
988 | + |
989 | + >>> late_command_str = 'd-i preseed/late_command string some_command\n' |
990 | + >>> section = Section.new(late_command_str.splitlines()) |
991 | + >>> section.value |
992 | + <TextPropertyValue: 'some_command'> |
993 | + >>> section.value.append('; another_command') |
994 | + <TextPropertyValue: 'some_command; another_command'> |
995 | + |
996 | """ |
997 | assert isinstance(other_text, basestring) |
998 | self.text = self.text + other_text |
999 | @@ -283,9 +506,37 @@ |
1000 | |
1001 | |
1002 | class ConfigurationSection(Section): |
1003 | - """ |
1004 | - A configuration statement made of one or multiple lines |
1005 | - """ |
1006 | + |
1007 | + r"""A preseed configuration statement made of one or multiple lines. |
1008 | + |
1009 | + The expected format of a configuration section is as follows:: |
1010 | + |
1011 | + <owner> <qname> <qtype> <value> |
1012 | + |
1013 | + where the whole section might be made of multiple lines. A line is |
1014 | + considered not to finish the statement if there's a backslash character |
1015 | + just before the newline character. |
1016 | + |
1017 | + If the parsing succeeds, every field is accessible using the same name as |
1018 | + above. |
1019 | + |
1020 | + :parameter raw_lines: An iterable that yields one line at a time |
1021 | + :type raw_lines: `iterable` |
1022 | + |
1023 | + >>> from utah.preseed import ConfigurationSection |
1024 | + >>> configuration_str = 'd-i passwd/username string utah\n' |
1025 | + >>> section = ConfigurationSection(configuration_str.splitlines()) |
1026 | + >>> section.owner |
1027 | + <TextPropertyValue: 'd-i'> |
1028 | + >>> section.qname |
1029 | + <TextPropertyValue: 'passwd/username'> |
1030 | + >>> section.qtype |
1031 | + <TextPropertyValue: 'string'> |
1032 | + >>> section.value |
1033 | + <TextPropertyValue: 'utah'> |
1034 | + |
1035 | + """ |
1036 | + |
1037 | TRAILING_CHARS = string.whitespace + '\\' |
1038 | |
1039 | owner = TextProperty('owner') |
1040 | @@ -293,11 +544,8 @@ |
1041 | qtype = TextProperty('qtype') |
1042 | value = TextProperty('value') |
1043 | |
1044 | - def __init__(self, parent, raw_lines): |
1045 | - """ |
1046 | - Parse each element, so that it can be modified if needed |
1047 | - """ |
1048 | - super(ConfigurationSection, self).__init__(parent) |
1049 | + def __init__(self, raw_lines): |
1050 | + super(ConfigurationSection, self).__init__() |
1051 | lines = [raw_lines[0].rstrip(self.TRAILING_CHARS)] |
1052 | for raw_line in raw_lines[1:]: |
1053 | lines.append(raw_line |
1054 | @@ -305,18 +553,21 @@ |
1055 | .rstrip(self.TRAILING_CHARS)) |
1056 | text = ' '.join(lines) |
1057 | splitted_text = text.split(None, 3) |
1058 | - self.owner = splitted_text[0] |
1059 | - self.qname = splitted_text[1] |
1060 | - self.qtype = splitted_text[2] |
1061 | + try: |
1062 | + self.owner = splitted_text[0] |
1063 | + self.qname = splitted_text[1] |
1064 | + self.qtype = splitted_text[2] |
1065 | + except IndexError: |
1066 | + raise ValueError('Unable to parse configuration lines: {}' |
1067 | + .format(text)) |
1068 | + |
1069 | if len(splitted_text) == 4: |
1070 | self.value = splitted_text[3] |
1071 | else: |
1072 | self.value = '' |
1073 | |
1074 | def __str__(self): |
1075 | - """ |
1076 | - Return text representation in a single line |
1077 | - """ |
1078 | + """Return text representation in a single line.""" |
1079 | if self.value: |
1080 | line = ('{} {} {} {}\n' |
1081 | .format(self.owner, self.qname, self.qtype, |
1082 | @@ -327,30 +578,81 @@ |
1083 | return line |
1084 | |
1085 | def prepend(self, new_section): |
1086 | - """ |
1087 | - Prepend a new section to this one |
1088 | - """ |
1089 | + """Prepend a new section to this one. |
1090 | + |
1091 | + This is a wrapper method that actually calls the |
1092 | + :meth:`Preseed.prepend` method in the preseed using this section as a |
1093 | + reference section to set the insertion position. |
1094 | + |
1095 | + :param new_section: The new section to be prepended. If a string is |
1096 | + passed instead, a new section will be created from the string. |
1097 | + :type new_section: :class:`Section` | `basestring` |
1098 | + :returns: None |
1099 | + :rtype: None |
1100 | + |
1101 | + """ |
1102 | + assert self.parent is not None |
1103 | if isinstance(new_section, basestring): |
1104 | - new_section = Section.new(self.parent, new_section) |
1105 | + new_section = Section.new(new_section.splitlines()) |
1106 | |
1107 | assert isinstance(new_section, Section) |
1108 | self.parent.prepend(new_section, self) |
1109 | |
1110 | def append(self, new_section): |
1111 | - """ |
1112 | - Append a new section to this one |
1113 | - """ |
1114 | + """Append a new section to this one. |
1115 | + |
1116 | + This is a wrapper method that actually calls the :meth:`Preseed.append` |
1117 | + method in the preseed using this section as a reference section to set |
1118 | + the insertion position. |
1119 | + |
1120 | + :param new_section: The new section to be appended. If a string is |
1121 | + passed instead, a new section will be created from the string. |
1122 | + :type new_section: :class:`Section` | `basestring` |
1123 | + :returns: None |
1124 | + :rtype: None |
1125 | + |
1126 | + """ |
1127 | + assert self.parent is not None |
1128 | if isinstance(new_section, basestring): |
1129 | - new_section = Section.new(self.parent, new_section) |
1130 | + new_section = Section.new(new_section.splitlines()) |
1131 | |
1132 | assert isinstance(new_section, Section) |
1133 | self.parent.append(new_section, self) |
1134 | |
1135 | def property_updated(self, property_name, old_value, new_value): |
1136 | - self.parent.section_updated(self, property_name, old_value, new_value) |
1137 | + """Propagate property updates to preseed parent. |
1138 | + |
1139 | + If a parent preseed is set, for every updated received from a property |
1140 | + value, the same update is propagated to the parent preseed object. |
1141 | + |
1142 | + :param property_name: Name of the updated property |
1143 | + :type property_name: string |
1144 | + :param old_value: Old property value |
1145 | + :type old_value: `string` | `None` |
1146 | + :param new_value: New property value |
1147 | + :type new_value: `string` |
1148 | + |
1149 | + """ |
1150 | + if self.parent: |
1151 | + self.parent.section_updated(self, |
1152 | + property_name, |
1153 | + old_value, |
1154 | + new_value) |
1155 | |
1156 | |
1157 | class DuplicatedQuestionName(Exception): |
1158 | - """ |
1159 | - Exception raised when a question name is found more than once in a preseed |
1160 | + |
1161 | + r"""Duplicated question name found in preseed. |
1162 | + |
1163 | + This exception is raised when a question name is found more than once in a |
1164 | + preseed. This is part of the process used in the `Preseed` class to |
1165 | + guarantee that questions can be accessed based on their name. |
1166 | + |
1167 | + >>> preseed_str = ('d-i passwd/username string utah\n' |
1168 | + ... 'd-i passwd/username string ubuntu\n') |
1169 | + >>> preseed = Preseed(preseed_str.splitlines()) |
1170 | + Traceback (most recent call last): |
1171 | + ... |
1172 | + DuplicatedQuestionName: passwd/username |
1173 | + |
1174 | """ |
This looks okay to me. I'm happy to see more tests being added to the codebase.
I'll let Max comment on the code changes.