14 Commits

Author SHA1 Message Date
790b836bd8 Mention splat operators as missing features in README
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-14 13:51:44 -08:00
c6bcb05ac6 Run tests in CI pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-13 23:27:14 -08:00
f48f76d9f8 Add CI badge to README
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-11-13 19:04:16 -08:00
c35c88244f Add CI pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci/woodpecker/release/woodpecker Pipeline was successful
2024-11-13 19:01:01 -08:00
7a55ee0777 Allow calling arbitrary values 2024-11-13 15:30:50 -08:00
fb81eb84a7 Implement getattr operation 2024-11-13 13:05:25 -08:00
2278a16ca0 Move index handling from expression parsing into value parsing 2024-11-13 12:34:53 -08:00
ab9a7bb34e Add unit tests 2024-11-12 23:39:13 -08:00
fbd62512d1 Fix issue where position was retained across Lexer instances 2024-11-11 14:55:02 -08:00
4098c0e25a Fix scanning of multiline operators with '<' or '/' 2024-11-11 00:08:02 -08:00
58b45e6cac Implement indices 2024-11-10 21:11:14 -08:00
0dcacdc04a Implement function calls 2024-11-10 12:12:37 -08:00
e4d273e10f Fix parameter name in README 2024-11-10 07:32:15 +00:00
a82533d34e Update pyproject.toml 2024-11-09 23:26:00 -08:00
11 changed files with 743 additions and 66 deletions

45
.woodpecker.yml Normal file
View File

@@ -0,0 +1,45 @@
labels:
platform: linux/amd64
clone:
git:
image: woodpeckerci/plugin-git
settings:
tags: true
steps:
test:
image: python
commands:
- python -m unittest
when:
event: [tag, pull_request]
build:
image: python
commands:
- python -m pip install build
- python -m build
when:
event: tag
publish:
image: gitea.elara.ws/music-kraken/plugin-twine
settings:
username: __token__
password:
from_secret: pypi_token
when:
event: tag
release:
image: woodpeckerci/plugin-release
settings:
title: "Version ${CI_COMMIT_TAG}"
files:
- 'dist/hisscl-*.whl'
- 'dist/hisscl-*.tar.gz'
api_key:
from_secret: gitea_token
when:
event: tag

View File

