diff --git a/CS1527/assessment-2/.envrc b/CS1527/assessment-2/.envrc new file mode 100644 index 0000000..f952b20 --- /dev/null +++ b/CS1527/assessment-2/.envrc @@ -0,0 +1,2 @@ +export NIX_PATH="nixpkgs=flake:nixpkgs" +use nix diff --git a/CS1527/assessment-2/assessment2.py b/CS1527/assessment-2/assessment2.py new file mode 100644 index 0000000..dd23f2d --- /dev/null +++ b/CS1527/assessment-2/assessment2.py @@ -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() diff --git a/CS1527/assessment-2/shell.nix b/CS1527/assessment-2/shell.nix new file mode 100644 index 0000000..f37bbad --- /dev/null +++ b/CS1527/assessment-2/shell.nix @@ -0,0 +1,3 @@ +{ pkgs ? import {} }: pkgs.mkShell { + packages = [(pkgs.python312.withPackages (p: with p; [ python-lsp-server pytest pytest-watch black ])) pkgs.nodePackages.pyright]; +}