Merge lp:~javier.collado/utah/preseed_tests into lp:utah

Proposed by Javier Collado
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
Reviewer Review Type Date Requested Status
Max Brustkern (community) Approve
Joe Talbott (community) Approve
Review via email: mp+130851@code.launchpad.net

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

To post a comment you must log in.
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

Revision history for this message
Joe Talbott (joetalbott) wrote :

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.

review: Approve
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.

Revision history for this message
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

Revision history for this message
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.

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

Revision history for this message
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

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

Subscribers

People subscribed via source and target branches