@@ -2,6 +2,8 @@
A [HashiCorp Config Language](https://github.com/hashicorp/hcl) parser for Python
[![status-badge](https://ci.elara.ws/api/badges/63/status.svg)](https://ci.elara.ws/repos/63)
## Usage
For most simple use-cases, you can use the `load*` convenience functions:
@@ -27,7 +29,7 @@ with open('test.hcl', 'r') as fl:
cfg = hisscl.load(fl, name=fl.name)
```
Each `load*` function has an optional `vars: dict[str, Any]` parameter, whose elements are used as variables in your config file. For example, if you have `x = y + 1`, `y` must be defined in `globals`.
Each `load*` function has an optional `vars: dict[str, Any]` parameter, whose elements are used as variables in your config file. For example, if you have `x = y + 1`, `y` must be defined in `vars`.
For more advanced use-cases, `lexer`, `parser`, `ast`, and `interp` submodules are provided.
@@ -48,6 +50,6 @@ Currently, this parser supports all HCL features except:
- [For Expressions](https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#for-expressions)
- [Templates](https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#templates)
- [Function Calls](https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#functions-and-function-calls)
- [Splat Operators](https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md#splat-operators)
Support for these features is planned.

View File

@@ -30,13 +30,21 @@ class String:
pos: Position
value: str
Literal = Integer | Float | Bool | String
@dataclasses.dataclass
class VariableRef:
pos: Position
name: str
Literal = Integer | Float | Bool | String | VariableRef
@dataclasses.dataclass
class FunctionCall:
pos: Position
value: 'Value'
args: list['Value']
Ref = VariableRef | FunctionCall
@dataclasses.dataclass
class Tuple:
pos: Position
@@ -67,8 +75,25 @@ class UnaryExpression:
op: Operator
value: 'Value'
Expression = BinaryExpression | UnaryExpression
Value = Literal | Collection | Expression
@dataclasses.dataclass
class Expansion:
pos: Position
value: 'Value'
@dataclasses.dataclass
class Index:
pos: Position
value: 'Value'
index: 'Value'
@dataclasses.dataclass
class GetAttr:
pos: Position
value: 'Value'
attr: str
Expression = BinaryExpression | UnaryExpression | Expansion | Index | GetAttr
Value = Literal | Collection | Expression | Ref
@dataclasses.dataclass
class Assignment:

View File

@@ -4,9 +4,9 @@ from . import parser
import typing
import io
__all__ = ['TypeError', 'Block', 'Interp']
__all__ = ['OperandError', 'Block', 'Interp']
class TypeError(Exception):
class OperandError(Exception):
def __init__(self, pos: ast.Position, action: str, issue: str, val: typing.Any):
super().__init__(f'{pos}: cannot perform {action} on {issue} operand ({type(val).__name__})')
@@ -15,10 +15,9 @@ class Block(dict):
self.labels = labels
super().__init__()
class Interp:
vars: dict[str, typing.Any] = {}
class Interp:
def __init__(self, stream: typing.TextIO, name: str):
self.vars: dict[str, typing.Any] = {}
self.parser = parser.Parser(stream, name)
def __setitem__(self, key, val):
@@ -33,11 +32,27 @@ class Interp:
def update(self, vars: dict[str, typing.Any]):
self.vars.update(vars)
def _eval_index(self, index: ast.Index) -> typing.Any:
val = self._convert_value(index.value)
if not hasattr(val, '__getitem__'):
raise ValueError(f'{index.value.pos}: value is not indexable ({type(val).__name__})')
index_val = self._convert_value(index.index)
if type(index_val) is int and hasattr(val, '__len__') and index_val >= len(val):
raise IndexError(f'{index.index.pos}: index out of range ({index_val} with length {len(val)})')
elif type(index_val) is not int:
if isinstance(val, list) or isinstance(val, tuple) or isinstance(val, str):
raise TypeError(f'{index.index.pos}: {type(val).__name__} indices must be integers, not {type(index_val).__name__}')
elif hasattr(val, '__contains__') and index_val not in val:
raise IndexError(f'{index.index.pos}: index {repr(index_val)} does not exist in value')
return val[index_val]
def _convert_value(self, val: ast.Value) -> typing.Any:
if isinstance(val, ast.VariableRef):
if val.name not in self.vars:
raise KeyError(f'{val.pos}: no such variable: {repr(val.name)}')
return self.vars[val.name]
elif isinstance(val, ast.FunctionCall):
return self._exec_func_call(val)
elif isinstance(val, ast.Literal):
return val.value
elif isinstance(val, ast.Tuple):
@@ -48,6 +63,15 @@ class Interp:
return self._eval_binary_expr(val)
elif isinstance(val, ast.UnaryExpression):
return self._eval_unary_expr(val)
elif isinstance(val, ast.Index):
return self._eval_index(val)
elif isinstance(val, ast.GetAttr):
obj = self._convert_value(val.value)
if not hasattr(obj, val.attr):
raise AttributeError(f'{val.pos}: no such attribute {repr(val.attr)} in object of type {type(obj).__name__}')
return getattr(obj, val.attr)
elif isinstance(val, ast.Expansion):
raise ValueError(f'{val.pos}: cannot use expansion operator outside of a function call')
def _is_numerical(self, val: typing.Any) -> bool:
return isinstance(val, float | int) and type(val) is not bool
@@ -55,16 +79,31 @@ class Interp:
def _is_comparable(self, val: typing.Any) -> bool:
return self._is_numerical(val) or isinstance(val, str)
def _exec_func_call(self, call: ast.FunctionCall) -> typing.Any:
val = self._convert_value(call.value)
if not callable(val):
raise ValueError(f'{call.pos}: cannot call non-callable object')
args = []
for arg in call.args:
if isinstance(arg, ast.Expansion):
arg_val = self._convert_value(arg.value)
if not isinstance(arg_val, typing.Iterable):
raise ValueError(f"{arg.pos}: cannot perform expansion on non-iterable value ({type(arg_val).__name__})")
args.extend(arg_val)
else:
args.append(self._convert_value(arg))
return val(*args)
def _eval_unary_expr(self, expr: ast.UnaryExpression) -> float | int | bool:
val = self._convert_value(expr.value)
match expr.op.value:
case '!':
if type(val) is not bool:
raise TypeError(expr.value.pos, 'NOT operation', 'non-boolean', val)
raise OperandError(expr.value.pos, 'NOT operation', 'non-boolean', val)
return not val
case '-':
if not self._is_numerical(val):
raise TypeError(expr.value.pos, 'negation', 'non-numerical', val)
raise OperandError(expr.value.pos, 'negation', 'non-numerical', val)
return -val
case _:
raise ValueError(f'{expr.op.pos}: unknown unary operation: {repr(expr.op.value)}')
@@ -80,69 +119,69 @@ class Interp:
return left != right
case '+':
if not self._is_numerical(left):
raise TypeError(expr.left.pos, 'addition operation', 'non-numerical', left)
raise OperandError(expr.left.pos, 'addition operation', 'non-numerical', left)
elif not self._is_numerical(right):
raise TypeError(expr.right.pos, 'addition operation', 'non-numerical', right)
raise OperandError(expr.right.pos, 'addition operation', 'non-numerical', right)
return left + right
case '-':
if not self._is_numerical(left):
raise TypeError(expr.left.pos, 'subtraction operation', 'non-numerical', left)
raise OperandError(expr.left.pos, 'subtraction operation', 'non-numerical', left)
elif not self._is_numerical(right):
raise TypeError(expr.right.pos, 'subtraction operation', 'non-numerical', right)
raise OperandError(expr.right.pos, 'subtraction operation', 'non-numerical', right)
return left - right
case '*':
if not self._is_numerical(left):
raise TypeError(expr.left.pos, 'multiplication operation', 'non-numerical', left)
raise OperandError(expr.left.pos, 'multiplication operation', 'non-numerical', left)
elif not self._is_numerical(right):
raise TypeError(expr.right.pos, 'multiplication operation', 'non-numerical', right)
raise OperandError(expr.right.pos, 'multiplication operation', 'non-numerical', right)
return left * right
case '/':
if not self._is_numerical(left):
raise TypeError(expr.left.pos, 'division operation', 'non-numerical', left)
raise OperandError(expr.left.pos, 'division operation', 'non-numerical', left)
elif not self._is_numerical(right):
raise TypeError(expr.right.pos, 'division operation', 'non-numerical', right)
raise OperandError(expr.right.pos, 'division operation', 'non-numerical', right)
return left / right
case '%':
if not self._is_numerical(left):
raise TypeError(expr.left.pos, 'modulo operation', 'non-numerical', left)
raise OperandError(expr.left.pos, 'modulo operation', 'non-numerical', left)
elif not self._is_numerical(right):
raise TypeError(expr.right.pos, 'modulo operation', 'non-numerical', right)
raise OperandError(expr.right.pos, 'modulo operation', 'non-numerical', right)
return left % right
case '>':
if not self._is_comparable(left):
raise TypeError(expr.left.pos, 'comparison', 'non-comparable', left)
raise OperandError(expr.left.pos, 'comparison', 'non-comparable', left)
elif not self._is_comparable(right):
raise TypeError(expr.right.pos, 'comparison', 'non-comparable', right)
raise OperandError(expr.right.pos, 'comparison', 'non-comparable', right)
return left > right
case '<':
if not self._is_comparable(left):
raise TypeError(expr.left.pos, 'comparison', 'non-comparable', left)
raise OperandError(expr.left.pos, 'comparison', 'non-comparable', left)
elif not self._is_comparable(right):
raise TypeError(expr.right.pos, 'comparison', 'non-comparable', right)
raise OperandError(expr.right.pos, 'comparison', 'non-comparable', right)
return left < right
case '<=':
if not self._is_comparable(left):
raise TypeError(expr.left.pos, 'comparison', 'non-comparable', left)
raise OperandError(expr.left.pos, 'comparison', 'non-comparable', left)
elif not self._is_comparable(right):
raise TypeError(expr.right.pos, 'comparison', 'non-comparable', right)
raise OperandError(expr.right.pos, 'comparison', 'non-comparable', right)
return left <= right
case '>=':
if not self._is_comparable(left):
raise TypeError(expr.left.pos, 'comparison', 'non-comparable', left)
raise OperandError(expr.left.pos, 'comparison', 'non-comparable', left)
elif not self._is_comparable(right):
raise TypeError(expr.right.pos, 'comparison', 'non-comparable', right)
raise OperandError(expr.right.pos, 'comparison', 'non-comparable', right)
return left >= right
case '||':
if type(left) is not bool:
raise TypeError(expr.left.pos, 'OR operation', 'non-boolean', left)
raise OperandError(expr.left.pos, 'OR operation', 'non-boolean', left)
elif type(right) is not bool:
raise TypeError(expr.right.pos, 'OR operation', 'non-boolean', right)
raise OperandError(expr.right.pos, 'OR operation', 'non-boolean', right)
return left or right
case '&&':
if type(left) is not bool:
raise TypeError(expr.left.pos, 'AND operation', 'non-boolean', left)
raise OperandError(expr.left.pos, 'AND operation', 'non-boolean', left)
elif type(right) is not bool:
raise TypeError(expr.right.pos, 'AND operation', 'non-boolean', right)
raise OperandError(expr.right.pos, 'AND operation', 'non-boolean', right)
return left and right
case _:
raise ValueError(f'{expr.op.pos}: unknown binary operation: {repr(expr.op.value)}')

View File

@@ -24,6 +24,8 @@ class Token(enum.Enum):
COMMA = 11
COLON = 12
OPERATOR = 13
ELLIPSIS = 14
DOT = 15
class ExpectedError(Exception):
def __init__(self, pos: ast.Position, expected: str, got: str):
@@ -33,15 +35,19 @@ class ExpectedError(Exception):
self.expected = expected
class Lexer:
pos = ast.Position()
prev_pos = ast.Position()
unread = ''
def __init__(self, stream: typing.TextIO, name: str):
self.pos = pos = ast.Position()
self.prev_pos = ast.Position()
self.unread = ''
self.stream = stream
self.pos.name = name
def _pos(self) -> ast.Position:
return dataclasses.replace(self.pos)
def _peek(self, n: int) -> str:
if self.unread != '':
return self.unread
pos = self.stream.tell()
text = self.stream.read(n)
self.stream.seek(pos)
@@ -172,7 +178,6 @@ class Lexer:
return Token.HEREDOC, pos, out.getvalue()
# TODO: scan multi-char operators like ==
def _scan_operator(self, char) -> tuple[Token, ast.Position, str]:
pos = dataclasses.replace(self.pos)
with io.StringIO() as out:
@@ -180,32 +185,31 @@ class Lexer:
out.write(char)
char = self._read()
self._unread(char)
val = out.getvalue()
return Token.OPERATOR, pos, out.getvalue()
def scan(self) -> tuple[Token, ast.Position, str]:
def scan(self) -> tuple[Token, ast.Position, str]:
char = self._read()
while is_whitespace(char):
char = self._read()
match char:
case '{' | '}':
return Token.CURLY, self.pos, char
return Token.CURLY, self._pos(), char
case '[' | ']':
return Token.SQUARE, self.pos, char
return Token.SQUARE, self._pos(), char
case '(' | ')':
return Token.PAREN, self.pos, char
return Token.PAREN, self._pos(), char
case ',':
return Token.COMMA, self.pos, char
return Token.COMMA, self._pos(), char
case ':':
return Token.COLON, self.pos, char
return Token.COLON, self._pos(), char
case '"':
return self._scan_str()
case '<':
# If the next character is not another less than symbol,
# this is probably a less than operator.
if self._peek(1) != '<':
return Token.OPERATOR, self.pos, char
return self._scan_operator(char)
return self._scan_heredoc(char)
case '/':
next = self._peek(1)
@@ -221,13 +225,20 @@ class Lexer:
# If the next character is not another slash
# or an asterisk, this is probably a division
# operator.
return Token.OPERATOR, self.pos, char
return self._scan_operator(char)
case '#':
# Ignore comments and return next token
self._scan_comment(char)
return self.scan()
case '.':
if (next := self._read()) != '.':
self._unread(next)
return Token.DOT, self._pos(), next
elif (next := self._read()) != '.':
raise ExpectedError(self.pos, '.', next)
return Token.ELLIPSIS, self._pos(), "..."
case '':
return Token.EOF, self.pos, char
return Token.EOF, self._pos(), char
if is_numeric(char):
return self._scan_number(char)
@@ -236,7 +247,7 @@ class Lexer:
elif is_operator(char):
return self._scan_operator(char)
return Token.ILLEGAL, self.pos, char
return Token.ILLEGAL, self._pos(), char
def is_whitespace(char: str) -> bool:
return char in (' ', '\t', '\r', '\n')

View File

@@ -15,10 +15,9 @@ class ExpectedError(Exception):
self.expected = expected
class Parser:
_prev: tuple[lexer.Token, ast.Position, str] | None = None
def __init__(self, stream: TextIO, name: str):
self.lexer = lexer.Lexer(stream, name)
self._prev: tuple[lexer.Token, ast.Position, str] | None = None
def _scan(self) -> tuple[lexer.Token, ast.Position, str]:
if self._prev is not None:
@@ -29,7 +28,29 @@ class Parser:
def _unscan(self, tok: lexer.Token, pos: ast.Position, lit: str):
self._prev = tok, pos, lit
def _parse_index(self, val: ast.Value, start_pos: ast.Position) -> ast.Index:
index = ast.Index(pos=start_pos, value=val, index=self._parse_expr())
tok, start_pos, lit = self._scan()
if tok != lexer.Token.SQUARE or lit != ']':
raise ExpectedError(start_pos, 'closing square bracket', lit)
while self.lexer._peek(1) == '[':
_, start_pos, _ = self._scan()
index = self._parse_index(index, start_pos)
return index
def _parse_getattr(self, val: ast.Value, start_pos: ast.Position) -> ast.Index | ast.GetAttr:
tok, pos, lit = self._scan()
if tok == lexer.Token.INTEGER:
return ast.Index(pos=start_pos, value=val, index=ast.Integer(pos=pos, value=int(lit)))
elif tok == lexer.Token.IDENT:
return ast.GetAttr(pos=start_pos, value=val, attr=lit)
else:
raise ExpectedError(pos, 'integer or identifier', lit)
while self.lexer._peek(1) == '.':
_, start_pos, _ = self._scan()
index = self._parse_getattr(index, start_pos)
def _parse_expr(self) -> ast.Value:
left = self._parse_value()
tok, pos, lit = self._scan()
@@ -76,32 +97,66 @@ class Parser:
self._unscan(tok, pos, lit)
return ast.Object(start_pos, items)
def _parse_func_call(self, val: ast.Value, start_pos: ast.Position) -> ast.FunctionCall:
tok, pos, lit = self._scan()
if tok == lexer.Token.PAREN and lit == ')':
out = ast.FunctionCall(pos=start_pos, value=val, args=[])
while self.lexer._peek(1) == '(':
_, start_pos, _ = self._scan()
out = self._parse_func_call(out, start_pos)
return out
self._unscan(tok, pos, lit)
args: list[ast.Value] = []
while True:
args.append(self._parse_expr())
tok, pos, lit = self._scan()
if tok == lexer.Token.PAREN and lit == ')':
break
elif tok == lexer.Token.COMMA:
continue
elif tok == lexer.Token.ELLIPSIS:
args[-1] = ast.Expansion(pos=args[-1].pos, value=args[-1])
tok, pos, lit = self._scan()
if tok != lexer.Token.PAREN or lit != ')':
raise ExpectedError(pos, 'closing parentheses', lit)
break
else:
raise ExpectedError(pos, 'comma or closing parentheses', lit)
out = ast.FunctionCall(pos=start_pos, value=val, args=args)
while self.lexer._peek(1) == '(':
_, start_pos, _ = self._scan()
out = self._parse_func_call(out, start_pos)
return out
def _parse_value(self) -> ast.Value:
out = None
tok, pos, lit = self._scan()
match tok:
case lexer.Token.INTEGER:
return ast.Integer(pos=pos, value=int(lit))
out = ast.Integer(pos=pos, value=int(lit))
case lexer.Token.FLOAT:
return ast.Float(pos=pos, value=float(lit))
out = ast.Float(pos=pos, value=float(lit))
case lexer.Token.BOOL:
return ast.Bool(pos=pos, value=(lit == 'true'))
out = ast.Bool(pos=pos, value=(lit == 'true'))
case lexer.Token.STRING:
return ast.String(pos=pos, value=pyast.literal_eval(lit))
out = ast.String(pos=pos, value=pyast.literal_eval(lit))
case lexer.Token.IDENT:
return ast.VariableRef(pos=pos, name=lit)
out = ast.VariableRef(pos=pos, name=lit)
case lexer.Token.HEREDOC:
return ast.String(pos=pos, value=lit)
out = ast.String(pos=pos, value=lit)
case lexer.Token.OPERATOR:
return ast.UnaryExpression(pos=pos, op=ast.Operator(pos=pos, value=lit), value=self._parse_value())
out = ast.UnaryExpression(pos=pos, op=ast.Operator(pos=pos, value=lit), value=self._parse_value())
case lexer.Token.SQUARE:
if lit != '[':
raise ExpectedError(pos, repr('['), lit)
return self._parse_tuple(pos)
out = self._parse_tuple(pos)
case lexer.Token.CURLY:
if lit != '{':
raise ExpectedError(pos, repr('{'), lit)
return self._parse_object(pos)
out = self._parse_object(pos)
case lexer.Token.PAREN:
if lit != '(':
raise ExpectedError(pos, repr('('), lit)
@@ -109,9 +164,21 @@ class Parser:
tok, pos, lit = self._scan()
if tok != lexer.Token.PAREN or lit != ')':
raise ExpectedError(pos, repr(')'), lit)
return expr
raise ExpectedError(pos, 'value', lit)
out = expr
case _:
raise ExpectedError(pos, 'value', lit)
tok, pos, lit = self._scan()
if tok == lexer.Token.SQUARE and lit == '[':
out = self._parse_index(out, pos)
elif tok == lexer.Token.PAREN and lit == '(':
out = self._parse_func_call(out, pos)
elif tok == lexer.Token.DOT:
out = self._parse_getattr(out, pos)
else:
self._unscan(tok, pos, lit)
return out
def parse(self, until: tuple[lexer.Token, str] = (lexer.Token.EOF, '')) -> ast.AST:
tree = []

View File

@@ -11,7 +11,7 @@
[project]
name = "hisscl"
description = "Python HCL parser"
description = "A Python HCL parser"
dynamic = ["version"]
authors = [{ name = "Elara6331", email = "elara@elara.ws" }]
readme = "README.md"
@@ -29,4 +29,5 @@
]
[project.urls]
Repository = "https://gitea.elara.ws/Elara6331/hisscl"
Repository = "https://gitea.elara.ws/Elara6331/hisscl"
GitHub = "https://github.com/Elara6331/hisscl"

0
test/__init__.py Normal file
View File

158
test/test_interp.py Normal file
View File

@@ -0,0 +1,158 @@
from hisscl import interp
import unittest
import io
class TestBasic(unittest.TestCase):
def test_assignment(self):
cfg = interp.Interp(io.StringIO("x = 26.04"), "TestBasic.test_assignment").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 26.04)
def test_block(self):
cfg = interp.Interp(io.StringIO("x { y = 26.04 }"), "TestBasic.test_block").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], [{'y': 26.04}])
def test_block_labels(self):
cfg = interp.Interp(io.StringIO("x y { z = 26.04 }"), "TestBasic.test_block_labels").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], [{'z': 26.04}])
self.assertEqual(cfg['x'][0].labels, ['y'])
class TestExpressions(unittest.TestCase):
def test_add(self):
cfg = interp.Interp(io.StringIO("x = 123 + 333"), "TestExpressions.test_add").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_sub(self):
cfg = interp.Interp(io.StringIO("x = 456 - 333"), "TestExpressions.test_sub").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 123)
def test_mul(self):
cfg = interp.Interp(io.StringIO("x = 128 * 2"), "TestExpressions.test_mul").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 256)
def test_div(self):
cfg = interp.Interp(io.StringIO("x = 256 / 2"), "TestExpressions.test_div").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 128)
def test_mod(self):
cfg = interp.Interp(io.StringIO("x = 256 % 3"), "TestExpressions.test_mod").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 1)
def test_eq(self):
cfg = interp.Interp(io.StringIO("x = 123 == 456"), "TestExpressions.test_eq").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], False)
def test_ne(self):
cfg = interp.Interp(io.StringIO("x = 123 != 456"), "TestExpressions.test_ne").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], True)
def test_lt(self):
cfg = interp.Interp(io.StringIO("x = 123 < 456"), "TestExpressions.test_lt").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], True)
def test_gt(self):
cfg = interp.Interp(io.StringIO("x = 123 > 456"), "TestExpressions.test_gt").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], False)
def test_le(self):
cfg = interp.Interp(io.StringIO("x = 123 <= 123"), "TestExpressions.test_le").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], True)
def test_ge(self):
cfg = interp.Interp(io.StringIO("x = 1234 >= 123"), "TestExpressions.test_ge").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], True)
def test_or(self):
cfg = interp.Interp(io.StringIO("x = true || false"), "TestExpressions.test_or").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], True)
def test_and(self):
cfg = interp.Interp(io.StringIO("x = true && false"), "TestExpressions.test_and").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], False)
def test_not(self):
cfg = interp.Interp(io.StringIO("x = !true"), "TestExpressions.test_not").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], False)
def test_neg(self):
cfg = interp.Interp(io.StringIO("x = -1"), "TestExpressions.test_neg").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], -1)
class TestRefs(unittest.TestCase):
def test_var(self):
i = interp.Interp(io.StringIO("x = 123 + y"), "TestRefs.test_var")
i['y'] = 333
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_index(self):
i = interp.Interp(io.StringIO('x = y[1]'), "TestRefs.test_index")
i['y'] = [123, 456, 789]
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_multi_index(self):
cfg = interp.Interp(io.StringIO('x = ["123", "456", "789"][1][2]'), "TestRefs.test_multi_index").run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], '6')
def test_index_legacy(self):
i = interp.Interp(io.StringIO('x = y.1'), "TestRefs.test_index_legacy")
i['y'] = [123, 456, 789]
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_getattr(self):
class Y:
z = 123
i = interp.Interp(io.StringIO('x = Y.z'), "TestRefs.test_getattr")
i['Y'] = Y()
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 123)
def test_func(self):
def y(a, b):
return a + b
i = interp.Interp(io.StringIO("x = y(123, 333)"), "TestRefs.test_func")
i['y'] = y
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_func_expansion(self):
def y(a, b):
return a + b
i = interp.Interp(io.StringIO("x = y(z...)"), "TestRefs.test_func_expansion")
i['y'] = y
i['z'] = (123, 333)
cfg = i.run()
self.assertIn('x', cfg)
self.assertEqual(cfg['x'], 456)
def test_call_uncallable(self):
i = interp.Interp(io.StringIO("x = y(123, 333)"), "TestRefs.test_call_uncallable")
i['y'] = 0
with self.assertRaises(ValueError) as ctx:
cfg = i.run()
self.assertIn('cannot call non-callable object', str(ctx.exception))

86
test/test_lexer.py Normal file
View File

@@ -0,0 +1,86 @@
from hisscl import lexer
import unittest
import io
class TestComments(unittest.TestCase):
def test_double_slash(self):
tok, pos, lit = lexer.Lexer(io.StringIO('// this should be ignored'), 'TestComments.test_double_slash').scan()
self.assertEqual(tok, lexer.Token.EOF)
self.assertEqual(lit, '')
def test_pound(self):
tok, pos, lit = lexer.Lexer(io.StringIO('# this should be ignored'), 'TestComments.test_pound').scan()
self.assertEqual(tok, lexer.Token.EOF)
self.assertEqual(lit, '')
def test_multiline(self):
tok, pos, lit = lexer.Lexer(io.StringIO('/* this\nshould\nbe\nignored */'), 'TestComments.test_multiline').scan()
self.assertEqual(tok, lexer.Token.EOF)
self.assertEqual(lit, '')
class TestOperators(unittest.TestCase):
def test_lt(self):
tok, pos, lit = lexer.Lexer(io.StringIO('<'), 'TestOperators.test_lt').scan()
self.assertEqual(tok, lexer.Token.OPERATOR)
self.assertEqual(lit, '<')
def test_div(self):
tok, pos, lit = lexer.Lexer(io.StringIO('/'), 'TestOperators.test_div').scan()
self.assertEqual(tok, lexer.Token.OPERATOR)
self.assertEqual(lit, '/')
def test_multichar(self):
tok, pos, lit = lexer.Lexer(io.StringIO('<='), 'TestOperators.test_multichar').scan()
self.assertEqual(tok, lexer.Token.OPERATOR)
self.assertEqual(lit, '<=')
def test_ellipsis(self):
tok, pos, lit = lexer.Lexer(io.StringIO('...'), 'TestOperators.test_ellipsis').scan()
self.assertEqual(tok, lexer.Token.ELLIPSIS)
self.assertEqual(lit, '...')
def test_invalid_ellipsis(self):
with self.assertRaises(lexer.ExpectedError) as ctx:
lexer.Lexer(io.StringIO('..'), 'TestOperators.test_invalid_ellipsis').scan()
self.assertIn("expected .", str(ctx.exception))
self.assertIn("got EOF", str(ctx.exception))
class TestHeredoc(unittest.TestCase):
def test_heredoc(self):
tok, pos, lit = lexer.Lexer(io.StringIO('<<EOT\nthis\nis\na\nmultiline\nstring\nEOT'), 'TestHeredoc.test_heredoc').scan()
self.assertEqual(tok, lexer.Token.HEREDOC)
self.assertEqual(lit, '\nthis\nis\na\nmultiline\nstring\n')
class TestNumber(unittest.TestCase):
def test_integer(self):
tok, pos, lit = lexer.Lexer(io.StringIO('1234'), 'TestNumber.test_integer').scan()
self.assertEqual(tok, lexer.Token.INTEGER)
self.assertEqual(lit, "1234")
def test_float(self):
tok, pos, lit = lexer.Lexer(io.StringIO('1234.5678'), 'TestNumber.test_float').scan()
self.assertEqual(tok, lexer.Token.FLOAT)
self.assertEqual(lit, "1234.5678")
def test_invalid_float(self):
with self.assertRaises(lexer.ExpectedError) as ctx:
lexer.Lexer(io.StringIO('1.0.0'), 'TestNumber.test_invalid_float').scan()
self.assertIn("expected number", str(ctx.exception))
self.assertIn("got '.'", str(ctx.exception))
class TestString(unittest.TestCase):
def test_basic(self):
tok, pos, lit = lexer.Lexer(io.StringIO('""'), 'TestString.test_basic').scan()
self.assertEqual(tok, lexer.Token.STRING)
self.assertEqual(lit, '""')
def test_escape(self):
tok, pos, lit = lexer.Lexer(io.StringIO('"\\""'), 'TestString.test_escape').scan()
self.assertEqual(tok, lexer.Token.STRING)
self.assertEqual(lit, '"\\""')
def test_newline(self):
with self.assertRaises(lexer.ExpectedError) as ctx:
lexer.Lexer(io.StringIO('"\n"'), 'TestString.test_newline').scan()
self.assertIn("expected '\"'", str(ctx.exception))
self.assertIn("got '\\n'", str(ctx.exception))

243
test/test_parser.py Normal file
View File

@@ -0,0 +1,243 @@
from hisscl import parser, ast
import unittest
import io
class TestLiterals(unittest.TestCase):
def test_integer(self):
val = parser.Parser(io.StringIO('1234'), 'TestLiterals.test_integer')._parse_value()
self.assertIsInstance(val, ast.Integer)
assert type(val) is ast.Integer
self.assertEqual(val.value, 1234)
def test_float(self):
val = parser.Parser(io.StringIO('1234.5678'), 'TestLiterals.test_float')._parse_value()
self.assertIsInstance(val, ast.Float)
assert type(val) is ast.Float
self.assertEqual(val.value, 1234.5678)
def test_string(self):
val = parser.Parser(io.StringIO('"test \\" \\u26a7"'), 'TestLiterals.test_string')._parse_value()
self.assertIsInstance(val, ast.String)
assert type(val) is ast.String
self.assertEqual(val.value, 'test " \u26a7')
def test_bool(self):
val = parser.Parser(io.StringIO('true'), 'TestLiterals.test_bool')._parse_value()
self.assertIsInstance(val, ast.Bool)
assert type(val) is ast.Bool
self.assertEqual(val.value, True)
def test_heredoc(self):
val = parser.Parser(io.StringIO('<<EOT\nthis\nis\na\nmultiline\nstring\nEOT'), 'TestLiterals.test_heredoc')._parse_value()
self.assertIsInstance(val, ast.String)
assert type(val) is ast.String
self.assertEqual(val.value, '\nthis\nis\na\nmultiline\nstring\n')
class TestCollections(unittest.TestCase):
def test_tuple(self):
val = parser.Parser(io.StringIO('[1, 2.0, "3", true]'), 'TestCollections.test_tuple')._parse_value()
self.assertIsInstance(val, ast.Tuple)
assert type(val) is ast.Tuple
self.assertEqual(val.items, [
ast.Integer(
pos = ast.Position(name="TestCollections.test_tuple", line=1, col=2),
value = 1,
),
ast.Float(
pos = ast.Position(name="TestCollections.test_tuple", line=1, col=5),
value = 2.0,
),
ast.String(
pos = ast.Position(name="TestCollections.test_tuple", line=1, col=10),
value = "3",
),
ast.Bool(
pos = ast.Position(name="TestCollections.test_tuple", line=1, col=15),
value = True,
),
])
def test_object(self):
val = parser.Parser(io.StringIO('{true: 2.0, "3": 4, 5.0: "6"}'), 'TestCollections.test_object')._parse_value()
self.assertIsInstance(val, ast.Object)
assert type(val) is ast.Object
self.assertEqual(val.items, [
(
ast.Bool(
pos = ast.Position(name="TestCollections.test_object", line=1, col=2),
value = True,
),
ast.Float(
pos = ast.Position(name="TestCollections.test_object", line=1, col=8),
value = 2.0,
),
),
(
ast.String(
pos = ast.Position(name="TestCollections.test_object", line=1, col=13),
value = "3",
),
ast.Integer(
pos = ast.Position(name="TestCollections.test_object", line=1, col=18),
value = 4,
),
),
(
ast.Float(
pos = ast.Position(name="TestCollections.test_object", line=1, col=21),
value = 5.0,
),
ast.String(
pos = ast.Position(name="TestCollections.test_object", line=1, col=26),
value = "6",
),
)
])
class TestExpressions(unittest.TestCase):
def test_bare_value(self):
val = parser.Parser(io.StringIO('1234'), 'TestExpressions.test_bare_value')._parse_expr()
self.assertIsInstance(val, ast.Integer)
assert type(val) is ast.Integer
self.assertEqual(val.value, 1234)
def test_binary(self):
val = parser.Parser(io.StringIO('1234 == 5678'), 'TestExpressions.test_binary')._parse_expr()
self.assertEqual(val, ast.BinaryExpression(
pos = ast.Position(name='TestExpressions.test_binary', line=1, col=1),
left = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary', line=1, col=1),
value = 1234,
),
op = ast.Operator(
pos = ast.Position(name='TestExpressions.test_binary', line=1, col=6),
value = '==',
),
right = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary', line=1, col=9),
value = 5678
),
))
def test_binary_nested(self):
val = parser.Parser(io.StringIO('(1234 - 5) == 5678'), 'TestExpressions.test_binary_nested')._parse_expr()
self.assertEqual(val, ast.BinaryExpression(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=2),
left = ast.BinaryExpression(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=2),
left = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=2),
value = 1234,
),
op = ast.Operator(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=7),
value = '-',
),
right = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=9),
value = 5,
),
),
op = ast.Operator(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=12),
value = '==',
),
right = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=15),
value = 5678
),
))
def test_binary_multi(self):
val = parser.Parser(io.StringIO('1234 == 5678 - 4444'), 'TestExpressions.test_binary_nested')._parse_expr()
self.assertEqual(val, ast.BinaryExpression(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=1),
left = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=1),
value = 1234,
),
op = ast.Operator(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=6),
value = '==',
),
right = ast.BinaryExpression(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=9),
left = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=9),
value = 5678
),
op = ast.Operator(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=14),
value = '-',
),
right = ast.Integer(
pos = ast.Position(name='TestExpressions.test_binary_nested', line=1, col=16),
value = 4444,
),
),
))
def test_expansion(self):
val = parser.Parser(io.StringIO('x(y...)'), 'TestExpressions.test_expansion')._parse_expr()
self.assertEqual(val, ast.FunctionCall(
pos = ast.Position(name='TestExpressions.test_expansion', line=1, col=2),
value = ast.VariableRef(
pos = ast.Position(name='TestExpressions.test_expansion', line=1, col=1),
name = 'x',
),
args = [
ast.Expansion(
pos = ast.Position(name='TestExpressions.test_expansion', line=1, col=3),
value = ast.VariableRef(
pos = ast.Position(name='TestExpressions.test_expansion', line=1, col=3),
name = 'y',
),
),
],
))
def test_index(self):
val = parser.Parser(io.StringIO('x[0]'), 'TestExpressions.test_index')._parse_expr()
self.assertIsInstance(val, ast.Index)
assert type(val) is ast.Index
self.assertEqual(val.value, ast.VariableRef(
pos = ast.Position(name='TestExpressions.test_index', line=1, col=1),
name = 'x',
))
self.assertEqual(val.index, ast.Integer(
pos = ast.Position(name='TestExpressions.test_index', line=1, col=3),
value = 0,
))
def test_index_legacy(self):
val = parser.Parser(io.StringIO('x.0'), 'TestExpressions.test_index_legacy')._parse_expr()
self.assertIsInstance(val, ast.Index)
assert type(val) is ast.Index
self.assertEqual(val.value, ast.VariableRef(
pos = ast.Position(name='TestExpressions.test_index_legacy', line=1, col=1),
name = 'x',
))
self.assertEqual(val.index, ast.Integer(
pos = ast.Position(name='TestExpressions.test_index_legacy', line=1, col=3),
value = 0,
))
def test_getattr(self):
val = parser.Parser(io.StringIO('x.y'), 'TestExpressions.test_getattr')._parse_expr()
self.assertIsInstance(val, ast.GetAttr)
assert type(val) is ast.GetAttr
self.assertEqual(val.value, ast.VariableRef(
pos = ast.Position(name='TestExpressions.test_getattr', line=1, col=1),
name = 'x',
))
self.assertEqual(val.attr, 'y')
def test_unary(self):
val = parser.Parser(io.StringIO('!true'), 'TestExpressions.test_unary')._parse_value()
self.assertIsInstance(val, ast.UnaryExpression)
assert type(val) is ast.UnaryExpression
self.assertEqual(val.op.value, '!')
self.assertEqual(val.value, ast.Bool(
pos = ast.Position(name="TestExpressions.test_unary", line=1, col=2),
value = True,
))