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
=== modified file 'docs/source/conf.py'
--- docs/source/conf.py 2012-10-18 15:12:59 +0000
+++ docs/source/conf.py 2012-11-07 09:32:22 +0000
@@ -143,7 +143,10 @@
143143
144# Add any Sphinx extension module names here, as strings. They can be144# Add any Sphinx extension module names here, as strings. They can be
145# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.145# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
146extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']146extensions = ['sphinx.ext.autodoc', # Include documentation from docstrings
147 'sphinx.ext.viewcode', # Add links to highlight source code
148 'sphinx.ext.doctest', # Test snippets in the documentation
149 ]
147150
148# Add any paths that contain templates here, relative to this directory.151# Add any paths that contain templates here, relative to this directory.
149templates_path = ['_templates']152templates_path = ['_templates']
150153
=== modified file 'docs/source/reference.rst'
--- docs/source/reference.rst 2012-09-03 09:27:10 +0000
+++ docs/source/reference.rst 2012-11-07 09:32:22 +0000
@@ -22,6 +22,13 @@
22.. automodule:: utah.iso22.. automodule:: utah.iso
23 :members:23 :members:
2424
25``utah.preseed``
26----------------
27
28.. automodule:: utah.preseed
29 :members:
30 :member-order: bysource
31
25.. automodule:: utah.process32.. automodule:: utah.process
26 :members:33 :members:
2734
2835
=== modified file 'tests/test_preseed.py'
--- tests/test_preseed.py 2012-10-08 13:27:52 +0000
+++ tests/test_preseed.py 2012-11-07 09:32:22 +0000
@@ -1,46 +1,412 @@
1from utah.preseed import (1"""
2 Preseed,2Test utah.preseed module
3 Section,3"""
4 BlankSection,4from utah.preseed import (Preseed,
5 CommentSection,5 Section,
6 ConfigurationSection,6 BlankSection,
7 DuplicatedQuestionName,7 CommentSection,
8 )8 ConfigurationSection,
9 DuplicatedQuestionName,
10 )
911
10import unittest12import unittest
13from mock import Mock
1114
1215
13class TestPreseedLoadDump(unittest.TestCase):16class TestPreseedLoadDump(unittest.TestCase):
14 """17
15 Minimal load/dump test cases18 """Test preseed load/dump."""
16 """19
17 BASIC_PRESEED = """# Comment20 PRESEED = """# Comment
1821
19d-i passwd/username string utah22d-i passwd/username string utah
20"""23"""
2124
22 DUPLICATED_QUESTION_NAME_PRESEED = """d-i passwd/username string utah25 DUPLICATED_LINE_PRESEED = """d-i passwd/username string utah
23d-i passwd/username string utah26d-i passwd/username string utah
24"""27"""
2528
26 def test_load(self):29 def test_load(self):
27 """Load basic preseed"""30 """Load basic preseed."""
28 preseed = Preseed(self.BASIC_PRESEED.splitlines())31 preseed = Preseed(self.PRESEED.splitlines())
2932
30 self.assertEqual(len(preseed.sections), 3)33 self.assertEqual(len(preseed.sections), 3)
31 section_types = (CommentSection, BlankSection, ConfigurationSection)34 section_types = (CommentSection, BlankSection, ConfigurationSection)
32 for index, section_type in enumerate(section_types):35 for index, section_type in enumerate(section_types):
33 self.assertIsInstance(preseed.sections[index], section_type)36 self.assertIsInstance(preseed.sections[index], section_type)
3437
38 def test_duplicated_question_name(self):
39 """Exception raised on duplicated question name."""
40 with self.assertRaises(DuplicatedQuestionName):
41 Preseed(self.DUPLICATED_LINE_PRESEED.splitlines())
42
35 def test_dump(self):43 def test_dump(self):
36 """Dump basic preseed"""44 """Dump basic preseed."""
37 preseed = Preseed()45 preseed = Preseed()
38 preseed.append(Section.new(preseed, '# Comment'))46 preseed.append(Section.new('# Comment\n'.splitlines()))
39 preseed.append(Section.new(preseed, ''))47 preseed.append(Section.new('\n'.splitlines()))
40 preseed.append(Section.new(preseed, 'd-i passwd/username string utah'))48 preseed.append(Section.new('d-i passwd/username string utah\n'
41 self.assertEqual(preseed.dump(), self.BASIC_PRESEED)49 .splitlines()))
4250 self.assertEqual(preseed.dump(), self.PRESEED)
43 def test_duplicated_question_name(self):51
44 """Exception raised on duplicated question name"""52
45 with self.assertRaises(DuplicatedQuestionName):53class TestPreseedIndexing(unittest.TestCase):
46 Preseed(self.DUPLICATED_QUESTION_NAME_PRESEED.splitlines())54
55 """Test preseed indexing."""
56
57 PRESEED = """# Comment
58
59d-i passwd/username string utah
60"""
61
62 def setUp(self):
63 """Set preseed object for test cases."""
64 self.preseed = Preseed(self.PRESEED.splitlines())
65
66 def test_get_by_question_name(self):
67 """Question can be retrieved by its name."""
68 qname = 'passwd/username'
69 question = self.preseed[qname]
70 self.assertEqual(question.qname, qname)
71
72 def test_key_error_unknown_question_name(self):
73 """Key error raised when trying to access an unknown question name."""
74 qname = 'unknown/unknwon'
75
76 with self.assertRaises(KeyError):
77 self.preseed[qname]
78
79 def test_type_error_question_is_not_string(self):
80 """Type error is raised when not using a string as key."""
81 with self.assertRaises(TypeError):
82 self.preseed[0]
83
84 def test_index_updated_on_question_name_update(self):
85 """Index is updated when question name is updated."""
86 qname = 'passwd/username'
87 new_qname = 'new/new'
88 question = self.preseed[qname]
89 question.qname = new_qname
90
91 # Question can no longer be accessed using old qname
92 with self.assertRaises(KeyError):
93 self.preseed[qname]
94
95 # Question can now be accessed using new qname
96 new_question = self.preseed[new_qname]
97 self.assertEqual(new_question.qname, new_qname)
98 self.assertIs(new_question, question)
99
100
101class TestPreseedMembership(unittest.TestCase):
102
103 """Test preseed membership."""
104
105 PRESEED = """# Comment
106
107d-i passwd/username string utah
108"""
109
110 def setUp(self):
111 """Set preseed object for test cases."""
112 self.preseed = Preseed(self.PRESEED.splitlines())
113
114 def test_contains(self):
115 """True is returned if question name is in preseed."""
116 qname = 'passwd/username'
117 self.assertIn(qname, self.preseed)
118
119 def test_not_contains(self):
120 """False is returned if question name is not in preseed."""
121 qname = 'unknown/unknown'
122 self.assertNotIn(qname, self.preseed)
123
124
125class TestPreseedAppendPrepend(unittest.TestCase):
126
127 """Test addition of new sections to a preseed."""
128
129 TIMES = 10 # Used as loop counter when adding multiple sections
130
131 def setUp(self):
132 """Set preseed object for test cases and a few sections."""
133 self.preseed = Preseed()
134
135 def test_prepend_section(self):
136 """Prepend section."""
137 for _ in range(self.TIMES):
138 self.preseed.prepend(Section())
139 self.assertEqual(len(self.preseed.sections), self.TIMES)
140 self.assertTrue(all([isinstance(section, Section)
141 for section in self.preseed.sections]))
142
143 def test_prepend_with_ref_section(self):
144 """Prepend before a given section."""
145 sections = [Section()
146 for section in range(self.TIMES)]
147 for section in sections:
148 self.preseed.prepend(section)
149
150 index = len(sections) / 2
151 ref_section = sections[index]
152 new_section = Section()
153 self.preseed.prepend(new_section, ref_section)
154 self.assertIs(self.preseed.sections[index - 1], new_section)
155
156 def test_prepend_string(self):
157 """Make a section from the given string and prepend it."""
158 line = '# Comment\n'
159 self.preseed.prepend(line)
160 self.assertEqual(str(self.preseed.sections[0]), line)
161
162 def test_prepend_with_unknown_ref_section(self):
163 """Exception raised if prepending to a section not in the preseed."""
164 ref_section = Section()
165 new_section = Section()
166 with self.assertRaises(ValueError):
167 self.preseed.prepend(new_section, ref_section)
168
169 def test_prepend_grouping_blank_section(self):
170 """Prepend groups blank sections."""
171 # Prepending many blank section results in a single blank section
172 # with all the empty lines joined together
173 for _ in range(self.TIMES):
174 self.preseed.prepend(BlankSection(1))
175 self.assertEqual(len(self.preseed.sections), 1)
176 self.assertIsInstance(self.preseed.sections[0], Section)
177
178 def test_prepend_grouping_comment_section(self):
179 """Prepend groups comment sections."""
180 # Prepending many comments section results in a single comment section
181 # with all the comment lines joined together
182 for _ in range(self.TIMES):
183 self.preseed.prepend(CommentSection('# Comment\n'.splitlines()))
184 self.assertEqual(len(self.preseed.sections), 1)
185 self.assertIsInstance(self.preseed.sections[0], Section)
186
187 def test_append_section(self):
188 """Append section."""
189 for _ in range(self.TIMES):
190 self.preseed.append(Section())
191 self.assertEqual(len(self.preseed.sections), self.TIMES)
192 self.assertTrue(all([isinstance(section, Section)
193 for section in self.preseed.sections]))
194
195 def test_append_with_ref_section(self):
196 """Append after a given section."""
197 sections = [Section()
198 for section in range(self.TIMES)]
199 for section in sections:
200 self.preseed.append(section)
201
202 index = len(sections) / 2
203 ref_section = sections[index]
204 new_section = Section()
205 self.preseed.append(new_section, ref_section)
206 self.assertIs(self.preseed.sections[index + 1], new_section)
207
208 def test_append_string(self):
209 """Make a section from the given string and append it."""
210 line = '# Comment\n'
211 self.preseed.append(line)
212 self.assertEqual(str(self.preseed.sections[0]), line)
213
214 def test_append_with_unknown_ref_section(self):
215 """Exception raised if appending to a section not in the preseed."""
216 ref_section = Section()
217 new_section = Section()
218 with self.assertRaises(ValueError):
219 self.preseed.append(new_section, ref_section)
220
221 def test_append_grouping_blank_section(self):
222 """Append groups blank sections."""
223 # Appending many blank section results in a single blank section
224 # with all the empty lines joined together
225 for _ in range(self.TIMES):
226 self.preseed.append(BlankSection(1))
227 self.assertEqual(len(self.preseed.sections), 1)
228 self.assertIsInstance(self.preseed.sections[0], Section)
229
230 def test_prepend_append_comment_section(self):
231 """Append groups comment sections."""
232 # Appending many comments section results in a single comment section
233 # with all the comment lines joined together
234 for _ in range(self.TIMES):
235 self.preseed.prepend(CommentSection('# Comment\n'.splitlines()))
236 self.assertEqual(len(self.preseed.sections), 1)
237 self.assertIsInstance(self.preseed.sections[0], Section)
238
239
240class TestSection(unittest.TestCase):
241
242 """Test section object instantiation."""
243
244 def test_new_blank_section(self):
245 """Convert empty lines to a blank section."""
246 lines_count = 5
247 lines = '\n' * lines_count
248 section = Section.new(lines.splitlines())
249 self.assertIsInstance(section, BlankSection)
250 self.assertEqual(str(section), lines)
251
252 def test_new_comment_section(self):
253 """Convert lines that start with a hash char to a comment section."""
254 lines = """# this is a comment
255# this is another comment
256"""
257 section = Section.new(lines.splitlines())
258 self.assertIsInstance(section, CommentSection)
259 self.assertEqual(str(section), lines)
260
261 def test_new_configuration_section_single_line(self):
262 """Question line => configuration section."""
263 lines = """d-i passwd/username string utah
264"""
265 section = Section.new(lines.splitlines())
266 self.assertIsInstance(section, ConfigurationSection)
267 self.assertEqual(str(section), lines)
268
269 def test_new_configuration_section_multiple_lines(self):
270 """Question lines => configuration section."""
271 lines = """d-i preseed/late_command string first_command;\\
272second_command;\\
273third_command;
274"""
275 section = Section.new(lines.splitlines())
276 self.assertIsInstance(section, ConfigurationSection)
277
278 # Note that ConfigurationSection object doesn't dump the exat input
279 # but a one line version of it
280 self.assertEqual(str(section), lines.replace('\\\n', ' '))
281
282
283class TestBlankSection(unittest.TestCase):
284
285 """Test blank section."""
286
287 def test_str(self):
288 """Blank section string representation."""
289 lines_count = 10
290 lines = '\n' * lines_count
291 section = BlankSection(lines_count)
292 self.assertEqual(section.lines_count, lines_count)
293 self.assertEqual(str(section), lines)
294
295 def test_add(self):
296 """Add two blank sections."""
297 section1 = BlankSection(3)
298 section2 = BlankSection(5)
299 section = section1 + section2
300 self.assertEqual(section.lines_count,
301 section1.lines_count + section2.lines_count)
302
303 def test_iadd(self):
304 """Add two blank sections with side effect."""
305 lines1 = 3
306 lines2 = 5
307 section1 = BlankSection(lines1)
308 section2 = BlankSection(lines2)
309 section1 += section2
310 self.assertEqual(section1.lines_count,
311 lines1 + lines2)
312
313
314class TestCommentSection(unittest.TestCase):
315
316 """Test comment section."""
317
318 def test_str(self):
319 """Comment section string representation."""
320 comment = '# Comment\n'
321 section = CommentSection(comment.splitlines())
322 self.assertEqual(str(section), comment)
323
324 def test_add(self):
325 """Add two comment sections."""
326 comment1 = '# Comment 1\n'
327 comment2 = '# Comment 1\n'
328 section1 = CommentSection(comment1.splitlines())
329 section2 = CommentSection(comment2.splitlines())
330 section = section1 + section2
331 self.assertEqual(str(section), comment1 + comment2)
332
333 def test_iadd(self):
334 """Add two comment sections with side effect."""
335 comment1 = '# Comment 1\n'
336 comment2 = '# Comment 1\n'
337 section1 = CommentSection(comment1.splitlines())
338 section2 = CommentSection(comment2.splitlines())
339 section1 += section2
340 self.assertEqual(str(section1), comment1 + comment2)
341
342
343class TestConfigurationSection(unittest.TestCase):
344
345 """Test configuration section."""
346
347 def setUp(self):
348 """Set section and preseed for test cases."""
349 self.line = 'd-i passwd/username string utah\n'
350 self.preseed = Preseed()
351 self.section = ConfigurationSection(self.line.splitlines())
352 self.preseed.append(self.section)
353
354 def test_str(self):
355 """Configuration section string representation."""
356 self.assertEqual(str(self.section), self.line)
357
358 def test_malformed_input(self):
359 """Raise exception when line cannot be parsed correctly."""
360 with self.assertRaises(ValueError):
361 ConfigurationSection('not valid\n'.splitlines())
362
363 def test_prepend(self):
364 """Prepend new section to this one in the preseed."""
365 self.preseed.prepend = Mock()
366 new_section = Section()
367 self.section.prepend(new_section)
368 self.preseed.prepend.assert_called_once_with(new_section, self.section)
369
370 def test_prepend_string(self):
371 """Make a section from the given string and prepend it."""
372 self.preseed.prepend = Mock()
373 line = '# Comment\n'
374 self.section.prepend(line)
375 args, _kwargs = self.preseed.prepend.call_args
376 self.assertEqual(str(args[0]), line)
377 self.assertEqual(args[1], self.section)
378
379 def test_append(self):
380 """Append new section to this one in the preseed."""
381 self.preseed.append = Mock()
382 new_section = Section()
383 self.section.append(new_section)
384 self.preseed.append.assert_called_once_with(new_section, self.section)
385
386 def test_append_string(self):
387 """Make a section from the given string and append it."""
388 self.preseed.append = Mock()
389 line = '# Comment\n'
390 self.section.append(line)
391 args, _kwargs = self.preseed.append.call_args
392 self.assertEqual(str(args[0]), line)
393 self.assertEqual(args[1], self.section)
394
395 def test_properties(self):
396 """Configuration section splits line into properties."""
397 self.assertEqual(self.section.owner, 'd-i')
398 self.assertEqual(self.section.qname, 'passwd/username')
399 self.assertEqual(self.section.qtype, 'string')
400 self.assertEqual(self.section.value, 'utah')
401
402 def test_section_updated(self):
403 """Call section_updated in preseed when a propery is updated."""
404 method_mock = Mock()
405 self.preseed.section_updated = method_mock
406 # qname is tested because is the property that the preseed
407 # uses to provide index based access
408 self.section.qname = 'new_qname'
409 method_mock.assert_called_with(self.section,
410 'qname',
411 'passwd/username',
412 'new_qname')
47413
=== modified file 'utah/preseed.py'
--- utah/preseed.py 2012-10-11 13:41:49 +0000
+++ utah/preseed.py 2012-11-07 09:32:22 +0000
@@ -1,13 +1,66 @@
1"""1r"""This module provides all the classes needed to:
2Pressed files handling2 - Parse a preseed file
3 - Update some values
4 - Add new sections
5 - Write the changes back to a file
6
7The expected way to use it is by passing a file-like object or an iterable that
8yields one line of the preseed at a time:
9
10>>> from utah.preseed import Preseed
11>>> from StringIO import StringIO
12>>> preseed_text = StringIO(
13... '# Comment\n'
14... '\n'
15... 'd-i passwd/username string utah\n')
16>>> preseed = Preseed(preseed_text)
17
18After that, any of the configuration sections can be accessed by the question
19name:
20
21>>> section = preseed['passwd/username']
22>>> section
23<ConfigurationSection: 'd-i passwd/username string utah\n'>
24
25and values can be updated by setting them directly in the section objects:
26
27>>> section.value = 'ubuntu'
28>>> section
29<ConfigurationSection: 'd-i passwd/username string ubuntu\n'>
30
31In addition to this, if a new section is needed, it can be appended/prepended
32to the preseed by calling directly the :class:`Preseed` methods or the
33:class:`ConfigurationSection` methods to use the section as a reference, that
34is, append/prepend after/before the given section.
35
36>>> section.append('d-i passwd/user-password password\n')
37>>> section.append('d-i passwd/user-password-again password\n')
38
39Once the desired changes have been applied, the :meth:`Preseed.dump` method can
40be used to write the output to a new file:
41
42>>> print preseed.dump()
43# Comment
44<BLANKLINE>
45d-i passwd/username string ubuntu
46d-i passwd/user-password-again password
47d-i passwd/user-password password
48<BLANKLINE>
49
3"""50"""
4import string51import string
552
653
7class Preseed(object):54class Preseed(object):
8 """55
9 Read/Write preseed files easily56 """Read/Write preseed files easily.
10 """57
58 :param lines: File-like object or iterable that yields one line from the
59 preseed at a time.
60 :type lines: iterable
61
62 """
63
11 def __init__(self, lines=None):64 def __init__(self, lines=None):
12 # Used to access quickly to configuration sections by question name65 # Used to access quickly to configuration sections by question name
13 self._qnames = {}66 self._qnames = {}
@@ -17,8 +70,13 @@
17 self.sections = []70 self.sections = []
1871
19 def __getitem__(self, key):72 def __getitem__(self, key):
20 """73 """Access lines directly by their question name.
21 Access lines directly by their question name74
75 :param key: Question name
76 :type: `basestring` | :class:`TextPropertyValue`
77 :returns: Section in the preseed that matches the passed question name
78 :rtype: :class:`Section`
79
22 """80 """
23 if isinstance(key, TextPropertyValue):81 if isinstance(key, TextPropertyValue):
24 key = key.text82 key = key.text
@@ -28,8 +86,14 @@
28 return self._qnames[key]86 return self._qnames[key]
2987
30 def __contains__(self, key):88 def __contains__(self, key):
31 """89 """Use in operator with question names.
32 Use in operator with question names90
91 :param key: Question name
92 :type: `basestring` | `TextPropertyValue`
93 :returns: Whether a section that matches the question name is in the
94 preseed or not
95 :rtype: `bool`
96
33 """97 """
34 if isinstance(key, TextPropertyValue):98 if isinstance(key, TextPropertyValue):
35 key = key.text99 key = key.text
@@ -39,11 +103,17 @@
39 return key in self._qnames103 return key in self._qnames
40104
41 def load(self, lines):105 def load(self, lines):
42 """106 """Parse preseed configuration lines.
43 Parse preseed configuration lines107
108 This method is automatically called at initialization time if the
109 `lines` parameter is passed to the constructor, so it's not really
110 expected to be used directly.
44111
45 :param lines: Any iterable that yields preseed file configuration lines112 :param lines: Any iterable that yields preseed file configuration lines
46 :type lines: iterable113 :type lines: iterable
114 :return: Preseed file object with information parsed
115 :rtype: :class:`Preseed`
116
47 """117 """
48 self.sections = []118 self.sections = []
49 # One line might be made of multiple lines119 # One line might be made of multiple lines
@@ -55,27 +125,99 @@
55125
56 # Line is finished only when no continuation character is found126 # Line is finished only when no continuation character is found
57 if not input_line.endswith('\\'):127 if not input_line.endswith('\\'):
58 new_section = Section.new(self, output_lines)128 new_section = Section.new(output_lines)
59 self.append(new_section)129 self.append(new_section)
60 output_lines = []130 output_lines = []
61131
62 return self132 return self
63133
64 def dump(self):134 def dump(self):
65 """135 r"""Dump preseed configuration statements.
66 Dump preseed configuration statements136
67 Write the modified file to the same or a new location137 This method returns the contents of the preseed after the changes
138 applied. The string returned is normally used to write the changes back
139 to a file that can be used as the new preseed to provision a system.
68140
69 :returns: Formatted preseed configuration lines141 :returns: Formatted preseed configuration lines
70 :rtype: string142 :rtype: `string`
143
144 >>> preseed = Preseed('# Comment\n'.splitlines())
145 >>> preseed.dump()
146 '# Comment\n'
147
71 """148 """
72 return ''.join(str(section) for section in self.sections)149 return ''.join(str(section) for section in self.sections)
73150
151 def prepend(self, new_section, ref_section=None):
152 r"""Prepend a new section to the preseed.
153
154 :param new_section: The new section to be prepended. If a string is
155 passed instead, a new section will be created from the string.
156 :type new_section: :class:`Section` | `basestring`
157 :param ref_section: A section to be used as a reference, meaning that
158 the new section will be prepended after the reference section. If no
159 reference section is passed, then the new section will be prepended
160 just to the beginning of the preseed.
161 :type ref_section: :class:`Section`
162
163 >>> preseed = Preseed('d-i passwd/username string utah\n'.splitlines())
164 >>> preseed.prepend('# Comment')
165 >>> print preseed.dump()
166 # Comment
167 d-i passwd/username string utah
168 <BLANKLINE>
169
170 """
171 if isinstance(new_section, basestring):
172 new_section = Section.new(new_section.splitlines())
173 assert isinstance(new_section, Section)
174 assert new_section.parent is None
175 if ref_section is None:
176 index = 0
177 else:
178 for index, section in enumerate(self.sections):
179 if section is ref_section:
180 break
181 else:
182 raise ValueError('Reference section not found: {}'
183 .format(ref_section))
184
185 if (self.sections and
186 isinstance(new_section, (BlankSection, CommentSection)) and
187 type(new_section) == type(self.sections[index])):
188 # Old section to be replaced, won't have a parent anymore
189 self.sections[index].parent = None
190 grouped_section = new_section + self.sections[index]
191 self.sections[index] = grouped_section
192 # New section is now included in the preseed
193 grouped_section.parent = self
194 else:
195 self._insert(index, new_section)
196
74 def append(self, new_section, ref_section=None):197 def append(self, new_section, ref_section=None):
75 """198 r"""Append a new section to the preseed.
76 Append a new section199
77 """200 :param new_section: The new section to be appended. If a string is
201 passed instead, a new section will be created from the string.
202 :type new_section: :class:`Section` | `basestring`
203 :param ref_section: A section to be used as a reference, meaning that
204 the new section will be appended after the reference section. If no
205 reference section is passed, then the new section will be appended
206 just to the end of the preseed.
207 :type ref_section: :class:`Section`
208
209 >>> preseed = Preseed('# Comment\n'.splitlines())
210 >>> preseed.append('d-i passwd/username string utah\n')
211 >>> print preseed.dump()
212 # Comment
213 d-i passwd/username string utah
214 <BLANKLINE>
215
216 """
217 if isinstance(new_section, basestring):
218 new_section = Section.new(new_section.splitlines())
78 assert isinstance(new_section, Section)219 assert isinstance(new_section, Section)
220 assert new_section.parent is None
79 if ref_section is None:221 if ref_section is None:
80 index = len(self.sections) - 1222 index = len(self.sections) - 1
81 else:223 else:
@@ -87,46 +229,46 @@
87 .format(ref_section))229 .format(ref_section))
88230
89 if (self.sections and231 if (self.sections and
90 isinstance(new_section, (BlankSection, CommentSection)) and232 isinstance(new_section, (BlankSection, CommentSection)) and
91 type(new_section) == type(self.sections[index])):233 type(new_section) == type(self.sections[index])):
92 self.sections[index] += new_section234 self.sections[index] += new_section
93 else:235 else:
94 index += 1236 index += 1
95 self._insert(index, new_section)237 self._insert(index, new_section)
96238
97 def prepend(self, new_section, ref_section=None):
98 """
99 Prepend a new section
100 """
101 assert isinstance(new_section, Section)
102 if ref_section is None:
103 index = 0
104 else:
105 for index, section in enumerate(self.sections):
106 if section is ref_section:
107 break
108 else:
109 raise ValueError('Reference section not found: {}'
110 .format(ref_section))
111
112 if (self.sections and
113 isinstance(new_section, (BlankSection, CommentSection)) and
114 type(new_section) == type(self.sections[index])):
115 self.sections[index] = new_section + self.sections[index]
116 else:
117 self._insert(index, new_section)
118
119 def _insert(self, index, new_section):239 def _insert(self, index, new_section):
120 """240 """Insert section or join it with another one of the same type."""
121 Insert section or join it with another one of the same type241 assert new_section.parent is None
122 """242 # Take ownership of the section
243 new_section.parent = self
244
123 self.sections.insert(index, new_section)245 self.sections.insert(index, new_section)
124246
247 # Update question name index
248 if isinstance(new_section, ConfigurationSection):
249 self.section_updated(new_section,
250 'qname',
251 None,
252 new_section.qname)
253
125 def section_updated(self, section, property_name, old_value, new_value):254 def section_updated(self, section, property_name, old_value, new_value):
126 """255 """Update question names index.
127 Callback called any time a section property is updated256
128257 This is a callback called every time a section property is updated and
129 Used to maintain question names index integrity258 used to maintain question names index integrity
259
260 :param section: Section object calling the callback
261 :type section: :class:`Section`
262 :param property_name: Name of the updated property
263 :type property_name: `string`
264 :param old_value: Old property value
265 :type old_value: `string` | `None`
266 :param new_value: New property value
267 :type new_value: `string`
268 :throws DuplicatedQuestionName: If the updated property is `qname` and
269 the new value is already taken by other section which would break the
270 access to a section by the question name.
271
130 """272 """
131 if property_name == 'qname':273 if property_name == 'qname':
132 new_text = new_value.text274 new_text = new_value.text
@@ -143,41 +285,68 @@
143285
144286
145class Section(object):287class Section(object):
146 """288
147 Any kind of line (blank, comment or configuration)289 """Any kind of preseed section (blank, comment or configuration)."""
148 """290
149 def __init__(self, parent):291 def __init__(self):
150 self.parent = parent292 self.parent = None
151293
152 def __repr__(self):294 def __repr__(self):
153 return '<{}: {!r}>'.format(self.__class__.__name__, str(self))295 return '<{}: {!r}>'.format(self.__class__.__name__, str(self))
154296
297 def __str__(self):
298 return '<Section>'
299
155 @classmethod300 @classmethod
156 def new(cls, parent, lines):301 def new(cls, lines):
157 """302 r"""Create new section subclass based on the lines in the preseed.
158 Create new section subclass based on the lines in the preseed303
159 """304 This method is used by the :meth:`Preseed.load` method to create new
160 if isinstance(lines, basestring):305 sections while parsing a preseed file.
161 lines = [lines]306
307 :param lines: Lines to be parsed for this particular section
308 :type lines: `list`
309 :returns: Section object the properly represents the lines passed
310 :rtype: subclass of :class:`Section`
311
312 >>> from utah.preseed import Section
313 >>> Section.new('\n'.splitlines())
314 <BlankSection: '\n'>
315 >>> Section.new('# Comment\n'.splitlines())
316 <CommentSection: '# Comment\n'>
317 >>> Section.new('d-i passwd/username string utah\n'.splitlines())
318 <ConfigurationSection: 'd-i passwd/username string utah\n'>
319
320 """
162 assert isinstance(lines, list)321 assert isinstance(lines, list)
163322
164 if all(not line for line in lines):323 if all(not line for line in lines):
165 return BlankSection(parent, len(lines))324 return BlankSection(len(lines))
166325
167 if all(line.startswith('#') for line in lines):326 if all(line.startswith('#') for line in lines):
168 return CommentSection(parent, lines)327 return CommentSection(lines)
169328
170 assert all(line and not line.startswith('#')329 assert all(line and not line.startswith('#')
171 for line in lines)330 for line in lines)
172 return ConfigurationSection(parent, lines)331 return ConfigurationSection(lines)
173332
174333
175class BlankSection(Section):334class BlankSection(Section):
176 """335
177 Any number of consecutive blank lines336 """A pressed section that represents a group of consecutive blank lines.
178 """337
179 def __init__(self, parent, lines_count):338 :param lines_count: Number of blank lines represented by this section
180 super(BlankSection, self).__init__(parent)339 :type lines_count: `int`
340
341 >>> from utah.preseed import BlankSection
342 >>> section = BlankSection(3)
343 >>> section.lines_count
344 3
345
346 """
347
348 def __init__(self, lines_count):
349 super(BlankSection, self).__init__()
181 assert isinstance(lines_count, int)350 assert isinstance(lines_count, int)
182 self.lines_count = lines_count351 self.lines_count = lines_count
183352
@@ -186,6 +355,7 @@
186355
187 def __add__(self, other):356 def __add__(self, other):
188 assert isinstance(other, BlankSection)357 assert isinstance(other, BlankSection)
358 assert self.parent == other.parent
189 return BlankSection(self.lines_count + other.lines_count)359 return BlankSection(self.lines_count + other.lines_count)
190360
191 def __iadd__(self, other):361 def __iadd__(self, other):
@@ -195,11 +365,22 @@
195365
196366
197class CommentSection(Section):367class CommentSection(Section):
198 """368
199 Any number of consecutive comment lines369 r"""A preseed section that represents a group consecutive comment lines.
200 """370
201 def __init__(self, parent, lines):371 :param lines: An iterable that yields one line at a time
202 super(CommentSection, self).__init__(parent)372 :type lines: `iterable`
373
374 >>> from utah.preseed import CommentSection
375 >>> comment_str = '# Comment\n'
376 >>> section = CommentSection(comment_str.splitlines())
377 >>> section.lines
378 ['# Comment']
379
380 """
381
382 def __init__(self, lines):
383 super(CommentSection, self).__init__()
203 assert isinstance(lines, list)384 assert isinstance(lines, list)
204 assert all(line.startswith('#') for line in lines)385 assert all(line.startswith('#') for line in lines)
205 self.lines = lines386 self.lines = lines
@@ -209,6 +390,7 @@
209390
210 def __add__(self, other):391 def __add__(self, other):
211 assert isinstance(other, CommentSection)392 assert isinstance(other, CommentSection)
393 assert self.parent == other.parent
212 return CommentSection(self.lines + other.lines)394 return CommentSection(self.lines + other.lines)
213395
214 def __iadd__(self, other):396 def __iadd__(self, other):
@@ -218,6 +400,9 @@
218400
219401
220class TextProperty(object):402class TextProperty(object):
403
404 """A text property used in :class:`ConfigurationSection` objects."""
405
221 def __init__(self, name):406 def __init__(self, name):
222 self.name = name407 self.name = name
223 self.obj_name = '_{}'.format(name)408 self.obj_name = '_{}'.format(name)
@@ -242,6 +427,14 @@
242427
243428
244class TextPropertyValue(object):429class TextPropertyValue(object):
430
431 """A text value used in :class:`TextProperty` objects.
432
433 The value being stored is just a text string, so there's currently no type
434 even if the configuration sections in a preseed use types for values.
435
436 """
437
245 def __init__(self, parent, obj, text=''):438 def __init__(self, parent, obj, text=''):
246 self.parent = parent439 self.parent = parent
247 self.obj = obj440 self.obj = obj
@@ -266,16 +459,46 @@
266 raise ValueError459 raise ValueError
267460
268 def prepend(self, other_text):461 def prepend(self, other_text):
269 """462 r"""Prepend a string to the stored value.
270 Prepend a string to the stored value463
464 :param other_text: The text to be prepended
465 :type other_text: `basestring`
466 :returns: The updated value
467 :rtype: :class:`TextPropertyValue`
468
469 Note that the change happens in place, so there's no need to assign any
470 result back to the :class:`TextProperty` object:
471
472 >>> late_command_str = 'd-i preseed/late_command string some_command\n'
473 >>> section = Section.new(late_command_str.splitlines())
474 >>> section.value
475 <TextPropertyValue: 'some_command'>
476 >>> section.value.prepend('another_command; ')
477 <TextPropertyValue: 'another_command; some_command'>
478
271 """479 """
272 assert isinstance(other_text, basestring)480 assert isinstance(other_text, basestring)
273 self.text = other_text + self.text481 self.text = other_text + self.text
274 return self482 return self
275483
276 def append(self, other_text):484 def append(self, other_text):
277 """485 r"""Append a string to the stored value.
278 Append a string to the stored value486
487 :param other_text: The text to be appended
488 :type other_text: `basestring`
489 :returns: The updated value
490 :rtype: :class:`TextPropertyValue`
491
492 Note that the change happens in place, so there's no need to assign any
493 result back to the :class:`TextProperty` object:
494
495 >>> late_command_str = 'd-i preseed/late_command string some_command\n'
496 >>> section = Section.new(late_command_str.splitlines())
497 >>> section.value
498 <TextPropertyValue: 'some_command'>
499 >>> section.value.append('; another_command')
500 <TextPropertyValue: 'some_command; another_command'>
501
279 """502 """
280 assert isinstance(other_text, basestring)503 assert isinstance(other_text, basestring)
281 self.text = self.text + other_text504 self.text = self.text + other_text
@@ -283,9 +506,37 @@
283506
284507
285class ConfigurationSection(Section):508class ConfigurationSection(Section):
286 """509
287 A configuration statement made of one or multiple lines510 r"""A preseed configuration statement made of one or multiple lines.
288 """511
512 The expected format of a configuration section is as follows::
513
514 <owner> <qname> <qtype> <value>
515
516 where the whole section might be made of multiple lines. A line is
517 considered not to finish the statement if there's a backslash character
518 just before the newline character.
519
520 If the parsing succeeds, every field is accessible using the same name as
521 above.
522
523 :parameter raw_lines: An iterable that yields one line at a time
524 :type raw_lines: `iterable`
525
526 >>> from utah.preseed import ConfigurationSection
527 >>> configuration_str = 'd-i passwd/username string utah\n'
528 >>> section = ConfigurationSection(configuration_str.splitlines())
529 >>> section.owner
530 <TextPropertyValue: 'd-i'>
531 >>> section.qname
532 <TextPropertyValue: 'passwd/username'>
533 >>> section.qtype
534 <TextPropertyValue: 'string'>
535 >>> section.value
536 <TextPropertyValue: 'utah'>
537
538 """
539
289 TRAILING_CHARS = string.whitespace + '\\'540 TRAILING_CHARS = string.whitespace + '\\'
290541
291 owner = TextProperty('owner')542 owner = TextProperty('owner')
@@ -293,11 +544,8 @@
293 qtype = TextProperty('qtype')544 qtype = TextProperty('qtype')
294 value = TextProperty('value')545 value = TextProperty('value')
295546
296 def __init__(self, parent, raw_lines):547 def __init__(self, raw_lines):
297 """548 super(ConfigurationSection, self).__init__()
298 Parse each element, so that it can be modified if needed
299 """
300 super(ConfigurationSection, self).__init__(parent)
301 lines = [raw_lines[0].rstrip(self.TRAILING_CHARS)]549 lines = [raw_lines[0].rstrip(self.TRAILING_CHARS)]
302 for raw_line in raw_lines[1:]:550 for raw_line in raw_lines[1:]:
303 lines.append(raw_line551 lines.append(raw_line
@@ -305,18 +553,21 @@
305 .rstrip(self.TRAILING_CHARS))553 .rstrip(self.TRAILING_CHARS))
306 text = ' '.join(lines)554 text = ' '.join(lines)
307 splitted_text = text.split(None, 3)555 splitted_text = text.split(None, 3)
308 self.owner = splitted_text[0]556 try:
309 self.qname = splitted_text[1]557 self.owner = splitted_text[0]
310 self.qtype = splitted_text[2]558 self.qname = splitted_text[1]
559 self.qtype = splitted_text[2]
560 except IndexError:
561 raise ValueError('Unable to parse configuration lines: {}'
562 .format(text))
563
311 if len(splitted_text) == 4:564 if len(splitted_text) == 4:
312 self.value = splitted_text[3]565 self.value = splitted_text[3]
313 else:566 else:
314 self.value = ''567 self.value = ''
315568
316 def __str__(self):569 def __str__(self):
317 """570 """Return text representation in a single line."""
318 Return text representation in a single line
319 """
320 if self.value:571 if self.value:
321 line = ('{} {} {} {}\n'572 line = ('{} {} {} {}\n'
322 .format(self.owner, self.qname, self.qtype,573 .format(self.owner, self.qname, self.qtype,
@@ -327,30 +578,81 @@
327 return line578 return line
328579
329 def prepend(self, new_section):580 def prepend(self, new_section):
330 """581 """Prepend a new section to this one.
331 Prepend a new section to this one582
332 """583 This is a wrapper method that actually calls the
584 :meth:`Preseed.prepend` method in the preseed using this section as a
585 reference section to set the insertion position.
586
587 :param new_section: The new section to be prepended. If a string is
588 passed instead, a new section will be created from the string.
589 :type new_section: :class:`Section` | `basestring`
590 :returns: None
591 :rtype: None
592
593 """
594 assert self.parent is not None
333 if isinstance(new_section, basestring):595 if isinstance(new_section, basestring):
334 new_section = Section.new(self.parent, new_section)596 new_section = Section.new(new_section.splitlines())
335597
336 assert isinstance(new_section, Section)598 assert isinstance(new_section, Section)
337 self.parent.prepend(new_section, self)599 self.parent.prepend(new_section, self)
338600
339 def append(self, new_section):601 def append(self, new_section):
340 """602 """Append a new section to this one.
341 Append a new section to this one603
342 """604 This is a wrapper method that actually calls the :meth:`Preseed.append`
605 method in the preseed using this section as a reference section to set
606 the insertion position.
607
608 :param new_section: The new section to be appended. If a string is
609 passed instead, a new section will be created from the string.
610 :type new_section: :class:`Section` | `basestring`
611 :returns: None
612 :rtype: None
613
614 """
615 assert self.parent is not None
343 if isinstance(new_section, basestring):616 if isinstance(new_section, basestring):
344 new_section = Section.new(self.parent, new_section)617 new_section = Section.new(new_section.splitlines())
345618
346 assert isinstance(new_section, Section)619 assert isinstance(new_section, Section)
347 self.parent.append(new_section, self)620 self.parent.append(new_section, self)
348621
349 def property_updated(self, property_name, old_value, new_value):622 def property_updated(self, property_name, old_value, new_value):
350 self.parent.section_updated(self, property_name, old_value, new_value)623 """Propagate property updates to preseed parent.
624
625 If a parent preseed is set, for every updated received from a property
626 value, the same update is propagated to the parent preseed object.
627
628 :param property_name: Name of the updated property
629 :type property_name: string
630 :param old_value: Old property value
631 :type old_value: `string` | `None`
632 :param new_value: New property value
633 :type new_value: `string`
634
635 """
636 if self.parent:
637 self.parent.section_updated(self,
638 property_name,
639 old_value,
640 new_value)
351641
352642
353class DuplicatedQuestionName(Exception):643class DuplicatedQuestionName(Exception):
354 """644
355 Exception raised when a question name is found more than once in a preseed645 r"""Duplicated question name found in preseed.
646
647 This exception is raised when a question name is found more than once in a
648 preseed. This is part of the process used in the `Preseed` class to
649 guarantee that questions can be accessed based on their name.
650
651 >>> preseed_str = ('d-i passwd/username string utah\n'
652 ... 'd-i passwd/username string ubuntu\n')
653 >>> preseed = Preseed(preseed_str.splitlines())
654 Traceback (most recent call last):
655 ...
656 DuplicatedQuestionName: passwd/username
657
356 """658 """

Subscribers

People subscribed via source and target branches