Skip to content

Commit cf7871d

Browse files
committed
Add unit testing example
1 parent b981959 commit cf7871d

16 files changed

+812
-0
lines changed

source-code/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ was used to develop it.
2121
programming in Python.
2222
1. `operators-functools`: illustrates some applications of the `operator`
2323
and `functools` modules in Python's standard library.
24+
1. `pyparsing`: illustration of unit testing on real world code.
2425
1. `typing`: illustrates how to use type annotation in Python, and
2526
demonstrates `mypy`.
2627
1. `unit-testing`: illustrates writing unit tests with `unittest` and
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env python
2+
3+
from argparse import ArgumentParser, FileType
4+
from newick_parser import NewickParser
5+
6+
7+
def compute_branch_lengths(node, branch_lengths=None, length=0.0):
8+
if branch_lengths is None:
9+
branch_lengths = {}
10+
compute_branch_lengths(node, branch_lengths, length)
11+
return branch_lengths
12+
if node.is_leaf():
13+
branch_lengths[node.label] = length + node.length
14+
else:
15+
if node.length is not None:
16+
length += node.length
17+
for child in node.children():
18+
compute_branch_lengths(child, branch_lengths, length)
19+
20+
21+
def main():
22+
arg_parser = ArgumentParser(description='Determine leaf branch '
23+
'lengths for Newick tree.')
24+
arg_parser.add_argument('--file', type=FileType('r'), action='store',
25+
dest='file', required=True,
26+
help='Newick file to parse')
27+
options = arg_parser.parse_args()
28+
tree_str = '\n'.join(options.file.readlines())
29+
options.file.close()
30+
node_parser = NewickParser()
31+
tree = node_parser.parse(tree_str)
32+
branch_lengths = compute_branch_lengths(tree)
33+
for taxa, length in list(branch_lengths.items()):
34+
print('{taxa}: {length}'.format(taxa=taxa, length=length))
35+
36+
if __name__ == '__main__':
37+
main()

source-code/pyparsing/macro_defs.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'''Module containing implementations of macros that
2+
can be used in macro-expansion texts.'''
3+
4+
5+
def upper(text):
6+
'''convert argument to all uppercase'''
7+
return text.upper()
8+
9+
10+
def lower(text):
11+
'''convert argument to all lowercase'''
12+
return text.lower()
13+
14+
15+
def author():
16+
'''returns the author'''
17+
return 'Geert Jan Bex'
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env python
2+
3+
from argparse import ArgumentParser, FileType
4+
import imp
5+
import sys
6+
import types
7+
from pyparsing import Regex, Literal, ZeroOrMore, Group
8+
9+
10+
class UndefinedMacroError(Exception):
11+
'''Class encoding an exception for an undefined macro encountered
12+
while parsing a text'''
13+
14+
def __init__(self, function_name):
15+
'''Constructor, takes the unknown macro name as an argument'''
16+
super(UndefinedMacroError, self).__init__()
17+
self._msg = "unknown macro '{0}'".format(function_name.strip('\\'))
18+
19+
def __str__(self):
20+
'''method to stringify the exception'''
21+
return repr(self._msg)
22+
23+
24+
class MacroExpander(object):
25+
'''Macro expansion class, macros are encoded as
26+
\\macro_name{param_1}...{param_n}'''
27+
28+
def __init__(self):
29+
'''Constructor'''
30+
self._macros = {}
31+
text = Regex(r'[^\\]+').leaveWhitespace()
32+
lb = Literal('{').suppress()
33+
rb = Literal('}').suppress()
34+
param_value = Regex(r'[^}\\]+')
35+
param = lb + ZeroOrMore(param_value) + rb
36+
params = Group(ZeroOrMore(param)).setResultsName('params')
37+
macro_name = Regex(r'\\\w+').setResultsName('macro')
38+
macro_call = macro_name + params
39+
text_file = ZeroOrMore(text | macro_call)
40+
41+
def macro_action(toks):
42+
macro_name = toks['macro']
43+
params = toks['params']
44+
if self._has_macro(macro_name):
45+
return self._macros[macro_name](*params)
46+
else:
47+
raise UndefinedMacroError(macro_name)
48+
macro_call.addParseAction(macro_action)
49+
self._grammar = text_file
50+
51+
def add_macro(self, macro_name, macro_impl):
52+
'''method to add a new macro to the macro expander, given
53+
the function name, and its implementation as arguments'''
54+
self._macros['\\' + macro_name] = macro_impl
55+
56+
def _has_macro(self, macro_name):
57+
'''internal method to check whether the parser has a
58+
definition for the given macro name'''
59+
return macro_name in self._macros
60+
61+
def expand(self, text):
62+
'''method to perform the macro expansion on the given text'''
63+
results = self._grammar.parseString(text)
64+
return ''.join(results)
65+
66+
67+
def main():
68+
arg_parser = ArgumentParser(description='macro expansion utility')
69+
arg_parser.add_argument('--file', type=FileType('r'),
70+
action='store', dest='file',
71+
required=True, help='file to expand')
72+
arg_parser.add_argument('--def', type=str, action='store',
73+
default='macro_defs', dest='defs',
74+
help='macro definitions module name')
75+
try:
76+
options = arg_parser.parse_args()
77+
text = ''.join(options.file)
78+
module_info = imp.find_module(options.defs)
79+
macro_module = imp.load_module(options.defs, *module_info)
80+
expander = MacroExpander()
81+
for macro_def in macro_module.__dict__.values():
82+
if isinstance(macro_def, types.FunctionType):
83+
expander.add_macro(macro_def.__name__, macro_def)
84+
print(expander.expand(text))
85+
except UndefinedMacroError as error:
86+
sys.stderr.write('### error: ' + str(error) + '\n')
87+
sys.exit(2)
88+
except Exception as error:
89+
sys.stderr.write('### error: ' + str(error) + '\n')
90+
sys.exit(1)
91+
92+
if __name__ == '__main__':
93+
main()

