# Ubuntu Testing Automation Harness
# Copyright 2012 Canonical Ltd.
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
# by the Free Software Foundation.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranties of
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
r"""This module provides all the classes needed to:
- Parse a preseed file
- Update some values
- Add new sections
- Write the changes back to a file
The expected way to use it is by passing a file-like object or an iterable that
yields one line of the preseed at a time:
>>> from utah.preseed import Preseed
>>> from StringIO import StringIO
>>> preseed_text = StringIO(
... '# Comment\n'
... '\n'
... 'd-i passwd/username string utah\n')
>>> preseed = Preseed(preseed_text)
After that, any of the configuration sections can be accessed by the question
name:
>>> section = preseed['passwd/username']
>>> section
<ConfigurationSection: 'd-i passwd/username string utah\n'>
and values can be updated by setting them directly in the section objects:
>>> section.value = 'ubuntu'
>>> section
<ConfigurationSection: 'd-i passwd/username string ubuntu\n'>
In addition to this, if a new section is needed, it can be appended/prepended
to the preseed by calling directly the :class:`Preseed` methods or the
:class:`ConfigurationSection` methods to use the section as a reference, that
is, append/prepend after/before the given section.
>>> section.append('d-i passwd/user-password password\n')
>>> section.append('d-i passwd/user-password-again password\n')
Once the desired changes have been applied, the :meth:`Preseed.dump` method can
be used to write the output to a new file:
>>> print preseed.dump()
# Comment
<BLANKLINE>
d-i passwd/username string ubuntu
d-i passwd/user-password-again password
d-i passwd/user-password password
<BLANKLINE>
"""
import string
[docs]class Preseed(object):
"""Read/Write preseed files easily.
:param lines:
File-like object or iterable that yields one line from the preseed at a
time.
:type lines: iterable
"""
def __init__(self, lines=None):
# Used to access quickly to configuration sections by question name
self._qnames = {}
if lines is not None:
self.load(lines)
else:
self.sections = []
def __getitem__(self, key):
"""Access lines directly by their question name.
:param key: Question name
:type: `basestring` | :class:`TextPropertyValue`
:returns: Section in the preseed that matches the passed question name
:rtype: :class:`Section`
"""
if isinstance(key, TextPropertyValue):
key = key.text
if not isinstance(key, basestring):
raise TypeError
return self._qnames[key]
def __contains__(self, key):
"""Use in operator with question names.
:param key: Question name
:type: `basestring` | `TextPropertyValue`
:returns:
Whether a section that matches the question name is in the preseed
or not
:rtype: `bool`
"""
if isinstance(key, TextPropertyValue):
key = key.text
if not isinstance(key, basestring):
raise TypeError
return key in self._qnames
[docs] def load(self, lines):
r"""Parse preseed configuration lines.
:param lines: Any iterable that yields preseed file configuration lines
:type lines: iterable
:returns: Preseed file object with information parsed
:rtype: :class:`Preseed`
:raises ParsingError:
If there's a problem parsing some configuration lines, this
exception will be raised with the line number where the problem was
detected in the message.
:Example:
>>> from StringIO import StringIO
>>> preseed_text = StringIO(
... '# Comment\n'
... '\n'
... 'd-i passwd/username string utah\n')
>>> preseed = Preseed()
>>> preseed.load(preseed_text)
>>> print preseed.dump()
# Comment
<BLANKLINE>
d-i passwd/username string utah
<BLANKLINE>
.. note::
This method is automatically called at initialization time if the
`lines` parameter is passed to the constructor, so it's not really
expected to be used directly.
.. seealso:: :meth:`dump`
"""
self.sections = []
# One line might be made of multiple lines
# that are continued with '\\'
output_lines = []
for line_number, input_line in enumerate(lines):
input_line = input_line.rstrip('\r\n')
output_lines.append(input_line)
# Line is finished when it's a comment
# or when no continuation character is found
if (input_line.startswith('#') or
not input_line.endswith('\\')):
try:
new_section = Section.new(output_lines)
except ParsingError as exception:
# Add line number information to the exception
raise ParsingError("Line {}: {}"
.format(line_number + 1, exception))
self.append(new_section)
output_lines = []
[docs] def dump(self):
r"""Dump preseed configuration statements.
This method returns the contents of the preseed after the changes
applied. The string returned is normally used to write the changes back
to a file that can be used as the new preseed to provision a system.
:returns: Formatted preseed configuration lines
:rtype: `string`
:Example:
>>> preseed = Preseed('# Comment\n'.splitlines())
>>> preseed.dump()
'# Comment\n'
.. seealso:: :meth:`load`
"""
return ''.join(str(section) for section in self.sections)
[docs] def prepend(self, new_section, ref_section=None):
r"""Prepend a new section to the preseed.
:param new_section:
The new section to be prepended.
.. note::
If a string is passed instead, a new section will be
created from the string with :meth:`Section.new`.
:type new_section: :class:`Section` | `basestring`
:param ref_section:
A section to be used as a reference, meaning that the new section
will be prepended after the reference section.
.. note::
If no reference section is passed, then the new section will be
prepended just to the beginning of the preseed.
:type ref_section: :class:`Section`
:Example:
>>> preseed = Preseed('d-i passwd/username string utah\n'.splitlines())
>>> preseed.prepend('# Comment')
>>> print preseed.dump()
# Comment
d-i passwd/username string utah
<BLANKLINE>
.. seealso:: :meth:`append`
"""
if isinstance(new_section, basestring):
new_section = Section.new(new_section.splitlines())
assert isinstance(new_section, Section)
assert new_section.parent is None
if ref_section is None:
index = 0
else:
for index, section in enumerate(self.sections):
if section is ref_section:
break
else:
raise ValueError('Reference section not found: {}'
.format(ref_section))
if (self.sections and
isinstance(new_section, (BlankSection, CommentSection)) and
type(new_section) == type(self.sections[index])):
# Old section to be replaced, won't have a parent anymore
self.sections[index].parent = None
grouped_section = new_section + self.sections[index]
self.sections[index] = grouped_section
# New section is now included in the preseed
grouped_section.parent = self
else:
self._insert(index, new_section)
[docs] def append(self, new_section, ref_section=None):
r"""Append a new section to the preseed.
:param new_section:
The new section to be appended.
.. note::
If a string is passed instead, a new section will be
created from the string with :meth:`Section.new`.
:type new_section: :class:`Section` | `basestring`
:param ref_section:
A section to be used as a reference, meaning that the new section
will be appended after the reference section.
.. note::
If no reference section is passed, then the new section will be
appended just to the end of the preseed.
:type ref_section: :class:`Section`
:Example:
>>> preseed = Preseed('# Comment\n'.splitlines())
>>> preseed.append('d-i passwd/username string utah\n')
>>> print preseed.dump()
# Comment
d-i passwd/username string utah
<BLANKLINE>
.. seealso:: :meth:`prepend`
"""
if isinstance(new_section, basestring):
new_section = Section.new(new_section.splitlines())
assert isinstance(new_section, Section)
assert new_section.parent is None
if ref_section is None:
index = len(self.sections) - 1
else:
for index, section in enumerate(self.sections):
if section is ref_section:
break
else:
raise ValueError('Reference section not found: {}'
.format(ref_section))
if (self.sections and
isinstance(new_section, (BlankSection, CommentSection)) and
type(new_section) == type(self.sections[index])):
self.sections[index] += new_section
else:
index += 1
self._insert(index, new_section)
def _insert(self, index, new_section):
"""Insert section or join it with another one of the same type."""
assert new_section.parent is None
# Take ownership of the section
new_section.parent = self
self.sections.insert(index, new_section)
# Update question name index
if isinstance(new_section, ConfigurationSection):
self.section_updated(new_section,
'qname',
None,
new_section.qname.text)
[docs] def section_updated(self, section, property_name, old_text, new_text):
"""Update question names index.
This is a callback called every time a section property is updated and
used to maintain question names index integrity
:param section: Section object calling the callback
:type section: :class:`Section`
:param property_name: Name of the updated property
:type property_name: `string`
:param old_text: Old property text
:type old_text: `string` | `None`
:param new_text: New property text
:type new_text: `string`
:raises DuplicatedQuestionName:
If the updated property is `qname` and the new text is already
taken by other section which would break the access to a section by
the question name.
"""
assert isinstance(new_text, basestring)
if property_name == 'qname':
if new_text in self._qnames:
raise DuplicatedQuestionName(new_text)
if old_text is not None:
assert isinstance(old_text, basestring)
assert old_text in self._qnames
assert self._qnames[old_text] == section
del self._qnames[old_text]
self._qnames[new_text] = section
[docs]class Section(object):
"""Any kind of preseed section (blank, comment or configuration)."""
def __init__(self):
self.parent = None
def __repr__(self):
return '<{}: {!r}>'.format(self.__class__.__name__, str(self))
def __str__(self):
return '<Section>'
@classmethod
[docs] def new(cls, lines):
r"""Create new section subclass based on the lines in the preseed.
This method is used by the :meth:`Preseed.load` method to create new
sections while parsing a preseed file.
:param lines: Lines to be parsed for this particular section
:type lines: `list`
:returns: Section object the properly represents the lines passed
:rtype: subclass of :class:`Section`
:Example:
>>> from utah.preseed import Section
>>> Section.new('\n'.splitlines())
<BlankSection: '\n'>
>>> Section.new('# Comment\n'.splitlines())
<CommentSection: '# Comment\n'>
>>> Section.new('d-i passwd/username string utah\n'.splitlines())
<ConfigurationSection: 'd-i passwd/username string utah\n'>
.. seealso::
:class:`BlankSection`, :class:`CommentSection`,
:class:`ConfigurationSection`
"""
assert isinstance(lines, list)
if all(not line for line in lines):
return BlankSection(len(lines))
if all(line.startswith('#') for line in lines):
return CommentSection(lines)
assert all(line and not line.startswith('#')
for line in lines)
return ConfigurationSection(lines)
[docs]class BlankSection(Section):
"""A pressed section that represents a group of consecutive blank lines.
:param lines_count: Number of blank lines represented by this section
:type lines_count: `int`
:var lines_count: The number of lines as passed to the constructor
:Example:
>>> from utah.preseed import BlankSection
>>> section = BlankSection(3)
>>> section.lines_count
3
.. automethod:: __add__
.. automethod:: __iadd__
"""
def __init__(self, lines_count):
super(BlankSection, self).__init__()
assert isinstance(lines_count, int)
self.lines_count = lines_count
def __str__(self):
return '\n' * self.lines_count
[docs] def __add__(self, other):
r"""Add two blank sections.
:param other: The section to add
:type other: :class:`BlankSection`
:returns:
A new blank section that contains the amount of lines lines from
the two sections being added.
:rtype: :class:`BlankSection`
:Example:
>>> blank1 = BlankSection(1)
>>> blank2 = BlankSection(2)
>>> blank3 = blank1 + blank2
>>> blank3.lines_count
3
.. seealso:: :meth:`__iadd__`
"""
assert isinstance(other, BlankSection)
assert self.parent == other.parent
return BlankSection(self.lines_count + other.lines_count)
[docs] def __iadd__(self, other):
r"""Add two blank sections in place.
:param other: The section to add
:type other: :class:`BlankSection`
:returns:
The section on the left updated to contain the amount of lines
lines from the two sections being added.
:rtype: :class:`BlankSection`
:Example:
>>> blank1 = BlankSection(1)
>>> blank2 = BlankSection(2)
>>> blank3 = blank1 + blank2
>>> blank3.lines_count
3
.. seealso:: :meth:`__iadd__`
"""
assert isinstance(other, BlankSection)
self.lines_count += other.lines_count
return self
[docs]class TextProperty(object):
"""A text property used in :class:`ConfigurationSection` objects.
.. seealso:: :class:`TextPropertyValue`
"""
def __init__(self, name):
self.name = name
self.obj_name = '_{}'.format(name)
def __get__(self, obj, owner=None):
if not hasattr(obj, self.obj_name):
self.__set__(obj, '')
value = getattr(obj, self.obj_name)
assert isinstance(value, TextPropertyValue)
return value
def __set__(self, obj, new_text):
assert isinstance(new_text, basestring)
if hasattr(obj, self.obj_name):
old_value = self.__get__(obj)
old_text = old_value.text
else:
old_text = None
new_value = TextPropertyValue(self, obj, new_text)
setattr(obj, self.obj_name, new_value)
obj.property_updated(self.name, old_text, new_text)
[docs]class TextPropertyValue(object):
"""A text value used in :class:`TextProperty` objects.
The value being stored is just a text string, so there's currently no type
even if the configuration sections in a preseed use types for values.
"""
def __init__(self, parent, obj, text=''):
self.parent = parent
self.obj = obj
self.text = text
def __str__(self):
return self.text
def __repr__(self):
return '<TextPropertyValue: {!r}>'.format(self.text)
def __nonzero__(self):
return bool(self.text)
def __eq__(self, other):
if isinstance(other, TextPropertyValue):
return self.text == other.text
if isinstance(other, basestring):
return self.text == other
raise TypeError
[docs] def prepend(self, other_text):
r"""Prepend a string to the stored value.
:param other_text: The text to be prepended
:type other_text: `basestring`
:returns: The updated value
:rtype: :class:`TextPropertyValue`
:Example:
>>> late_command_str = 'd-i preseed/late_command string some_command\n'
>>> section = Section.new(late_command_str.splitlines())
>>> section.value
<TextPropertyValue: 'some_command'>
>>> section.value.prepend('another_command; ')
<TextPropertyValue: 'another_command; some_command'>
.. note::
The change happens in place, so there's no need to assign any
result back to the :class:`TextProperty` object:
"""
assert isinstance(other_text, basestring)
old_text = self.text
new_text = other_text + self.text
self.text = new_text
self.obj.property_updated(self.parent.name, old_text, new_text)
return self
[docs] def append(self, other_text):
r"""Append a string to the stored value.
:param other_text: The text to be appended
:type other_text: `basestring`
:returns: The updated value
:rtype: :class:`TextPropertyValue`
:Example:
>>> late_command_str = 'd-i preseed/late_command string some_command\n'
>>> section = Section.new(late_command_str.splitlines())
>>> section.value
<TextPropertyValue: 'some_command'>
>>> section.value.append('; another_command')
<TextPropertyValue: 'some_command; another_command'>
.. note::
The change happens in place, so there's no need to assign any
result back to the :class:`TextProperty` object:
"""
assert isinstance(other_text, basestring)
old_text = self.text
new_text = self.text + other_text
self.text = new_text
self.obj.property_updated(self.parent.name, old_text, new_text)
return self
[docs]class ConfigurationSection(Section):
r"""A preseed configuration statement made of one or multiple lines.
The expected format of a configuration section is as follows::
<owner> <qname> <qtype> <value>
where the whole section might be made of multiple lines. A line is
considered not to finish the statement if there's a backslash character
just before the newline character.
If the parsing succeeds, every field is accessible using the same name as
above.
:parameter raw_lines: An iterable that yields one line at a time
:type raw_lines: `iterable`
:raises ParsingError:
If the configuration lines don't follow the expected format above, this
exception will be raised.
:Example:
>>> from utah.preseed import ConfigurationSection
>>> configuration_str = 'd-i passwd/username string utah\n'
>>> section = ConfigurationSection(configuration_str.splitlines())
>>> section.owner
<TextPropertyValue: 'd-i'>
>>> section.qname
<TextPropertyValue: 'passwd/username'>
>>> section.qtype
<TextPropertyValue: 'string'>
>>> section.value
<TextPropertyValue: 'utah'>
"""
TRAILING_CHARS = string.whitespace + '\\'
owner = TextProperty('owner')
qname = TextProperty('qname')
qtype = TextProperty('qtype')
value = TextProperty('value')
def __init__(self, raw_lines):
super(ConfigurationSection, self).__init__()
lines = [raw_lines[0].rstrip(self.TRAILING_CHARS)]
for raw_line in raw_lines[1:]:
lines.append(raw_line
.lstrip()
.rstrip(self.TRAILING_CHARS))
text = ' '.join(lines)
splitted_text = text.split(None, 3)
try:
self.owner = splitted_text[0]
self.qname = splitted_text[1]
self.qtype = splitted_text[2]
except IndexError:
raise ParsingError('Unable to parse configuration lines: {}'
.format(text))
if len(splitted_text) == 4:
self.value = splitted_text[3]
else:
self.value = ''
def __str__(self):
"""Return text representation in a single line."""
if self.value:
line = ('{} {} {} {}\n'
.format(self.owner, self.qname, self.qtype,
self.value))
else:
line = ('{} {} {}\n'
.format(self.owner, self.qname, self.qtype))
return line
[docs] def prepend(self, new_section):
r"""Prepend a new section to this one.
This is a wrapper method that actually calls the
:meth:`Preseed.prepend` method in the preseed using this section as a
reference section to set the insertion position.
:param new_section: The new section to be prepended.
.. note::
If a string is passed instead, a new section will be
created from the string with :meth:`Section.new`.
:type new_section: :class:`Section` | `basestring`
:returns: None
:rtype: None
:Example:
>>> preseed = Preseed()
>>> section = Section.new('d-i passwd/username string utah\n'
... .splitlines())
>>> preseed.append(section)
>>> section.prepend('d-i passwd/user-password password\n')
>>> print preseed.dump()
d-i passwd/user-password password
d-i passwd/username string utah
<BLANKLINE>
.. seealso:: :meth:`append`
"""
assert self.parent is not None
if isinstance(new_section, basestring):
new_section = Section.new(new_section.splitlines())
assert isinstance(new_section, Section)
self.parent.prepend(new_section, self)
[docs] def append(self, new_section):
r"""Append a new section to this one.
This is a wrapper method that actually calls the :meth:`Preseed.append`
method in the preseed using this section as a reference section to set
the insertion position.
:param new_section: The new section to be appended.
.. note::
If a string is passed instead, a new section will be
created from the string with :meth:`Section.new`.
:type new_section: :class:`Section` | `basestring`
:returns: None
:rtype: None
:Example:
>>> preseed = Preseed()
>>> section = Section.new('d-i passwd/username string utah\n'
... .splitlines())
>>> preseed.append(section)
>>> section.append('d-i passwd/user-password password\n')
>>> print preseed.dump()
d-i passwd/username string utah
d-i passwd/user-password password
<BLANKLINE>
.. seealso:: :meth:`prepend`
"""
assert self.parent is not None
if isinstance(new_section, basestring):
new_section = Section.new(new_section.splitlines())
assert isinstance(new_section, Section)
self.parent.append(new_section, self)
[docs] def property_updated(self, property_name, old_value, new_value):
"""Propagate property updates to preseed parent.
If a parent preseed is set, for every updated received from a property
value, the same update is propagated to the parent preseed object.
:param property_name: Name of the updated property
:type property_name: string
:param old_value: Old property value
:type old_value: `string` | `None`
:param new_value: New property value
:type new_value: `string`
"""
if self.parent:
self.parent.section_updated(self,
property_name,
old_value,
new_value)
[docs]class ParsingError(Exception):
r"""An error happened when parsing a preseed section.
This exception is raised when a preseed is being parsed and a new section
object is being created to store some contents.
:Example:
>>> preseed_str = ('not valid\n')
>>> preseed = Preseed(preseed_str.splitlines())
Traceback (most recent call last):
...
ParsingError: Line 1: Unable to parse configuration lines: not valid
.. seealso:: :meth:`Preseed.load`, :class:`ConfigurationSection`
"""
pass
[docs]class DuplicatedQuestionName(Exception):
r"""Duplicated question name found in preseed.
This exception is raised when a question name is found more than once in a
preseed. This is part of the process used in the `Preseed` class to
guarantee that questions can be accessed based on their name.
:Example:
>>> preseed_str = ('d-i passwd/username string utah\n'
... 'd-i passwd/username string ubuntu\n')
>>> preseed = Preseed(preseed_str.splitlines())
Traceback (most recent call last):
...
DuplicatedQuestionName: passwd/username
"""
pass