Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create and test Node and ItemNode #72

Merged
merged 10 commits into from
Nov 1, 2019
Merged
6 changes: 4 additions & 2 deletions apacheconfig/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from apacheconfig.loader import ApacheConfigLoader
from apacheconfig.error import ApacheConfigError

from apacheconfig.wloader import ItemNode # noqa: F401


@contextmanager
def make_loader(**options):
Expand All @@ -18,5 +20,5 @@ def make_loader(**options):
**options)


__all__ = ['make_lexer', 'make_parser', 'make_loader', 'ApacheConfigLoader',
'ApacheConfigError']
__all__ = ['make_lexer', 'make_parser', 'make_loader',
'ApacheConfigLoader', 'ApacheConfigError']
219 changes: 219 additions & 0 deletions apacheconfig/wloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#
# This file is part of apacheconfig software.
#
# Copyright (c) 2018-2019, Ilya Etingof <[email protected]>
# License: https://github.com/etingof/apacheconfig/LICENSE.rst
#
import abc
import six

from apacheconfig import make_parser
from apacheconfig import make_lexer


def _restore_original(word):
"""If the `word` is a Quoted string, restores it to original.
"""
if getattr(word, 'is_single_quoted', False):
return "'%s'" % word
if getattr(word, 'is_double_quoted', False):
return '"%s"' % word
return word


@six.add_metaclass(abc.ABCMeta)
class Node():
"""Generic class containing data that represents a node in the config AST.
"""

@abc.abstractmethod
def __str__(self):
"""Writes this node to a raw string. To get more metadata about the
object, use ``repr``.
"""
pass

@abc.abstractmethod
def __repr__(self):
"""Returns a string containing object metadata."""
pass

@property
def parser_type(self):
"""A typestring as defined by the apacheconfig parser.

The possible values for this are:

:class:`apacheconfig.ItemNode`: ``comment``, ``statement``,
``include``, ``includeoptional``

:returns: The typestring (as defined by the apacheconfig parser) for
this node.
:rtype: `str`
"""
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/me notes that we should properly document parameters, return value, exceptions etc...

if self._type is None:
raise NotImplementedError()
return self._type

@property
def whitespace(self):
"""A string representing literal trailing or preceding whitespace
for this node. Can be overwritten.

Each ``ItemNode`` or ``BlockNode`` keeps track of the whitespace
preceding it. For the first element in the configuration file, there
could be no whitespace preceding it at all, in which case this should
return the empty string.

ContentsNode is special in that it keeps track of the *trailing*
whitespace. For example::

ItemNode('\\n option value').whitespace => "\\n "
ItemNode('\\n # comment').whitespace => "\\n "
BlockNode('\\n <a>\\n</a>').whitespace => "\\n "
ContentsNode('\\n option value # comment\\n').whitespace => "\\n"

:returns: a string containing literal preceding or trailing whitespace
information for this node.
:rtype: `str`
"""
if self._whitespace is None:
raise NotImplementedError()
return self._whitespace

@whitespace.setter
def whitespace(self, value):
"""A string representing literal trailing or preceding whitespace
for this node. Trailing for ``Contents``, preceding for ``Item`` or
``Block``.

:param str value: whitespace string to set this node's whitespace.
"""
if self._whitespace is None:
raise NotImplementedError()
self._whitespace = value


class ItemNode(Node):
"""Contains data for a comment or option-value directive.

Also manages any preceding whitespace. Can represent a key/value option,
a comment, or an include/includeoptional directive.

Examples of what ItemNode fields might look like for different directives::

"option"
name: "option", value: None, whitespace: ""
"include relative/path/*"
name: "include", value: "relative/path/*", whitespace: ""
"\\n option = value"
name: "option", value: "value", whitespace: "\\n "
"# here is a comment"
name: "# here is a comment", value: None, whitespace: ""
"\\n # here is a comment"
name: "# here is a comment", value: None, whitespace: "\\n "

To construct from a raw string, use the `parse` constructor. The regular
constructor receives data from the internal apacheconfig parser.

:param list raw: Raw data returned from ``apacheconfig.parser``.
"""

def __init__(self, raw, options={}):
self._type = raw[0]
self._raw = tuple(raw[1:])
self._whitespace = ""
if len(raw) > 1 and raw[1].isspace():
self._whitespace = raw[1]
self._raw = tuple(raw[2:])

@staticmethod
def parse(raw_str, options={}, parser=None):
"""Constructs an ItemNode by parsing it from a raw string.

:param dict options: (optional) Additional options to pass to the
created parser. Ignored if another ``parser`` is
supplied.
:param parser: (optional) To re-use an existing parser. If ``None``,
creates a new one.
:type parser: :class:`apacheconfig.ApacheConfigParser`

:returns: an ItemNode containing metadata parsed from ``raw_str``.
:rtype: :class:`apacheconfig.ItemNode`
"""
if not parser:
parser = _create_apache_parser(options, start='startitem')
return ItemNode(parser.parse(raw_str))