source-code/pyparsing/macro_tests.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env python
2+
'''Module defining some unit tests for the MacroExpander utility'''
3+
4+
import unittest
5+
from macro_expander import MacroExpander
6+
7+
8+
class MacroExapnderTest(unittest.TestCase):
9+
10+
def setUp(self):
11+
self._expander = MacroExpander()
12+
self._expander.add_macro('upper', lambda x: x.upper())
13+
self._expander.add_macro('lower', lambda x: x.lower())
14+
self._expander.add_macro('gjb', lambda: 'Geert Jan Bex')
15+
self._expander.add_macro('repeat', lambda x, n: int(n)*x)
16+
self._expander.add_macro('cat', lambda x, y: x + '-' + y)
17+
self._expander.add_macro('add', lambda x, y: str(int(x) + int(y)))
18+
19+
def test_upper(self):
20+
orig_text = 'This is a text with \\upper{a function call}.'
21+
target_text = 'This is a text with A FUNCTION CALL.'
22+
text = self._expander.expand(orig_text)
23+
self.assertEqual(text, target_text)
24+
25+
def test_apostrophe(self):
26+
orig_text = "This'll be upper case \\upper{won't it?}."
27+
target_text = "This'll be upper case WON'T IT?."
28+
text = self._expander.expand(orig_text)
29+
self.assertEqual(text, target_text)
30+
31+
def test_multiple_arguments(self):
32+
orig_text = 'This \cat{abc}{def} a text.'
33+
target_text = 'This abc-def a text.'
34+
text = self._expander.expand(orig_text)
35+
self.assertEqual(text, target_text)
36+
37+
def test_numerical_arguments(self):
38+
orig_text = 'This is a sum: \\add{3}{7}.'
39+
target_text = 'This is a sum: 10.'
40+
text = self._expander.expand(orig_text)
41+
self.assertEqual(text, target_text)
42+
43+
def test_literal_string(self):
44+
orig_text = r'This \repeat{is}{3} a text.'
45+
target_text = 'This isisis a text.'
46+
text = self._expander.expand(orig_text)
47+
self.assertEqual(text, target_text)
48+
49+
def test_multiple_macros(self):
50+
orig_text = ('This \\repeat{is}{3} a text with \\upper{a ' +
51+
'function call}.')
52+
target_text = 'This isisis a text with A FUNCTION CALL.'
53+
text = self._expander.expand(orig_text)
54+
self.assertEqual(text, target_text)
55+
56+
def test_multiple_lines(self):
57+
orig_text = ('This is a text with \\upper{a function call},\n' +
58+
'and \lower{ANOTHER ONE} on multiple lines\n\n' +
59+
'authored by... \gjb.\n')
60+
target_text = ('This is a text with A FUNCTION CALL,\n' +
61+
'and another one on multiple lines\n\n' +
62+
'authored by... Geert Jan Bex.\n')
63+
text = self._expander.expand(orig_text)
64+
self.assertEqual(text, target_text)
65+
66+
67+
if __name__ == '__main__':
68+
unittest.main()

