Skip to content

Commit

Permalink
Add initial AST node class hierarchy (#72)
Browse files Browse the repository at this point in the history
Also introduced parser "flavors" to adjust it to syntax variations.
  • Loading branch information
sydneyli authored and etingof committed Nov 1, 2019
1 parent e01d99c commit 5741248
Show file tree
Hide file tree
Showing 5 changed files with 308 additions and 3 deletions.
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 LeafASTNode # 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']
14 changes: 14 additions & 0 deletions apacheconfig/flavors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#
# This file is part of apacheconfig software.
#
# Copyright (c) 2018-2019, Ilya Etingof <[email protected]>
# License: https://github.com/etingof/apacheconfig/LICENSE.rst
#

NATIVE_APACHE = {
"preservewhitespace": True,
"disableemptyelementtags": True,
"multilinehashcomments": True,
"useapacheinclude": True,
}
""" Set of options for apacheconfig to work with native Apache config files"""
176 changes: 176 additions & 0 deletions apacheconfig/wloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#
# 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


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 AbstractASTNode(object):
"""Generic class containing data that represents a node in the config AST.
"""

@abc.abstractmethod
def dump(self):
"""Returns the contents of this node as in a config file."""

@abc.abstractproperty
def typestring(self):
"""Returns object typestring as defined by the apacheconfig parser."""

@abc.abstractproperty
def whitespace(self):
"""Returns preceding or trailing whitespace for this node as a string.
"""

@abc.abstractmethod
@whitespace.setter
def whitespace(self, value):
"""Set preceding or trailing whitespace for this node.
Args:
value (str): value to set whitespace to.
"""


class LeafASTNode(AbstractASTNode):
"""Creates object containing a simple list of tokens.
Also manages any preceding whitespace. Can represent a key/value option,
a comment, or an include/includeoptional directive.
Examples of what LeafASTNode 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.
Args:
raw (list): Raw data returned from ``apacheconfig.parser``.
"""

def __init__(self, raw):
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:])

@property
def typestring(self):
"""Returns object typestring as defined by the apacheconfig parser.
Returns:
The typestring (as defined by the apacheconfig parser) for
this node. The possible values for this are ``"comment"``,
``"statement"``, ``"include"``, ``"includeoptional"``.
"""
return self._type

@property
def whitespace(self):
"""Returns preceding whitespace for this node.
For example::
LeafASTNode('\\n option value').whitespace => "\\n "
LeafASTNode('option value').whitespace => ""
LeafASTNode('\\n # comment').whitespace => "\\n "
"""
return self._whitespace

@whitespace.setter
def whitespace(self, value):
"""See base class. Operates on preceding whitespace."""
self._whitespace = value

@classmethod
def parse(cls, raw_str, parser):
"""Factory for :class:`apacheconfig.LeafASTNode` by parsing data from a
config string.
Args:
raw_str (string): The text to parse.
parser (:class:`apacheconfig.ApacheConfigParser`): specify the
parser to use. Can be created by ``native_apache_parser()``.
Returns:
:class:`apacheconfig.LeafASTNode` containing metadata parsed from
``raw_str``.
"""
raw = parser.parse(raw_str)
return cls(raw[1])

@property
def name(self):
"""Returns the name of this node.
The name is the first non-whitespace token in the directive. Cannot be
written. For comments, is the entire comment.
"""
return self._raw[0]

@property
def has_value(self):
"""Returns ``True`` if this :class:`apacheconfig.LeafASTNode` has a value.
``LeafASTNode`` objects don't have to have a value, like option/value
directives with no value, or comments.
"""
return len(self._raw) > 1

@property
def value(self):
"""Returns the value of this item as a string.
The "value" is anything but the name. Can be overwritten.
"""
if not self.has_value:
return
return self._raw[-1]

@value.setter
def value(self, value):
"""Sets for the value of this item.
Args:
value (str): 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 dump(self):
return (self.whitespace +
"".join([_restore_original(word) for word in self._raw]))

def __str__(self):
return ("%s(%s)"
% (self.__class__.__name__,
str([self._type] +
[_restore_original(word) for word in self._raw])))
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
112 changes: 112 additions & 0 deletions tests/unit/test_wloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#
# 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 LeafASTNode
from apacheconfig import flavors
from apacheconfig import make_lexer
from apacheconfig import make_parser


class WLoaderTestCaseWrite(unittest.TestCase):

def setUp(self):
ApacheConfigLexer = make_lexer(**flavors.NATIVE_APACHE)
ApacheConfigParser = make_parser(**flavors.NATIVE_APACHE)
self.parser = ApacheConfigParser(ApacheConfigLexer(), start="contents")

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 = LeafASTNode.parse(raw, self.parser)
node.value = new_value
self.assertEqual(expected, node.dump())


class WLoaderTestCaseRead(unittest.TestCase):

def setUp(self):
ApacheConfigLexer = make_lexer(**flavors.NATIVE_APACHE)
ApacheConfigParser = make_parser(**flavors.NATIVE_APACHE)
self.parser = ApacheConfigParser(ApacheConfigLexer(), start="contents")

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 = LeafASTNode.parse(raw, self.parser)
node.value = new_value
self.assertEqual(expected, node.dump())

def _test_item_cases(self, cases, expected_type, parser=None):
if not parser:
parser = self.parser
for raw, expected_name, expected_value in cases:
node = LeafASTNode.parse(raw, self.parser)
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, node.dump(),
("Expected node('%s').dump() to be the same, "
"but got '%s'" % (repr(raw), node.dump())))
self.assertEqual(expected_type, node.typestring,
("Expected node('%s').typestring to be '%s', "
"but got '%s'" % (repr(raw), expected_type,
str(node.typestring))))

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', self.parser)

0 comments on commit 5741248

Please sign in to comment.