@property
def name(self):
"""The first non-whitespace token, semantically the "name" of this
directive. Cannot be written. For comments, is the entire comment.

:returns: The name of this node.
:rtype: `str`
"""
return self._raw[0]

def has_value(self):
"""Returns whether value exists. ``ItemNode`` objects don't have to
have a value, like option/value directives with no value, or comments.

:returns: True if this ``ItemNode`` has a value.
:rtype: `bool`
"""
return len(self._raw) > 1

@property
def value(self):
"""Everything but the name, semantically the "value" of this item.
Can be overwritten.

:returns: The value of this node.
:rtype: `str`
"""
if not self.has_value():
return None
return self._raw[-1]

@value.setter
def value(self, value):
"""Setter for the value of this item.

:param str value: string to set new value to.

.. todo:: (sydneyli) convert `value` to quotedstring when quoted
"""
if not self.has_value():
self._raw = self._raw + (" ", value,)
self._raw = self._raw[0:-1] + (value,)

def __str__(self):
return (self.whitespace +
"".join([_restore_original(word) for word in self._raw]))

def __repr__(self):
return ("%s(%s)"
% (self.__class__.__name__,
str([self._type] +
[_restore_original(word) for word in self._raw])))


def _create_apache_parser(options={}, start='contents'):
"""Creates a ``ApacheConfigParser`` with default options that are expected
by Apache's native parser, to enable the writable loader to work.

Overrides options ``preservewhitespace``, ``disableemptyelementtags``, and
``multilinehashcomments`` to ``True``.

:param dict options: Additional parameters to pass.
:param str start: Which parsing token, as defined by the apacheconfig
parser, to expect at the root of strings. This is an
internal flag and shouldn't need to be used.
"""
options['preservewhitespace'] = True
options['disableemptyelementtags'] = True
options['multilinehashcomments'] = True
ApacheConfigLexer = make_lexer(**options)
ApacheConfigParser = make_parser(**options)
return ApacheConfigParser(ApacheConfigLexer(), start=start)
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
ply>=3.0
ply>=3.0
six
80 changes: 80 additions & 0 deletions tests/unit/test_wloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#
# This file is part of apacheconfig software.
#
# Copyright (c) 2018-2019, Ilya Etingof <[email protected]>
# License: https://github.com/etingof/apacheconfig/LICENSE.rst
#
try:
import unittest2 as unittest

except ImportError:
import unittest

from apacheconfig import ItemNode


class WLoaderTestCaseWrite(unittest.TestCase):
def testChangeItemValue(self):
cases = [
('option value', 'value2', 'option value2'),
('\noption value', 'value2', '\noption value2'),
('\noption =\\\n value', 'value2', '\noption =\\\n value2'),
('option value', 'long value', 'option long value'),
('option value', '"long value"', 'option "long value"'),
('option', 'option2', 'option option2'),
('include old/path/to/file', 'new/path/to/file',
'include new/path/to/file'),
]
for raw, new_value, expected in cases:
node = ItemNode.parse(raw)
node.value = new_value
self.assertEqual(expected, str(node))


class WLoaderTestCaseRead(unittest.TestCase):
def _test_item_cases(self, cases, expected_type, options={}):
for raw, expected_name, expected_value in cases:
node = ItemNode.parse(raw, options)
self.assertEqual(expected_name, node.name,
"Expected node('%s').name to be %s, got %s" %
(repr(raw), expected_name, node.name))
self.assertEqual(expected_value, node.value,
"Expected node('%s').value to be %s, got %s" %
(repr(raw), expected_value, node.value))
self.assertEqual(raw, str(node),
("Expected str(node('%s')) to be the same, "
"but got '%s'" % (repr(raw), str(node))))
self.assertEqual(expected_type, node.parser_type,
("Expected node('%s').parser_type to be '%s', "
"but got '%s'" % (repr(raw), expected_type,
str(node.parser_type))))

def testLoadStatement(self):
cases = [
('option value', 'option', 'value'),
('option', 'option', None),
(' option value', 'option', 'value'),
(' option = value', 'option', 'value'),
('\noption value', 'option', 'value'),
('option "dblquoted value"', 'option', 'dblquoted value'),
("option 'sglquoted value'", "option", "sglquoted value"),
]
self._test_item_cases(cases, 'statement')

def testLoadComment(self):
comment = '# here is a silly comment'
cases = [
(comment, comment, None),
('\n' + comment, comment, None),
(' ' + comment, comment, None),
]
self._test_item_cases(cases, 'comment')

def testLoadApacheInclude(self):
cases = [
('include path', 'include', 'path'),
(' include path', 'include', 'path'),
('\ninclude path', 'include', 'path'),
]
self._test_item_cases(cases, 'include',
options={'useapacheinclude': True})