Compare commits

..

2 Commits

Author SHA1 Message Date
bluepython508
5bbbb4f1da OOP Assessment 2 2024-05-01 17:42:07 +01:00
bluepython508
b7b584a989 Typo 2024-04-11 12:58:08 +01:00
5 changed files with 409 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,2 @@
export NIX_PATH="nixpkgs=flake:nixpkgs"
use nix

View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python
"""Usage: `python assessment2.py {subcommand} {expressions}`
If expressions are not provided, they are taken from each line of stdin.
Documentation of subcommands is available from `python assessment2.py -h`.
Examples for marking criteria:
1. Eval: `python assessment2.py eval "(((2*(3+2))+5)/2)"`
2. Tree: `python assessment2.py render-tree "(((2*(3+2))+5)/2)"`
3. Preorder: `python assessment2.py format-prefix "(((2*(3+2))+5)/2)"`
In-order: `python assessment2.py format-infix "(((2*(3+2))+5)/2)"`
Postorder: `python assessment2.py format-postfix "(((2*(3+2))+5)/2)"`
4. Errors: `python assessment2.py eval "(4*3*2)" "(4*(2))" "(4*(3+2)*(2+1))" "(2*4)*(3+2)" "((2+3)*(4*5)" "((2+3)*(4*5)" "(2+5)*(4/(2+2)))" "(2+5)*(4/(2+2)))" "(((2+3)*(4*5))+(1(2+3)))"`
5. Tests: `python assessment2.py test`
Tests can be run with `pytest assessment2.py` or `python assessment2.py test`.
All of the formatting is implemented with post-order traversals. This is
necessary to output parenthesis in the pre- and in-order cases. It could also be
done with a buffer and a combined traversal (with callbacks before, between, and
after subtree visits), but that requires mutation.
Note: this program requires Python 3.12 due to the generic syntax used to define
`BTree.traverse`. Pytest is also required. These should be installed already
on Codio.
"""
# stdlib
from dataclasses import dataclass
from typing import Literal, Tuple, Callable, cast, override
from operator import add, sub, mul, truediv as div
from argparse import ArgumentParser
import sys
import re
from textwrap import dedent
# pytest, from https://pypi.org/project/pytest/
# authors can be found at https://github.com/pytest-dev/pytest/graphs/contributors,
import pytest
OPERATORS: dict[Literal["+", "-", "*", "/"], Callable[[float, float], float]] = {
"+": add,
"-": sub,
"*": mul,
"/": div,
}
class BTree:
value: float
def traverse[T](
self,
value: Callable[["Value", int], T],
operator: Callable[["Operator", T, T, int], T],
depth: int = 0,
) -> T:
"""Traverse the binary tree with a post-order traversal
`value` takes the value node and its depth from the root node
`operator` takes the operator node, the results of traversing the left
and right subtrees, and the depth of the node
The tree could, for example, be evaluated with `traverse`:
```
(...).traverse(
lambda node, _: node.value,
lambda node, left, right, _: OPERATORS[node.operator](left, right)
)
```
"""
raise NotImplementedError
def as_preorder_str(self) -> str:
"""Format the tree in prefix notation"""
return self.traverse(
lambda value, _: str(value.value),
lambda op, left, right, _: f"({op.operator} {left} {right})",
)
def as_inorder_str(self) -> str:
"""Format the tree as a typical parenthesized expression"""
return self.traverse(
lambda value, _: str(value.value),
lambda op, left, right, _: f"({left} {op.operator} {right})",
)
__str__ = as_inorder_str
def as_inorder_lines(self) -> str:
"""Format the tree, visually as a tree"""
return self.traverse(
lambda value, depth: " " * depth + str(value.value),
lambda op, left, right, depth: f"{left}\n{' ' * depth}{op.operator}\n{right}",
)
def as_postorder_str(self) -> str:
"""Format the tree in postfix notation (RPN)"""
return self.traverse(
lambda value, _: str(value.value),
lambda op, left, right, _: f"{left} {right} {op.operator}",
)
@dataclass(frozen=True, slots=True)
class Value(BTree):
value: float
@override
def traverse[T](
self,
value: Callable[["Value", int], T],
operator: Callable[["Operator", T, T, int], T],
depth: int = 0,
) -> T:
"""Traverse the binary tree with a post-order traversal
`value` takes the value node and its depth from the root node
`operator` takes the operator node, the results of traversing the left
and right subtrees, and the depth of the node
The tree could, for example, be evaluated with `traverse`:
```
(...).traverse(
lambda node, _: node.value,
lambda node, left, right, _: OPERATORS[node.operator](left, right)
)
```
"""
return value(self, depth)
@dataclass(frozen=True, slots=True)
class Operator(BTree):
operator: Literal["+", "-", "*", "/"]
left: BTree
right: BTree
@property
def value(self) -> float: # type: ignore - the field should be read-only
return OPERATORS[self.operator](self.left.value, self.right.value)
@override
def traverse[T](
self,
value: Callable[["Value", int], T],
operator: Callable[["Operator", T, T, int], T],
depth: int = 0,
) -> T:
"""Traverse the binary tree with a post-order traversal
`value` takes the value node and its depth from the root node
`operator` takes the operator node, the results of traversing the left
and right subtrees, and the depth of the node
The tree could, for example, be evaluated with `traverse`:
```
(...).traverse(
lambda node, _: node.value,
lambda node, left, right, _: OPERATORS[node.operator](left, right)
)
```
"""
return operator(
self,
self.left.traverse(value, operator, depth + 1),
self.right.traverse(value, operator, depth + 1),
depth,
)
def parse(expr: str) -> BTree:
"""Parse a parenthesized expression as in the spec.
Requires a single parenthesized string, potentially with whitespace
Raises ValueError on parse errors
"""
def number(expr: str) -> Tuple[BTree, str] | None:
if not expr[0].isdigit():
return None
rest = expr.lstrip("0123456789")
return Value(int(expr[: len(expr) - len(rest)])), rest.lstrip()
def parenthesized(expr: str) -> Tuple[BTree, str] | None:
if not expr.startswith("("):
return None
expr = expr[1:].lstrip()
left, expr = operand(expr)
operator, expr = expr[0], expr[1:]
if operator not in OPERATORS:
raise ValueError(f"Unknown operator {operator}")
operator = cast(Literal["+", "-", "*", "/"], operator)
right, expr = operand(expr.lstrip())
if not expr.startswith(")"):
if expr and expr[0] in OPERATORS:
raise ValueError("Too many operands in expression")
raise ValueError("Expected closing parenthesis")
return Operator(operator, left, right), expr[1:].lstrip()
def operand(expr: str) -> Tuple[BTree, str]:
v = number(expr) or parenthesized(expr)
if not v:
raise ValueError(
"Expected a number or the beginning of a parenthesized expression"
)
return v
result = parenthesized(expr.lstrip())
if not result or result[1]:
# If rest is non-empty, then something was after the parenthesis starting the expression
# i.e. the full expression was not parenthesized.
raise ValueError("Expected parenthesized expression at the top level")
return result[0]
@pytest.mark.parametrize(
("expr", "ast"),
[
("(1 + 1)", Operator("+", Value(1), Value(1))),
(
"(2*(3+ 2))",
Operator("*", Value(2), Operator("+", Value(3), Value(2))),
),
(" (1 +1) ", Operator("+", Value(1), Value(1))),
],
)
def test_parse(expr: str, ast: BTree):
"""Test parsing of expressions"""
assert parse(expr) == ast
@pytest.mark.parametrize(
("expr", "value"),
[
("(1 + 2)", 3),
("(12 + (((4 / 2) * 3) + (3 * 2)))", 24),
("(((2 * (3 + 2)) + 5) / 2)", 7.5),
],
)
def test_eval(expr: str, value: float):
"""Test evaluation"""
assert parse(expr).value == value
@pytest.mark.parametrize(
("expr", "tree"),
[
(
"(1 + 2)",
"""
1
+
2
""",
),
(
"(1 + (((4 / 2) * 3) + (3 * 2)))",
"""
1
+
4
/
2
*
3
+
3
*
2
""",
),
(
"(((2*(3+2))+5)/2)",
"""
2
*
3
+
2
+
5
/
2
"""
)
],
)
def test_inorder_lines(expr: str, tree: str):
"""Test tree rendering"""
assert parse(expr).as_inorder_lines() == dedent(tree).strip("\n")
@pytest.mark.parametrize(
("expr", "prefix"),
[
("(1 + 2)", "(+ 1 2)"),
("(1 + (((4 / 2) * 3) + (3 * 2)))", "(+ 1 (+ (* (/ 4 2) 3) (* 3 2)))"),
],
)
def test_prefix(expr: str, prefix: str):
"""Test prefix formatting"""
assert parse(expr).as_preorder_str() == prefix
@pytest.mark.parametrize(
("expr"),
[
"(1 + 2)",
"(1 + (((4 / 2) * 3) + (3 * 2)))",
"(((3 + 2) * 2) + (((7 / 3) * 5) - 3))",
],
)
def test_infix_roundtrip(expr: str):
"""Test infix formatting roundtrips"""
assert str(parse(expr)) == expr
@pytest.mark.parametrize(
("expr", "postfix"),
[
("(1 + 2)", "1 2 +"),
("(1 + (((4 / 2) * 3) + (3 * 2)))", "1 4 2 / 3 * 3 2 * + +"),
],
)
def test_postfix(expr: str, postfix: str):
"""Test postfix formatting"""
assert parse(expr).as_postorder_str() == postfix
@pytest.mark.parametrize(
("expr", "err"),
[
("(4 * 3 * 2)", "Too many operands in expression"),
("(4 * (2))", "Unknown operator )"),
("(4 * (3 + 2) * (2 + 1))", "Too many operands in expression"),
("(2 *4)*(3+2)", "Expected parenthesized expression at the top level"),
("(2+5)*(4/(2+2))", "Expected parenthesized expression at the top level"),
("(((2+3)*(4*5))+(1(2+3)))", "Unknown operator ("),
],
)
def test_error(expr: str, err: str):
"""Test error messages"""
with pytest.raises(ValueError, match=re.escape(err)):
parse(expr)
if __name__ == "__main__":
parser = ArgumentParser(usage=__doc__)
subcommands = parser.add_subparsers(title="Subcommands", required=True)
def with_expressions(parser):
parser.add_argument(
"expressions",
nargs="*",
help="The expressions to operate on. If none are provided, operate on lines of stdin.",
)
return parser
with_expressions(
subcommands.add_parser("eval", help="Evaluate the expression")
).set_defaults(func=lambda expr: expr.value)
with_expressions(
subcommands.add_parser("render-tree", help="Render the expression as a tree")
).set_defaults(func=BTree.as_inorder_lines)
with_expressions(
subcommands.add_parser(
"format-prefix", help="Format the expression in prefix notation"
)
).set_defaults(func=BTree.as_preorder_str)
with_expressions(
subcommands.add_parser(
"format-infix",
help="Format the expression in typical parenthesized infix notation",
)
).set_defaults(func=BTree.as_inorder_str)
with_expressions(
subcommands.add_parser(
"format-postfix",
help="Format the expression in postfix notation, i.e. as RPN",
)
).set_defaults(func=BTree.as_postorder_str)
subcommands.add_parser("test", help="Run tests").set_defaults(
func=lambda: pytest.main([__file__])
)
args = parser.parse_args()
if "expressions" in args:
for expression in args.expressions or sys.stdin:
if len(args.expressions) > 1:
print(expression + ":")
try:
parsed = parse(expression)
except ValueError as e:
print(f"Error: {e}")
continue
print(args.func(parsed))
else:
args.func()

View File

@@ -0,0 +1,3 @@
{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell {
packages = [(pkgs.python312.withPackages (p: with p; [ python-lsp-server pytest pytest-watch black ])) pkgs.nodePackages.pyright];
}