diff --git a/.travis.yml b/.travis.yml index baca1385..7129bf8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,8 @@ script: - nosetests --cover-branches --with-coverage . - pycodestyle --max-line-length=120 . - python ./../pycalc_checker.py + - cd - + # added + - cd final_task + - python -m unittest - cd - \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..6eb27988 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# pycalc + +[![Build Status](https://travis-ci.org/siarhiejkresik/Epam-2019-Python-Homework.svg?branch=master)](https://travis-ci.org/siarhiejkresik/Epam-2019-Python-Homework) +Python Programming Language Foundation Hometask (EPAM, 2019). +For task description see [link](https://github.com/siarhiejkresik/Epam-2019-Python-Homework/tree/master/final_task). + +`pycalc` is a command-line calculator implemented in pure Python 3 using Top Down Operator Precedence parsing algorithm (Pratt parser). It receives mathematical expression string as an argument and prints evaluated result. + +## Features + +`pycalc` supports: + +- arithmetic operations (`+`, `-`, `*`, `/`, `//`, `%`, `^` (`^` is a power)); +- comparison operations (`<`, `<=`, `==`, `!=`, `>=`, `>`); +- 2 built-in python functions: `abs` and `round`; +- all functions and constants from standard python module `math` (trigonometry, logarithms, etc.); +- functions and constants from the modules provided with `-m` or `--use-modules` command-line option; +- exit with non-zero exit code on errors. + +## How to install + +1. `git clone ` +2. `cd /final_task/` +3. `pip3 install --user .` or `sudo -H pip3 install .` + +## Examples + +### Command line interface: + +```shell +$ pycalc --help +usage: pycalc [-h] EXPRESSION [-m MODULE [MODULE ...]] + +Pure-python command-line calculator. + +positional arguments: + EXPRESSION expression string to evaluate + +optional arguments: + -h, --help show this help message and exit + -m MODULE [MODULE ...], --use-modules MODULE [MODULE ...] + additional modules to use +``` + +### Calculation: + +```shell +$ pycalc '2+2*2' +6 + +$ pycalc '2+sin(pi)^(2-cos(e))' +2.0 +``` + +```shell +$ pycalc '5+3<=1' +False +``` + +```shell +$ pycalc 'e + pi + tau' +12.143059789228424 + +$ pycalc '1 + inf' +inf + +$ pycalc '1 - inf' +-inf + +$ pycalc 'inf - inf' +nan + +$ pycalc 'nan == nan' +False +``` + +### Errors: + +```shell +$ pycalc '15*(25+1' +ERROR: syntax error +15*(25+1 + ^ +$ pycalc 'func' +ERROR: syntax error +func +^ +$ pycalc '10 + 1/0 -3' +ERROR: division by zero +10 + 1/0 -3 + ^ +$ pycalc '1 + sin(1,2) - 2' +ERROR: sin() takes exactly one argument (2 given) +1 + sin(1,2) - 2 + ^ +$ pycalc '10^10^10' +ERROR: math range error +10^10^10 + ^ +$ pycalc '(-1)^0.5' +ERROR: math domain error +(-1)^0.5 + ^ +$ pycalc '' +ERROR: empty expression provided + +$ pycalc '1514' -m fake calendar nonexistent time +ERROR: no module(s) named fake, nonexistent +``` + +### Additional modules: + +```python +# my_module.py +def sin(number): + return 42 +``` + +```shell +$ pycalc 'sin(pi/2)' +1.0 +$ pycalc 'sin(pi/2)' -m my_module +42 +$ pycalc 'THURSDAY' -m calendar +3 +$ pycalc 'sin(pi/2) - THURSDAY * 10' -m my_module calendar +12 +``` + +## References + +- https://en.wikipedia.org/wiki/Pratt_parser +- https://tdop.github.io/ +- http://www.oilshell.org/blog/2017/03/31.html +- https://engineering.desmos.com/articles/pratt-parser diff --git a/final_task/Makefile b/final_task/Makefile new file mode 100644 index 00000000..f9254729 --- /dev/null +++ b/final_task/Makefile @@ -0,0 +1,34 @@ +.DEFAULT_GOAL := run + +PROGRAMM_NAME := pycalc +SRC_FOLDER := $(PROGRAMM_NAME) +INSTALLED_EXECUTABLE_DIR := ~/.local/bin + +PYTHON := python3 + +PIP := pip3 +PIP_LOCAL := --user + +run: + $(INSTALLED_EXECUTABLE_DIR)/$(PROGRAMM_NAME) + +run-source: + $(PYTHON) -m $(SRC_FOLDER) + +install: + @echo "Installing..." + $(PIP) install $(PIP_LOCAL) . + +install-dev: + @echo "Installing in the development mode..." + $(PIP) install $(PIP_LOCAL) --editable . + +uninstall: + @echo "Uninstalling..." + $(PIP) uninstall $(PROGRAMM_NAME) -y + +show: + $(PIP) show $(PROGRAMM_NAME) + +pycodestyle: + pycodestyle $(PROGRAMM_NAME)/* diff --git a/final_task/pycalc/__init__.py b/final_task/pycalc/__init__.py new file mode 100644 index 00000000..650d27d8 --- /dev/null +++ b/final_task/pycalc/__init__.py @@ -0,0 +1,6 @@ +""" +Pure-python implementation of a command-line calculator. + +Receives mathematical expression string as an argument +and prints evaluated result. +""" diff --git a/final_task/pycalc/__main__.py b/final_task/pycalc/__main__.py new file mode 100644 index 00000000..61c1b3f4 --- /dev/null +++ b/final_task/pycalc/__main__.py @@ -0,0 +1,18 @@ +""" +Pure-python implementation of a command-line calculator. + +Receives mathematical expression string as an argument +and prints evaluated result. +""" + +from pycalc.cli import Cli + + +def main(): + """Pure-python implementation of a command-line calculator.""" + + Cli().run() + + +if __name__ == "__main__": + main() diff --git a/final_task/pycalc/args.py b/final_task/pycalc/args.py new file mode 100644 index 00000000..1cb49c47 --- /dev/null +++ b/final_task/pycalc/args.py @@ -0,0 +1,47 @@ +""" +Parse command-line options. +""" + +import argparse + +PARSER = { + 'description': 'Pure-python command-line calculator.' +} + +EXPRESSION = { + 'name_or_flags': ['expression'], + 'keyword_arguments': { + 'metavar': 'EXPRESSION', + 'type': str, + 'help': 'expression string to evaluate' + } +} + +MODULE = { + 'name_or_flags': ['-m', '--use-modules'], + 'keyword_arguments': { + 'metavar': 'MODULE', + 'type': str, + 'nargs': '+', + 'help': 'additional modules to use', + 'dest': 'modules' + } +} + +ARGUMENTS = ( + EXPRESSION, + MODULE, +) + + +def get_args(): + """Parse command line arguments.""" + + parser = argparse.ArgumentParser(**PARSER) + + for arg in ARGUMENTS: + parser.add_argument(*arg['name_or_flags'], **arg['keyword_arguments']) + + args = parser.parse_args() + + return args diff --git a/final_task/pycalc/calculator/__init__.py b/final_task/pycalc/calculator/__init__.py new file mode 100644 index 00000000..2f7e268f --- /dev/null +++ b/final_task/pycalc/calculator/__init__.py @@ -0,0 +1,10 @@ +""" +The calculator package. +""" + +from .calculator import calculator +from .errors import ( + CalculatorError, + CalculatorInitializationError, + CalculatorCalculationError +) diff --git a/final_task/pycalc/calculator/calculator.py b/final_task/pycalc/calculator/calculator.py new file mode 100644 index 00000000..780976b7 --- /dev/null +++ b/final_task/pycalc/calculator/calculator.py @@ -0,0 +1,102 @@ +""" +Initialization of a calculator. Return a calculator instance. +""" + +from pycalc.lexer import Lexer +from pycalc.parser import Parser, ParserGenericError +from pycalc.importer import ModuleImportErrors + +from .formatters import err_msg_with_ctx_formatter, err_modules_import_formatter +from .errors import CalculatorCalculationError, CalculatorInitializationError, get_err_msg +from .importer import build_modules_registry +from .matchers import build_matchers +from .messages import ( + CALCULATOR_INITIALIZATION_ERROR, + CANT_PARSE_EXPRESSION, + EMPTY_EXPRESSION_PROVIDED, +) +from .precedence import Precedence +from .specification import build_specification + + +class Calculator: + """ + The calculator class. + + Provide a method to calculate an expression from a string. + """ + + def __init__(self, parser): + self._parser = parser + + def calculate(self, expression): + """ + Calculate an expression. + + Return result of calculation or raise a `CalculatorCalculationError` exception + if calculation fails. + """ + + # empty expression + if not expression: + raise CalculatorCalculationError(EMPTY_EXPRESSION_PROVIDED) + + # calculate an expression + try: + result = self._parser.parse(expression) + return result + + # handle calculation errors + except ParserGenericError as exc_wrapper: + + # ’unwrap’ an original exception and get context + exc = exc_wrapper.__cause__ + ctx = exc.ctx if hasattr(exc, 'ctx') else exc_wrapper.ctx + + # an error message + err_msg = get_err_msg(exc) + err_msg = err_msg_with_ctx_formatter(err_msg, ctx) + + # handle all other errors + except Exception as exc: + err_msg = CANT_PARSE_EXPRESSION + + raise CalculatorCalculationError(err_msg) + + +def calculator(modules_names=None): + """ + Initialize a calculator and return a Calculator instance. + + Raise a `CalculatorInitializationError` exception when initialization fails.""" + + try: + # import constants and functions from default and requested modules + modules_registry = build_modules_registry(modules_names) + + # build lexemes matchers + matchers = build_matchers(modules_registry) + + # create a lexer + lexer = Lexer(matchers) + + # build a specification for a parser + spec = build_specification(modules_registry) + + # create a parser + power = Precedence.DEFAULT + parser = Parser(spec, lexer, power) + + # create a calculator + calculator_ = Calculator(parser) + + return calculator_ + + except ModuleImportErrors as exc: + modules_names = exc.modules_names + err_msg = err_modules_import_formatter(modules_names) + + except Exception: + err_msg = CALCULATOR_INITIALIZATION_ERROR + + raise CalculatorInitializationError(err_msg) diff --git a/final_task/pycalc/calculator/errors.py b/final_task/pycalc/calculator/errors.py new file mode 100644 index 00000000..0dacaff5 --- /dev/null +++ b/final_task/pycalc/calculator/errors.py @@ -0,0 +1,53 @@ +""" +Provides functions to compose an error message +according to an exception type. +""" + +from pycalc.parser import errors as parser_err +from pycalc.specification import errors as spec_err +from .parselets import errors as parselet_err + +from .messages import SYNTAX_ERROR, CANT_PARSE_EXPRESSION + +SYNTAX_ERROR_EXCEPIONS = ( + parser_err.ParserNoTokenReceived, + parser_err.ParserExpectedTokenAbsent, + parser_err.ParserSourceNotExhausted, + spec_err.NudDenotationError, + spec_err.LedDenotationError +) + + +class CalculatorError(Exception): + """Raise on calculator’s errors.""" + + def __init__(self, err_msg): + super().__init__() + self.err_msg = err_msg + + +class CalculatorInitializationError(CalculatorError): + """Raise on calculator’s initialization errors.""" + + +class CalculatorCalculationError(CalculatorError): + """Raise on calculator’s calculation errors.""" + + +def get_err_msg(exc): + """Return an error message according to an exception type.""" + + # for function calls and operator applying errors + if isinstance(exc, parselet_err.CallError): + # get the error message of the original exception + err_msg = str(exc.__cause__) + + # for exceptions that mean a syntax error + elif isinstance(exc, SYNTAX_ERROR_EXCEPIONS): + err_msg = SYNTAX_ERROR + + # for all others exception + else: + err_msg = CANT_PARSE_EXPRESSION + + return err_msg diff --git a/final_task/pycalc/calculator/formatters.py b/final_task/pycalc/calculator/formatters.py new file mode 100644 index 00000000..f0b1e57e --- /dev/null +++ b/final_task/pycalc/calculator/formatters.py @@ -0,0 +1,37 @@ +""" +Provides functions for string formatting. +""" + +from .messages import ( + ERROR_PLACE_INDICATOR, + MODULES_IMPORT_ERROR, +) + + +def ctx_formatter(ctx): + """ + Return a two-line string with a source in the first line + and a sign in the second one which indicate + a place where an error occured. + """ + + source = ctx.source + pos = ctx.pos + + return '{}\n{}{}'.format(source, ' ' * (pos), ERROR_PLACE_INDICATOR) + + +def err_msg_with_ctx_formatter(msg, ctx): + """Return an error message with context information.""" + + ctx_msg = ctx_formatter(ctx) + + return f'{msg}\n{ctx_msg}' + + +def err_modules_import_formatter(modules_names): + """Return an error message for module imports errors.""" + + modules_names = ', '.join(modules_names) + + return f'{MODULES_IMPORT_ERROR} {modules_names}' diff --git a/final_task/pycalc/calculator/importer.py b/final_task/pycalc/calculator/importer.py new file mode 100644 index 00000000..82b111b0 --- /dev/null +++ b/final_task/pycalc/calculator/importer.py @@ -0,0 +1,50 @@ +""" +Collect maps of module member’s names +to module member’s objects of specified types. +""" + + +from functools import partial +from types import BuiltinFunctionType, FunctionType, LambdaType + +from pycalc.importer import collect_members_by_type, import_modules + + +NUMERIC_TYPES = (int, float, complex) +FUNCTION_TYPES = (BuiltinFunctionType, FunctionType, LambdaType, partial) + +DEFAULT_MODULE_NAMES = ('math',) +DEFAULT_FUNCTIONS = {'abs': abs, 'round': round} + + +def is_numeric(obj) -> bool: + """Return `True` if a object is one of numeric types.""" + + return isinstance(obj, (NUMERIC_TYPES)) + + +def is_function(obj) -> bool: + """Return `True` if a object is a function.""" + + return isinstance(obj, (FUNCTION_TYPES)) + + +def build_modules_registry(modules_names): + """ + Collect maps of module member’s names + to module member’s objects of specified types. + """ + + if not modules_names: + modules_names = tuple() + + modules = import_modules(DEFAULT_MODULE_NAMES, modules_names) + + functions = collect_members_by_type(modules, + is_function, + predefined=DEFAULT_FUNCTIONS) + + constants = collect_members_by_type(modules, + is_numeric) + + return {"functions": functions, 'constants': constants} diff --git a/final_task/pycalc/calculator/matchers.py b/final_task/pycalc/calculator/matchers.py new file mode 100644 index 00000000..92dcc466 --- /dev/null +++ b/final_task/pycalc/calculator/matchers.py @@ -0,0 +1,57 @@ +""" +Create and register matchers for token types. +""" + +import re + +from pycalc.matcher import Matchers + +from .tokens.types import TokenType +from .tokens.lexemes import PREDEFINED + + +NUMBER = r""" +( # integers or numbers with a fractional part: 13, 154., 3.44, ... +\d+ # an integer part: 10, 2, 432, ... +(\.\d*)* # a fractional part: .2, .43, .1245, ... or dot: . +) +| +( # numbers that begin with a dot: .12, .59, ... +\.\d+ # a fractional part: .2, .43, .1245, ... +) +""" + +NUMBER_REGEX = re.compile(NUMBER, re.VERBOSE) + + +def build_matchers(imports_registry): + """Create and register matchers for token types.""" + + matchers = Matchers() + + # create matchers for token types with dynamically created lexemes + + numeric_matcher = matchers.create_from.compiled_regex(NUMBER_REGEX) + + functions_matcher = matchers.create_from.literals_list( + imports_registry['functions'].keys()) + + constants_matcher = matchers.create_from.literals_list( + imports_registry['constants'].keys()) + + # register matchers for token types with dynamically created lexemes + matchers.register_matcher(TokenType.NUMERIC, numeric_matcher) + matchers.register_matcher(TokenType.FUNCTION, functions_matcher) + matchers.register_matcher(TokenType.CONSTANT, constants_matcher) + + # sort predefined lexemes map by lexeme length in reversed order + lexemes_map = sorted(PREDEFINED.items(), + key=lambda kv: len(kv[1]), + reverse=True) + + # create and register matchers for predefined lexemes + for token_type, lexeme in lexemes_map: + matchers.register_matcher(token_type, + matchers.create_from.literals_list([lexeme])) + + return matchers diff --git a/final_task/pycalc/calculator/messages.py b/final_task/pycalc/calculator/messages.py new file mode 100644 index 00000000..6093a84e --- /dev/null +++ b/final_task/pycalc/calculator/messages.py @@ -0,0 +1,11 @@ +""" +Text constants for calculator messages. +""" + +CALCULATOR_INITIALIZATION_ERROR = 'calculator initialization error' +CANT_PARSE_EXPRESSION = 'can’t parse this expression' +EMPTY_EXPRESSION_PROVIDED = 'empty expression provided' +MODULES_IMPORT_ERROR = 'no module(s) named' +SYNTAX_ERROR = 'syntax error' + +ERROR_PLACE_INDICATOR = '^' diff --git a/final_task/pycalc/calculator/parselets/__init__.py b/final_task/pycalc/calculator/parselets/__init__.py new file mode 100644 index 00000000..562141a1 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/__init__.py @@ -0,0 +1,27 @@ +""" +Parselets for a calculator. +""" + +from .constant import Constant +from .function import Function +from .number import Number +from .operators import (UnaryPrefix, + UnaryPostfix, + BinaryInfixLeft, + BinaryInfixRight) +from .group import GroupedExpressionStart, GroupedExpressionEnd +from .punctuation import Comma + + +__all__ = [ + 'BinaryInfixLeft', + 'BinaryInfixRight', + 'Comma', + 'Constant', + 'Function', + 'GroupedExpressionEnd', + 'GroupedExpressionStart', + 'Number', + 'UnaryPostfix', + 'UnaryPrefix', +] diff --git a/final_task/pycalc/calculator/parselets/base.py b/final_task/pycalc/calculator/parselets/base.py new file mode 100644 index 00000000..39b49a8a --- /dev/null +++ b/final_task/pycalc/calculator/parselets/base.py @@ -0,0 +1,13 @@ +""" +The generic parselet class. +""" + + +class Base(): + """The generic parselet class.""" + + def __init__(self, power): + self.power = power + + def __repr__(self): + return self.__class__.__name__ diff --git a/final_task/pycalc/calculator/parselets/constant.py b/final_task/pycalc/calculator/parselets/constant.py new file mode 100644 index 00000000..537c6d15 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/constant.py @@ -0,0 +1,31 @@ +""" +The parselet class for constants. +""" + +from .base import Base + + +class Constant(Base): + """The parselet class for constants.""" + + def __init__(self, power, const_registry): + super().__init__(power) + self.const_registry = const_registry + + def nud(self, parser, token): + """""" + + const = self.const(token) + + return const + + def const(self, token): + """""" + + const_name = token.lexeme + try: + const = self.const_registry[const_name] + except KeyError: + raise Exception(f"$'{const_name}' constant is not registered") + + return const diff --git a/final_task/pycalc/calculator/parselets/errors.py b/final_task/pycalc/calculator/parselets/errors.py new file mode 100644 index 00000000..8c7305a6 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/errors.py @@ -0,0 +1,16 @@ +""" +The exception class for function calling. +""" + + +class CallError(Exception): + """ + Raise when a function call throw an exception. + + Wrap built-in exceptions like `ArithmeticError`, + `OverflowError`, etc. + """ + + def __init__(self, ctx): + super().__init__() + self.ctx = ctx diff --git a/final_task/pycalc/calculator/parselets/function.py b/final_task/pycalc/calculator/parselets/function.py new file mode 100644 index 00000000..c07fec25 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/function.py @@ -0,0 +1,56 @@ +""" +The parselet class for functions. +""" + +from .base import Base +from .helpers import call + + +class Function(Base): + """The parselet class for functions.""" + + def __init__(self, power, func_registry, start, stop, sep): + super().__init__(power) + self.func_registry = func_registry + self.start = start + self.stop = stop + self.sep = sep + + def nud(self, parser, token): + """""" + + ctx = parser.context(previous=True) + + parser.advance(self.start) + args = self.args(parser) + parser.advance(self.stop) + + func = self.func(token) + result = call(ctx, func, args) + + return result + + def args(self, parser): + """Advance a parser and collect an arguments list.""" + + args = [] + + if not parser.peek_and_check(self.stop): + while True: + args.append(parser.expression()) + if not parser.peek_and_check(self.sep): + break + parser.advance(self.sep) + + return args + + def func(self, token): + """Get a function from the function registry by a function name.""" + + func_name = token.lexeme + try: + func = self.func_registry[func_name] + except KeyError: + raise Exception(f"$'{func_name}' function is not registered") + + return func diff --git a/final_task/pycalc/calculator/parselets/group.py b/final_task/pycalc/calculator/parselets/group.py new file mode 100644 index 00000000..39c47509 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/group.py @@ -0,0 +1,30 @@ +""" +The parselet classes for grouped expressions. +""" + +from .base import Base + + +class GroupedExpressionStart(Base): + """A grouped expression start parselet.""" + + def __init__(self, power, right_pair): + super().__init__(power) + self.right_pair = right_pair + + def nud(self, parser, token): + """""" + + expr = parser.expression() + parser.advance(self.right_pair) + + return expr + + +class GroupedExpressionEnd(Base): + """A grouped expression end parselet.""" + + def led(self, parser, token, left): + """""" + + return parser.expression(self.power) diff --git a/final_task/pycalc/calculator/parselets/helpers.py b/final_task/pycalc/calculator/parselets/helpers.py new file mode 100644 index 00000000..0b71c44a --- /dev/null +++ b/final_task/pycalc/calculator/parselets/helpers.py @@ -0,0 +1,16 @@ +""" +Provides helper functions. +""" + +from .errors import CallError + + +def call(ctx, func, args): + """Call a function with given arguments.""" + + try: + result = func(*args) + except Exception as exc: + raise CallError(ctx) from exc + + return result diff --git a/final_task/pycalc/calculator/parselets/number.py b/final_task/pycalc/calculator/parselets/number.py new file mode 100644 index 00000000..fdcb3cee --- /dev/null +++ b/final_task/pycalc/calculator/parselets/number.py @@ -0,0 +1,19 @@ +""" +The parselet class for numbers. +""" + +from .base import Base + + +class Number(Base): + """The parselet class for numbers.""" + + def nud(self, parser, token): + """""" + + try: + value = int(token.lexeme) + except ValueError: + value = float(token.lexeme) + + return value diff --git a/final_task/pycalc/calculator/parselets/operators.py b/final_task/pycalc/calculator/parselets/operators.py new file mode 100644 index 00000000..819c8014 --- /dev/null +++ b/final_task/pycalc/calculator/parselets/operators.py @@ -0,0 +1,74 @@ +""" +The parselet classes for operations. +""" + +from .base import Base +from .helpers import call + + +class Operator(Base): + """The generic parselet class for operations.""" + + def __init__(self, power, func): + super().__init__(power) + self.func = func + + def ctx(self, parser): + """Get parsing context.""" + + return parser.context(previous=True) + + +class UnaryPrefix(Operator): + """The parselet class for unary prefix operations.""" + + def nud(self, parser, token): + """""" + + ctx = self.ctx(parser) + + right = parser.expression(self.power) + result = call(ctx, self.func, (right,)) + + return result + + +class UnaryPostfix(Operator): + """The parselet class for unary postfix operations.""" + + def led(self, parser, token, left): + """""" + + ctx = self.ctx(parser) + + result = call(ctx, self.func, (left,)) + + return result + + +class BinaryInfixLeft(Operator): + """The parselet class for binary prefix operations.""" + + def led(self, parser, token, left): + """""" + + ctx = self.ctx(parser) + + right = parser.expression(self.power) + result = call(ctx, self.func, (left, right)) + + return result + + +class BinaryInfixRight(Operator): + """The parselet class for binary infix operations.""" + + def led(self, parser, token, left): + """""" + + ctx = self.ctx(parser) + + right = parser.expression(self.power - 1) + result = call(ctx, self.func, (left, right)) + + return result diff --git a/final_task/pycalc/calculator/parselets/punctuation.py b/final_task/pycalc/calculator/parselets/punctuation.py new file mode 100644 index 00000000..6db880ca --- /dev/null +++ b/final_task/pycalc/calculator/parselets/punctuation.py @@ -0,0 +1,14 @@ +""" +The punctuation parselets. +""" + +from .base import Base + + +class Comma(Base): + """The parselet class for comma.""" + + def nud(self, parser, token): + """""" + + return parser.expression(self.power) diff --git a/final_task/pycalc/calculator/precedence.py b/final_task/pycalc/calculator/precedence.py new file mode 100644 index 00000000..04af20eb --- /dev/null +++ b/final_task/pycalc/calculator/precedence.py @@ -0,0 +1,75 @@ +""" +The operator precedence. +""" + +from enum import IntEnum + + +class Precedence(IntEnum): + """ + The operator precedence according to the operator precedence in Python. + + https://docs.python.org/3/reference/expressions.html#operator-precedence + """ + DEFAULT = 0 + + # Lambda expression + LAMBDA = 10 # lambda + # Conditional expression + CONDITIONAL_EXPRESSION = 20 # if – else + # Boolean OR + BOOLEAN_OR = 30 # or + # Boolean AND + BOOLEAN_AND = 40 # and + # Boolean NOT + BOOLEAN_NOT = 50 # not x + + # Comparisons, including membership tests and identity tests + COMPARISONS = 60 # <, <= , > , >= , != , == + MEMBERSHIP_TESTS = 60 # in, not in + IDENTITY_TESTS = 60 # is, is not + + # Bitwise XOR + BITWISE_XOR = 70 # ^ + # Bitwise OR + BITWISE_OR = 80 # | + # Bitwise AND + BITWISE_AND = 90 # & + # Shifts + SHIFTS = 100 # <<, >> + + # Addition and subtraction + ADDITION = 110 # + + SUBTRACTION = 110 # - + + # Multiplication, matrix multiplication, division, floor division, remainder + MULTIPLICATION = 120 # * + MATRIX_MULTIPLICATION = 120 # @ + DIVISION = 120 # / + FLOOR_DIVISION = 120 # // + REMAINDER = 120 # % + + # Positive, negative, bitwise NOT + POSITIVE = 130 # +x + NEGATIVE = 130 # -x + BITWISE_NOT = 130 # ~x + + # Exponentiation + EXPONENTIATION = 140 # ^ + # EXPONENTIATION = 140 # ** + + # Await expression + AWAIT = 150 # await x + + # Subscription, slicing, call, attribute reference + SUBSCRIPTION = 160 # x[index], + SLICING = 160 # x[index:index], + CALL = 160 # x(arguments...), + ATTRIBUTE_REFERENCE = 160 # x.attribute + + # Binding or tuple display, list display, dictionary display, set display + BINDING = 170 + TUPLE = 170 # (expressions...), + LIST = 170 # [expressions...], + DICTIONARY = 170 # {key: value...}, + SET = 170 # {expressions...} diff --git a/final_task/pycalc/calculator/specification.py b/final_task/pycalc/calculator/specification.py new file mode 100644 index 00000000..85ca0b20 --- /dev/null +++ b/final_task/pycalc/calculator/specification.py @@ -0,0 +1,122 @@ +""" +Initialization of parser specification. +""" + +import operator +from math import pow as math_pow + +from pycalc.specification import Specification + +from .parselets import * +from .precedence import Precedence +from .tokens.types import TokenType + + +def build_specification(registry): + """Initialize a parser specification.""" + + spec = Specification() + + # NUMBERS + spec.nud.register(TokenType.NUMERIC, Number, + power=Precedence.DEFAULT) + + # FUNCTIONS + spec.nud.register(TokenType.FUNCTION, Function, + power=Precedence.CALL, + func_registry=registry['functions'], + start=TokenType.LEFT_PARENTHESIS, + stop=TokenType.RIGHT_PARENTHESIS, + sep=TokenType.COMMA) + + # CONSTANTS + spec.nud.register(TokenType.CONSTANT, Constant, + power=Precedence.DEFAULT, + const_registry=registry['constants']) + + # OPERATIONS + + # positive + spec.nud.register(TokenType.ADD, UnaryPrefix, + power=Precedence.POSITIVE, + func=lambda x: +x) + # negation + spec.nud.register(TokenType.SUB, UnaryPrefix, + power=Precedence.NEGATIVE, + func=lambda x: -x) + + # addition + spec.led.register(TokenType.ADD, BinaryInfixLeft, + power=Precedence.ADDITION, + func=operator.add) + # subtraction + spec.led.register(TokenType.SUB, BinaryInfixLeft, + power=Precedence.SUBTRACTION, + func=operator.sub) + + # multiplication + spec.led.register(TokenType.MUL, BinaryInfixLeft, + power=Precedence.MULTIPLICATION, + func=operator.mul) + + # division + spec.led.register(TokenType.TRUEDIV, BinaryInfixLeft, + power=Precedence.DIVISION, + func=operator.truediv) + + # floor division + spec.led.register(TokenType.FLOORDIV, BinaryInfixLeft, + power=Precedence.FLOOR_DIVISION, + func=operator.floordiv) + + # exponentiation + spec.led.register(TokenType.POW, BinaryInfixRight, + power=Precedence.EXPONENTIATION, + func=math_pow) + + # remainder + spec.led.register(TokenType.MOD, BinaryInfixLeft, + power=Precedence.REMAINDER, + func=operator.mod) + + # equal + spec.led.register(TokenType.EQ, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.eq) + # not equal + spec.led.register(TokenType.NE, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.ne) + + # greater + spec.led.register(TokenType.GT, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.gt) + + # equal or greater + spec.led.register(TokenType.GE, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.ge) + + # less + spec.led.register(TokenType.LT, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.lt) + + # equal or less + spec.led.register(TokenType.LE, BinaryInfixLeft, + power=Precedence.COMPARISONS, + func=operator.le) + + # PUNCTUATION + spec.nud.register(TokenType.LEFT_PARENTHESIS, GroupedExpressionStart, + power=Precedence.BINDING, + right_pair=TokenType.RIGHT_PARENTHESIS) + + spec.led.register(TokenType.RIGHT_PARENTHESIS, GroupedExpressionEnd, + power=Precedence.DEFAULT) + + spec.led.register(TokenType.COMMA, Comma, + power=Precedence.DEFAULT) + + return spec diff --git a/final_task/pycalc/calculator/tokens/__init__.py b/final_task/pycalc/calculator/tokens/__init__.py new file mode 100644 index 00000000..f8b74f44 --- /dev/null +++ b/final_task/pycalc/calculator/tokens/__init__.py @@ -0,0 +1,3 @@ +""" +Tokens package. Provides token types and predefined lexemes. +""" diff --git a/final_task/pycalc/calculator/tokens/lexemes.py b/final_task/pycalc/calculator/tokens/lexemes.py new file mode 100644 index 00000000..535dcee6 --- /dev/null +++ b/final_task/pycalc/calculator/tokens/lexemes.py @@ -0,0 +1,53 @@ +""" +Lexemes. +""" + +from itertools import chain + +from .types import TokenType + + +PREDEFINED = { + # common operators + TokenType.ADD: '+', + TokenType.SUB: '-', + TokenType.MUL: '*', + TokenType.TRUEDIV: '/', + TokenType.FLOORDIV: '//', + TokenType.POW: '^', + TokenType.MOD: '%', + + # comparison operators + TokenType.EQ: '==', + TokenType.NE: '!=', + TokenType.LT: '<', + TokenType.LE: '<=', + TokenType.GT: '>', + TokenType.GE: '>=', + + # built-in functions + TokenType.ABS: 'abs', + TokenType.ROUND: 'round', + + # punctuation + TokenType.COMMA: ',', + TokenType.LEFT_PARENTHESIS: '(', + TokenType.RIGHT_PARENTHESIS: ')', +} + +# lexemes for this token types are created dynamically at runtime +DYNAMIC = ( + TokenType.NUMERIC, + TokenType.FUNCTION, + TokenType.CONSTANT, +) + +ALL = (PREDEFINED, DYNAMIC) + + +# Simple import time validations (TODO: not for production, to refactor) + +# all non-dynamically created token types should have lexemes +for token_type in TokenType: + if token_type not in chain(*ALL): + raise Exception(f'no lexeme is specified for {token_type}') diff --git a/final_task/pycalc/calculator/tokens/types.py b/final_task/pycalc/calculator/tokens/types.py new file mode 100644 index 00000000..61b4343d --- /dev/null +++ b/final_task/pycalc/calculator/tokens/types.py @@ -0,0 +1,40 @@ +""" +Token types specification. +""" + + +from enum import Enum, auto + + +class TokenType(Enum): + """Token types specification.""" + + NUMERIC = auto() # 0 1 2.3 4. .5, ... (TODO: 3.14e-10 3.14j ?) + CONSTANT = auto() # e, pi, ... + FUNCTION = auto() # abs, sin, ... + + # operators + ADD = auto() # + + SUB = auto() # - + MUL = auto() # * + TRUEDIV = auto() # / + FLOORDIV = auto() # // + POW = auto() # ^ + MOD = auto() # % + + # comparison operators + EQ = auto() # == + NE = auto() # != + LT = auto() # < + LE = auto() # <= + GT = auto() # > + GE = auto() # > + + # built-in functions + ABS = auto() # abs + ROUND = auto() # round + + # punctuation + COMMA = auto() # , + LEFT_PARENTHESIS = auto() # ( + RIGHT_PARENTHESIS = auto() # ) diff --git a/final_task/pycalc/cli.py b/final_task/pycalc/cli.py new file mode 100644 index 00000000..9e169f94 --- /dev/null +++ b/final_task/pycalc/cli.py @@ -0,0 +1,88 @@ +""" +Initialize a calculator and calculate +an expression from a command line argument. +""" + +import sys + +from pycalc.args import get_args +from pycalc.calculator import calculator, CalculatorError + +ERROR_MSG_PREFIX = 'ERROR: ' + + +class Cli: + """Command line interface for a calculator.""" + + def __init__(self): + self.args = get_args() + self.calculator = None + + def run(self): + """Initialize a calculator and make a calculation.""" + + modules = self.args.modules + self.init_calculator(modules) + + expression = self.args.expression + self.calculate(expression) + + def init_calculator(self, modules): + """Initialize a calculator.""" + + try: + self.calculator = calculator(modules) + return + + except CalculatorError as exc: + err_msg = exc.err_msg + self.on_error(err_msg) + + def calculate(self, expression): + """Make a calculation.""" + + assert callable( + self.calculator.calculate), 'calculate is not a callable' + + try: + result = self.calculator.calculate(expression) + self.on_success(result) + return + + except CalculatorError as exc: + err_msg = exc.err_msg + + except Exception as exc: + err_msg = str(exc) + + self.on_error(err_msg) + + def on_success(self, result): + """Run if a calculation was succesfull.""" + + self.exit(result) + + def on_error(self, err_msg): + """Run if there were initialization or calculation errors.""" + + message = self.prefix_err_msg(err_msg) + self.exit(message, is_error=True) + + def exit(self, message, is_error=False): + """Print a message and exit.""" + + print(message) + + if is_error: + sys.exit(1) + + sys.exit() + + def prefix_err_msg(self, msg): + """Return an error message with an error prefix.""" + + return f'{ERROR_MSG_PREFIX}{msg}' + + +if __name__ == "__main__": + Cli().run() diff --git a/final_task/pycalc/importer/__init__.py b/final_task/pycalc/importer/__init__.py new file mode 100644 index 00000000..b056eec8 --- /dev/null +++ b/final_task/pycalc/importer/__init__.py @@ -0,0 +1,7 @@ +""" +The importer package provides functions for +modules importing and module members collecting. +""" + +from .importer import collect_members_by_type, import_modules +from .errors import ModuleImportErrors diff --git a/final_task/pycalc/importer/errors.py b/final_task/pycalc/importer/errors.py new file mode 100644 index 00000000..cc7ac360 --- /dev/null +++ b/final_task/pycalc/importer/errors.py @@ -0,0 +1,15 @@ +""" +Exceptions for the importer module. +""" + + +class ModuleImportErrors(ModuleNotFoundError): + """Raise when at least one of module imports failed.""" + + def __init__(self, module_names): + super().__init__() + self.modules_names = module_names + + def __str__(self): + + return f"no module named {', '.join(self.modules_names)}" diff --git a/final_task/pycalc/importer/importer.py b/final_task/pycalc/importer/importer.py new file mode 100644 index 00000000..0883fcc9 --- /dev/null +++ b/final_task/pycalc/importer/importer.py @@ -0,0 +1,67 @@ +""" +Functions for modules importing and module members collecting. +""" + +from collections import OrderedDict +from importlib import import_module +from inspect import getmembers +from itertools import chain + +from .errors import ModuleImportErrors + + +UNDERSCORE = '_' + + +def iter_uniq(iterables): + """Returns a generator that iterates over unique elements of iterables.""" + + return (key for key in OrderedDict.fromkeys(chain(*iterables))) + + +def import_modules(*iterables): + """ + Return a list of imported modules. + + Raise an `ModuleImportErrors` exception if at least one of imports fails. + """ + + modules = [] + failed_imports = [] + + for module_name in iter_uniq(iterables): + + try: + module = import_module(module_name) + modules.append(module) + except ModuleNotFoundError: + failed_imports.append(module_name) + + if failed_imports: + raise ModuleImportErrors(failed_imports) + + return modules + + +def module_members_by_type(module, type_checker, skip_underscored=True): + """ + Create a generator over tuples of names and + members of a certain type in a module. + """ + + for name, member in getmembers(module, type_checker): + if skip_underscored and name.startswith(UNDERSCORE): + continue + yield name, member + + +def collect_members_by_type(modules, type_checker, skip_underscored=True, predefined=None): + """Collect members of modules by types into an dictionary.""" + + accumulator = dict(predefined) if predefined else {} + + for module in modules: + for name, member in module_members_by_type(module, type_checker, skip_underscored): + accumulator[name] = member + + return accumulator diff --git a/final_task/pycalc/lexer/__init__.py b/final_task/pycalc/lexer/__init__.py new file mode 100644 index 00000000..8f762a95 --- /dev/null +++ b/final_task/pycalc/lexer/__init__.py @@ -0,0 +1,5 @@ +""" +Lexer performs lexical analysis of a source. +""" + +from .lexer import Lexer diff --git a/final_task/pycalc/lexer/lexer.py b/final_task/pycalc/lexer/lexer.py new file mode 100644 index 00000000..329b5178 --- /dev/null +++ b/final_task/pycalc/lexer/lexer.py @@ -0,0 +1,106 @@ +""" +Lexer class. +""" + +import re +from collections import namedtuple + + +Token = namedtuple("Token", ("token_type", "lexeme")) +LexerContext = namedtuple('LexerContext', ('source', 'pos')) + +WHITESPACES = re.compile(r"\s+") + + +class Lexer: + """Represents a lexer.""" + + def __init__(self, matchers): + self.matchers = matchers + self.source = '' + self.pos = 0 + self.prev_pos = 0 + self.length = 0 + + # a wrapper for caching the last peeked token + self._token_wrapper = [] + + def init(self, source): + """Init a lexer with a source string.""" + + self.source = source + self.pos = 0 + self.prev_pos = 0 + self.length = len(source) + self._token_wrapper.clear() + + def context(self, previous=False): + """Return a lexer context.""" + + pos = self.prev_pos if previous else self.pos + + return LexerContext(self.source, pos) + + def is_source_exhausted(self): + """Return `True` if the position pointer is out of the source string.""" + + assert(self.pos) >= 0 + + return self.pos >= self.length + + def peek(self): + """Return the next token without advancing the pointer position.""" + + if self._token_wrapper: + return self._token_wrapper[-1] + + token = self._next_token() + self._token_wrapper.append(token) + + return token + + def consume(self): + """Return the next token and advance the pointer position.""" + + token = self.peek() + self._token_wrapper.pop() + if token: + self._advance_pos_by_lexeme(token.lexeme) + + return token + + def _next_token(self): + """Skip whitespaces and return the next token.""" + + self._skip_whitespaces() + token = self._match() + + return token + + def _match(self): + """Try to match the next token. Return a `Token` instance or `None`.""" + + for token_type, matcher in self.matchers: + lexeme = matcher(self.source, self.pos) + + if not lexeme: + continue + + token = Token(token_type, lexeme) + + return token + + def _skip_whitespaces(self): + """Skip whitespaces and advance the position index + to the first non whitespace symbol.""" + + whitespaces = WHITESPACES.match(self.source, self.pos) + if whitespaces: + self._advance_pos_by_lexeme(whitespaces.group()) + + def _advance_pos_by_lexeme(self, lexeme): + """Advance the position index by lexeme length.""" + + value = len(lexeme) + self.prev_pos = self.pos + self.pos += value diff --git a/final_task/pycalc/matcher/__init__.py b/final_task/pycalc/matcher/__init__.py new file mode 100644 index 00000000..718ce07e --- /dev/null +++ b/final_task/pycalc/matcher/__init__.py @@ -0,0 +1,7 @@ +""" +Matcher packages provides a matchers container +with methods for creating matchers from list of +literals or a regex object. +""" + +from .matcher import Matchers diff --git a/final_task/pycalc/matcher/creator.py b/final_task/pycalc/matcher/creator.py new file mode 100644 index 00000000..08803795 --- /dev/null +++ b/final_task/pycalc/matcher/creator.py @@ -0,0 +1,21 @@ +""" +Matcher creator class holds functions for matchers creation. +""" + +from .helpers import regex_matcher, construct_regex + + +class MatcherCreator: + """Matcher creator class holds functions for matchers creation.""" + + @staticmethod + def compiled_regex(regex): + """Create a matcher from compiled regex object.""" + + return regex_matcher(regex) + + @staticmethod + def literals_list(literals): + """Create a matcher from a list of literals.""" + + return regex_matcher(construct_regex(literals)) diff --git a/final_task/pycalc/matcher/helpers.py b/final_task/pycalc/matcher/helpers.py new file mode 100644 index 00000000..62a356ff --- /dev/null +++ b/final_task/pycalc/matcher/helpers.py @@ -0,0 +1,48 @@ +""" +Helpers function for building matchers. +""" + +import re + + +def list_sorted_by_length(iterable) -> list: + """ + Return a sorted list from iterable. + + Result list is sorted by length in reversed order. + """ + + return sorted(iterable, key=len, reverse=True) + + +def construct_literals_list(literals): + """Return a list of literals sorted by length in reversed order.""" + + sorted_literals = list_sorted_by_length(literals) + return sorted_literals + + +def construct_regex(literals): + """Return compiled regex object for a list of string literals.""" + + literals_list = construct_literals_list(literals) + regex_string = '|'.join(map(re.escape, literals_list)) + regex = re.compile(regex_string) + + return regex + + +def regex_matcher(regex): + """Return a regex matcher function.""" + + def matcher(string, pos): + """ + Return a substring that match a string from + a given position or `None` if there are no matches. + """ + + result = regex.match(string, pos) + if result: + return result.group() + + return matcher diff --git a/final_task/pycalc/matcher/matcher.py b/final_task/pycalc/matcher/matcher.py new file mode 100644 index 00000000..e293b745 --- /dev/null +++ b/final_task/pycalc/matcher/matcher.py @@ -0,0 +1,30 @@ +""" +Matchers class. +""" + +from collections import namedtuple + +from .creator import MatcherCreator + + +Matcher = namedtuple("Matcher", ("token_type", "matcher")) + + +class Matchers: + """ + Matchers is an iterable container for matchers with methods + for creating matchers from literals list or regex. + """ + + def __init__(self): + self.matchers = [] + self.create_from = MatcherCreator + + def __iter__(self): + for matcher in self.matchers: + yield matcher + + def register_matcher(self, token_type, matcher): + """Register a matcher with a corresponding token type.""" + + self.matchers.append(Matcher(token_type, matcher)) diff --git a/final_task/pycalc/parser/__init__.py b/final_task/pycalc/parser/__init__.py new file mode 100644 index 00000000..8c351fd5 --- /dev/null +++ b/final_task/pycalc/parser/__init__.py @@ -0,0 +1,15 @@ +""" +Parser package provides a Parser class for +top down operator precedence parcing (Pratt parser). + +https://en.wikipedia.org/wiki/Pratt_parser +""" + +from .parser import Parser +from .errors import ( + ParserGenericError, + ParserSyntaxError, + ParserNoTokenReceived, + ParserExpectedTokenAbsent, + ParserSourceNotExhausted +) diff --git a/final_task/pycalc/parser/errors.py b/final_task/pycalc/parser/errors.py new file mode 100644 index 00000000..c6385d7d --- /dev/null +++ b/final_task/pycalc/parser/errors.py @@ -0,0 +1,45 @@ +""" +Exceptions for the parser package. +""" + +GENERIC_PARSER_ERROR = 'encountered an error while parsing' +GENERIC_PARSER_SYNTAX_ERROR = 'syntax error' + + +class ParserGenericError(SyntaxError): + """A generic exception for a parser.""" + + def __init__(self, ctx): + super().__init__() + self.ctx = ctx + + def __str__(self): + return GENERIC_PARSER_ERROR + + +class ParserSyntaxError(ParserGenericError): + """A generic parser exception that represents a syntax error.""" + + def __str__(self): + return GENERIC_PARSER_SYNTAX_ERROR + + +class ParserNoTokenReceived(ParserGenericError): + """Raise when a lexer returns `None` instead of `Token` object.""" + + pass + + +class ParserExpectedTokenAbsent(ParserGenericError): + """Raise when a token is not of a given type.""" + + pass + + +class ParserSourceNotExhausted(ParserGenericError): + """ + Raise when a parser finished parsing a source + but a source is not parsed completely. + """ + + pass diff --git a/final_task/pycalc/parser/parser.py b/final_task/pycalc/parser/parser.py new file mode 100644 index 00000000..51124027 --- /dev/null +++ b/final_task/pycalc/parser/parser.py @@ -0,0 +1,134 @@ +""" +Parser package provides a Parser class for +top down operator precedence parsing (Pratt parser). +""" + + +from .errors import ( + ParserExpectedTokenAbsent, + ParserNoTokenReceived, + ParserGenericError, + ParserSourceNotExhausted +) + + +class Parser: + """Parser class for top down operator precedence parsing (Pratt parser).""" + + def __init__(self, spec, lexer, default_power): + self.spec = spec + self.lexer = lexer + self.default_power = default_power + + def parse(self, source): + """Parse a source and return a result of parsing.""" + + self.lexer.init(source) + + try: + result = self._parse() + except Exception as exc: + raise ParserGenericError(self.context()) from exc + + return result + + def _parse(self): + """ + The inner parsing function. + + Splitted from the main parsing function to allow + catching all parsing error exceptions in one place. + """ + + result = self.expression() + + if not self.lexer.is_source_exhausted(): + raise ParserSourceNotExhausted(self.context()) + + return result + + def expression(self, power=None): + """The main parsing function of Pratt parser.""" + + token = self.consume() + if not token: + raise ParserNoTokenReceived(self.context()) + + left = self._nud(token) + + while True: + token = self.peek() + if not token: + break + + if self._right_power(power) >= self._left_power(token): + break + + self.consume() + left = self._led(token, left) + + return left + + def consume(self): + """Return the next token and advance the source position pointer.""" + + return self.lexer.consume() + + def peek(self): + """Return the next token without advancing the source position pointer.""" + + return self.lexer.peek() + + def advance(self, token_type=None): + """ + Consume a next token if that one is of given type. + + Raise an `ParserExpectedTokenAbsent` exception if types don’t match. + """ + + token = self.peek() + + if not token or ( + token_type and + not token.token_type == token_type + ): + raise ParserExpectedTokenAbsent(self.context()) + + self.consume() + + def peek_and_check(self, token_type): + """Check if the next token is of given type.""" + + token = self.peek() + if not token or token.token_type != token_type: + return False + + return True + + def context(self, previous=False): + """Return parsing context.""" + + return self.lexer.context(previous) + + def _nud(self, token): + """""" + + return self.spec.nud.eval(self, token) + + def _led(self, token, left): + """""" + + return self.spec.led.eval(self, token, left) + + def _right_power(self, power): + """Return token binding power.""" + + if power is not None: + return power + + return self.default_power + + def _left_power(self, token): + """Get token binding power.""" + + return self.spec.led.power(self, token) diff --git a/final_task/pycalc/specification/__init__.py b/final_task/pycalc/specification/__init__.py new file mode 100644 index 00000000..6d70f385 --- /dev/null +++ b/final_task/pycalc/specification/__init__.py @@ -0,0 +1,11 @@ +""" +Specification for top down operator +precedence parsing (Pratt parser). +""" + +from .specification import Specification +from .errors import ( + DuplicatedTokenType, + NudDenotationError, + LedDenotationError +) diff --git a/final_task/pycalc/specification/denotation.py b/final_task/pycalc/specification/denotation.py new file mode 100644 index 00000000..0d2d30bf --- /dev/null +++ b/final_task/pycalc/specification/denotation.py @@ -0,0 +1,39 @@ +""" +Provides the base class for nud- and led-denotation classes. +""" + +from .errors import DuplicatedTokenType, ParseletNotRegistered + + +class Denotation: + """ + The base class for nud- and led-denotation classes. + """ + + def __init__(self): + self.registry = {} + + def register(self, token_type, parselet, **kwargs): + """Register a parselet for a given token type.""" + + self._check_for_dup(token_type) + self.registry[token_type] = parselet(**kwargs) + + def _get_parselet(self, token): + """Find and return a parselet for a given token type.""" + + token_type = token.token_type + try: + parselet = self.registry[token_type] + except KeyError: + raise ParseletNotRegistered(token_type) + + return parselet + + def _check_for_dup(self, token_type): + """ + Check if a givent token type is already registered. + """ + + if token_type in self.registry: + raise DuplicatedTokenType(token_type) diff --git a/final_task/pycalc/specification/errors.py b/final_task/pycalc/specification/errors.py new file mode 100644 index 00000000..cdc24bb3 --- /dev/null +++ b/final_task/pycalc/specification/errors.py @@ -0,0 +1,54 @@ +""" +Exceptions for the specification package. +""" + + +def denotation_err_msg(token, denotation_type): + """Return a formatted message for denotation error excepcions.""" + + return f'{token.token_type} is not registered or can’t be in the {denotation_type}-position.' + + +class DuplicatedTokenType(Exception): + """Raise when a token type is already registered in a registry.""" + + def __init__(self, token_type): + super().__init__() + self.token_type = token_type + + +class ParseletNotRegistered(Exception): + """Raise when there is no registered parselet for a given token type.""" + + def __init__(self, token_type): + super().__init__() + self.token_type = token_type + + +class DenotationError(SyntaxError): + """The generic exception class for denotation errors.""" + + def __init__(self, ctx, token): + super().__init__() + self.ctx = ctx + self.token = token + + +class NudDenotationError(DenotationError): + """ + Raise when a token type is not registered in a specification + or can’t be in the nud-position. + """ + + def __str__(self): + return denotation_err_msg(self.token, 'nud') + + +class LedDenotationError(DenotationError): + """ + Raise when a token type is not registered in a specification + or can’t be in the led-position. + """ + + def __str__(self): + return denotation_err_msg(self.token, 'led') diff --git a/final_task/pycalc/specification/led.py b/final_task/pycalc/specification/led.py new file mode 100644 index 00000000..8772b603 --- /dev/null +++ b/final_task/pycalc/specification/led.py @@ -0,0 +1,41 @@ +""" +Left-Denotation. +""" + +from .denotation import Denotation +from .errors import LedDenotationError, ParseletNotRegistered + + +class Led(Denotation): + """ + Left-Denotation. + + The specification of how an operator consumes to the right with a left-context. + """ + + def power(self, parser, token): + """Return power for a given token.""" + + parselet = self._parselet(parser, token) + power = parselet.power + + return power + + def eval(self, parser, token, left): + """Receive from left, evaluate and return result.""" + + parselet = self._parselet(parser, token) + result = parselet.led(parser, token, left) + + return result + + def _parselet(self, parser, token): + """Find and return a stored parselet for a given token type.""" + + try: + parselet = super()._get_parselet(token) + except ParseletNotRegistered: + ctx = parser.context() + raise LedDenotationError(ctx, token) + + return parselet diff --git a/final_task/pycalc/specification/nud.py b/final_task/pycalc/specification/nud.py new file mode 100644 index 00000000..c18b6493 --- /dev/null +++ b/final_task/pycalc/specification/nud.py @@ -0,0 +1,27 @@ +""" +Null-Denotation. +""" + +from .denotation import Denotation +from .errors import NudDenotationError, ParseletNotRegistered + + +class Nud(Denotation): + """ + Null-Denotation. + + The specification of how an operator consumes to the right with no left-context. + """ + + def eval(self, parser, token): + """Evaluate and return result.""" + + try: + parselet = self._get_parselet(token) + except ParseletNotRegistered: + ctx = parser.context(previous=True) + raise NudDenotationError(ctx, token) + + result = parselet.nud(parser, token) + + return result diff --git a/final_task/pycalc/specification/specification.py b/final_task/pycalc/specification/specification.py new file mode 100644 index 00000000..3b263dcb --- /dev/null +++ b/final_task/pycalc/specification/specification.py @@ -0,0 +1,14 @@ +""" +A generic parser specification. +""" + +from .led import Led +from .nud import Nud + + +class Specification: + """Holds nud and led specifications.""" + + def __init__(self): + self.nud = Nud() + self.led = Led() diff --git a/final_task/setup.py b/final_task/setup.py index e69de29b..0ecb6811 100644 --- a/final_task/setup.py +++ b/final_task/setup.py @@ -0,0 +1,26 @@ +from os import path +from setuptools import setup, find_packages + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.md')) as f: + long_description = f.read() + +setup( + name='pycalc', + version='0.1.0', + description='A pure-python command-line calculator', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/siarhiejkresik/PythonHomework', + packages=find_packages(), + author='Siarhiej Kresik', + author_email='siarhiej.kresik@gmail.com', + keywords='calculator calc cli', + python_requires='>=3.6', + entry_points={ + "console_scripts": [ + "pycalc=pycalc.__main__:main", + ] + }, +) diff --git a/final_task/tests/__init__.py b/final_task/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/final_task/tests/integration/__init__.py b/final_task/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/final_task/tests/unit/__init__.py b/final_task/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/final_task/tests/unit/lexer/__init__.py b/final_task/tests/unit/lexer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/final_task/tests/unit/lexer/test_lexer.py b/final_task/tests/unit/lexer/test_lexer.py new file mode 100644 index 00000000..30618845 --- /dev/null +++ b/final_task/tests/unit/lexer/test_lexer.py @@ -0,0 +1,99 @@ +""" +Test a lexer. +""" + +import unittest + +from pycalc.lexer.lexer import Lexer + + +class LexerTestCase(unittest.TestCase): + """""" + + def test_class_init(self): + """Test the class init method""" + + matchers = ['1'] + lexer = Lexer(matchers) + + self.assertEqual(lexer.matchers, matchers) + self.assertEqual(lexer.source, '') + self.assertEqual(lexer.pos, 0) + self.assertEqual(lexer.prev_pos, 0) + self.assertEqual(lexer.length, 0) + + def test_is_source_exhausted(self): + """""" + + test_cases = ( + (0, 0, True), + (3, 3, True), + (1, 2, False), + (2, 1, True), + ) + + for pos, length, expected_result in test_cases: + with self.subTest(pos=pos, + length=length, + expected_result=expected_result + ): + lexer = Lexer([]) + lexer.pos = pos + lexer.length = length + self.assertEqual(lexer.is_source_exhausted(), + expected_result) + + @unittest.skip('not implemented') + def test_peek(self): + pass + + @unittest.skip('not implemented') + def test_consume(self): + pass + + @unittest.skip('not implemented') + def test__next_token(self): + pass + + @unittest.skip('not implemented') + def test__match(self): + pass + + def test__skip_whitespaces(self): + """""" + + test_cases = ( + (' bcd', 0, 1), + (' cd', 0, 2), + ('0 d', 1, 3), + (' ', 0, 1), + ('', 0, 0), + ('\n\tc', 0, 2), + ) + + for source, pos, expected_pos in test_cases: + with self.subTest(source=source, + pos=pos, + expected_pos=expected_pos): + lexer = Lexer([]) + lexer.init(source) + lexer.pos = pos + lexer._skip_whitespaces() + self.assertEqual(lexer.pos, expected_pos) + + def test__advance_pos_by_lexeme(self): + """""" + + lexeme = 'abcde' + pos = 10 + expected_pos = pos + len(lexeme) + + lexer = Lexer([]) + lexer.pos = pos + + lexer._advance_pos_by_lexeme(lexeme) + self.assertEqual(lexer.pos, expected_pos) + + +if __name__ == '__main__': + unittest.main()