Skip to content

Commit

Permalink
Merge pull request #1 from pinzon/package-setup
Browse files Browse the repository at this point in the history
Package setup
  • Loading branch information
pinzon authored Oct 16, 2024
2 parents 0d5418d + 9a32171 commit 4d7ca35
Show file tree
Hide file tree
Showing 12 changed files with 439 additions and 68 deletions.
40 changes: 40 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Publish to PyPI

on:
push:
branches:
- main

jobs:
publish:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'

- name: Install dependencies
run: |
pip install -e .[test]
- name: Run tests
run: pytest

- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build project
run: python -m build

- name: Publish to PyPI
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: twine upload dist/*
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Run Tests

on:
pull_request:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'

- name: Install dependencies
run: |
pip install -e .[test]
- name: Run tests
run: pytest
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
__pycache__
.idea
.vscode
aws_json_term_matcher.egg-info
.ruff_cache
build
33 changes: 33 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
VENV_BIN ?= python3 -m venv
VENV_DIR ?= .venv
PIP_CMD ?= pip3

ifeq ($(OS), Windows_NT)
VENV_ACTIVATE = $(VENV_DIR)/Scripts/activate
else
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
endif

VENV_RUN = . $(VENV_ACTIVATE)


$(VENV_ACTIVATE): pyproject.toml
test -d $(VENV_DIR) || $(VENV_BIN) $(VENV_DIR)
$(VENV_RUN); $(PIP_CMD) install --upgrade pip setuptools wheel plux
touch $(VENV_ACTIVATE)


venv: $(VENV_ACTIVATE) ## Create a new (empty) virtual environment

format: venv ## Run ruff and black to format the whole codebase
($(VENV_RUN); python -m ruff check --output-format=full --fix .; python -m black .)

lint: venv ## Run code linter to check code style and check if formatter would make changes
($(VENV_RUN); python -m ruff check --show-source . && python -m black --check .)

install: venv
$(VENV_RUN); $(PIP_CMD) install -e .

test: venv ## Run tests
($(VENV_RUN); python -m pytest -v --cov=plux --cov-report=term-missing --cov-report=xml tests)

8 changes: 8 additions & 0 deletions aws_json_term_matcher/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class ParsingError(Exception):
def __init__(self, message):
super().__init__(message)


class MatchingError(Exception):
def __init__(self, message):
super().__init__(message)
32 changes: 28 additions & 4 deletions aws_json_term_matcher/grammar.lark
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
start: "{" expression "}"

expression: "(" expression ")" -> paren
expression: "(" expression ")"
| expression "&&" expression -> and_op
| expression "||" expression -> or_op
| BOOL
| comparison

BOOL: "true" | "false"
%ignore /\s+/
comparison: entity COMPARATOR value
COMPARATOR: "=" | "!=" | ">" | ">=" | "<" | "<="

entity: "$" selection
selection: (attribute_access | index_access)+
attribute_access: "." NAME | "[" ESCAPED_STRING "]"
index_access: "[" INT "]"

value: ESCAPED_STRING | NUMBER | SCIENTIFIC | IP | WILDCARD_IP


NUMBER: INT | FLOAT
SCIENTIFIC: SIGNED_INT "e" SIGNED_INT | SIGNED_FLOAT "e" SIGNED_INT
IP: INT "." INT "." INT "." INT
WILDCARD_IP: INT "." "*" | INT "." INT "." "*" | INT "." INT "." INT "." "*"

NAME: /[a-zA-Z_][a-zA-Z0-9_-]*/
INT: /[0-9]+/
FLOAT: /[0-9]+\.[0-9]+/
SIGNED_INT: INT | "+"INT | "-"INT
SIGNED_FLOAT: FLOAT | "+"FLOAT | "-"FLOAT


%import common.WS
%import common.ESCAPED_STRING
%ignore WS
163 changes: 163 additions & 0 deletions aws_json_term_matcher/matcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import os

from lark import Lark, Transformer, v_args, Tree, Token
from lark.exceptions import UnexpectedCharacters, UnexpectedInput, UnexpectedToken

from aws_json_term_matcher.exceptions import ParsingError, MatchingError


class IpRange:
def __init__(self, ip_range: str):
self.range = ip_range

def ip_is_in_range(self, ip: str | None) -> bool:
if ip is None:
return False
if len(ip) == 0:
return False