source-code/pyparsing/newick.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
(taxa1: 0.1, ((taxa2: 0.2, taxa3: 0.3): 0.11, taxa4: 0.4): 0.12);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python
2+
'''various implementations of tree writers'''
3+
4+
from argparse import ArgumentParser, FileType
5+
from tree_converter import XmlWriter, IndentedStringWriter, RelationalWriter
6+
from newick_parser import NewickParser
7+
8+
9+
class XmlNewickWriter(XmlWriter):
10+
'''Writes a given node and its children as XML'''
11+
12+
def node_attr(self, node):
13+
'''formats node attributes, if any'''
14+
attr = ''
15+
if node.label is not None:
16+
attr += ' label="{0}"'.format(node.label)
17+
if node.length is not None:
18+
attr += ' length="{0}"'.format(node.length)
19+
return attr
20+
21+
22+
class IndentedStringNewickWriter(IndentedStringWriter):
23+
'''Writes a given node and its children as an indented string'''
24+
25+
def node_attr(self, node):
26+
'''formats node attributes, if any'''
27+
attr = ''
28+
if node.label is not None:
29+
attr += ': {0}'.format(node.label)
30+
if node.length is not None:
31+
attr += '[{0}]'.format(node.length)
32+
return attr
33+
34+
35+
class RelationalNewickWriter(RelationalWriter):
36+
'''Writes a given node and its children in a form that can be stored in
37+
a relational table'''
38+
39+
def node_attr(self, node):
40+
'''formats node attributes, if any'''
41+
attr = ''
42+
if node.label is not None:
43+
attr += '\t{0}'.format(node.label)
44+
else:
45+
attr += '\tNone'
46+
if node.length is not None:
47+
attr += '\t{0}'.format(node.length)
48+
else:
49+
attr += '\tNone'
50+
return attr
51+
52+
53+
def main():
54+
'''Function that will parse the given file and convert the tree to the
55+
format specified'''
56+
argParser = ArgumentParser(description='tree structured data converter')
57+
argParser.add_argument('--file', type=FileType('r'), action='store',
58+
dest='file', required=True,
59+
help='file to parse')
60+
argParser.add_argument('--format', type=str, action='store',
61+
default='string', dest='format',
62+
help='format to convert to, default = string')
63+
options = argParser.parse_args()
64+
tree_str = '\n'.join(options.file.readlines())
65+
options.file.close()
66+
node_parser = NewickParser()
67+
tree = node_parser.parse(tree_str)
68+
if options.format == 'xml':
69+
writer = XmlNewickWriter()
70+
elif options.format == 'relational':
71+
writer = RelationalNewickWriter()
72+
else:
73+
writer = IndentedStringNewickWriter()
74+
print(writer.write(tree))
75+
76+
if __name__ == '__main__':
77+
main()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node "node_007"
2+
node "node_001": taxa1[0.1]
3+
node "node_006"[0.12]
4+
node "node_004"[0.11]
5+
node "node_002": taxa2[0.2]
6+
node "node_003": taxa3[0.3]
7+
node "node_005": taxa4[0.4]
8+

0 commit comments

Comments
 (0)