ip_parts = ip.split(".")
range_parts = self.range.split(".")

# Compare each part of the IP to the range
for i in range(len(range_parts)):
if range_parts[i] == "*":
continue # Wildcard matches any value
if i >= len(ip_parts) or ip_parts[i] != range_parts[i]:
return False
return True


# Transformer to evaluate the parsed filter
@v_args(inline=True)
class FilterEvaluator(Transformer):
def __init__(self, data):
self.data = data

def start(self, expr):
if isinstance(expr, bool):
return expr
elif hasattr(expr, "children") and len(expr.children) > 0:
child = expr.children[0]
if isinstance(child, bool):
return child

if hasattr(child, "children") and len(child.children) > 0:
return child.children[0]

return None # Fallback if the structure isn't as expected

def and_op(self, left, right):
value_left = left.children[0]
value_right = right.children[0]
return value_left and value_right

def or_op(self, left: Tree, right: Tree):
value_left = left.children[0]
value_right = right.children[0]
return value_left or value_right

def comparison(self, entity, comparator, value):
entity_value = self.resolve_entity(entity)
result = self.compare(entity_value, comparator, value)
return result

def resolve_entity(self, entity: Tree):
# Extract the entity from the dictionary based on selection rules
# This would resolve $.attribute or $[index] kind of paths in the dictionary
keys = []
# in this case the three only is composed of branch with just one branch
# entity -> selection -> attribute access -> "NAME"

def _resolve(node):
if node.data == "attribute_access":
# Handles attributes like $.attributeName or $["attributeName"]
child = node.children[0]
if child.type == "NAME":
keys.append(child.value) # Regular attribute
elif child.type == "ESCAPED_STRING":
keys.append(
child.value.strip('"')
) # Attribute accessed like ["attr"]

elif node.data == "index_access":
index = node.children[0].value
keys.append(index)

elif node.data == "selection":
# Keep recursing through the selection (attributes or indices)
for child in node.children:
_resolve(child)
elif node.data == "entity":
for child in node.children:
_resolve(child)

# Start traversing the entity tree to build the keys
_resolve(entity)

value = self.data
try:
for key in keys:
if key.isdigit():
value = value[int(key)]
else:
value = value.get(key, None)
return value
except IndexError:
return None

def compare(self, entity_value, comparator, value):
comparator_value = comparator.value

if isinstance(value, IpRange):
return value.ip_is_in_range(entity_value)

if comparator_value == "=":
return entity_value == value
elif comparator_value == "!=":
return entity_value != value
elif comparator_value == ">":
return entity_value > value
elif comparator_value == ">=":
return entity_value >= value
elif comparator_value == "<":
return entity_value < value
elif comparator_value == "<=":
return entity_value <= value
return False

def value(self, value: Token):
# Returns the value as-is (for STRING, NUMBER, etc.)
if value.type in ["SCIENTIFIC", "NUMBER"]:
return float(value.value)

if value.type == "WILDCARD_IP":
return IpRange(value.value)

return value.value.strip("\"'")


def load_parser():
grammar_path = os.path.join(os.path.dirname(__file__), "grammar.lark")

with open(grammar_path, "r") as grammar_file:
grammar = grammar_file.read()
return Lark(grammar, start="start", parser="lalr")


def parse_filter(expression):
parser = load_parser()
try:
return parser.parse(expression)
except (UnexpectedCharacters, UnexpectedInput, UnexpectedToken) as e:
raise ParsingError(str(e))


def match(obj: dict, filter: str):
tree = parse_filter(filter)
evaluator = FilterEvaluator(obj)

try:
return evaluator.transform(tree)
except Exception as e:
raise MatchingError(str(e))
14 changes: 0 additions & 14 deletions aws_json_term_matcher/parser.py

This file was deleted.

22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[project]
name = "aws-json-term-matcher"
version = "0.1.0"
authors = [
{ name = 'Cristopher Pinzon', email = '[email protected]' }
]
description = "The core library and runtime of LocalStack"
requires-python = ">=3.8"
dependencies=[
"lark"
]

[project.optional-dependencies]
dev = [
"pytest",
"ruff",
"black"
]

test = [
"pytest"
]
24 changes: 0 additions & 24 deletions setup.py

This file was deleted.

Loading

0 comments on commit 4d7ca35

Please sign in to comment.