diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5d0200a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: CI Testing + +on: + push: + pull_request: + + workflow_dispatch: + +jobs: + testing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install deps + run: | + pip3 --version + pip3 install .[dev] + + - name: Run tests + run: pytest + + code_style_checking: + uses: ./.github/workflows/styling.yml \ No newline at end of file diff --git a/.github/workflows/styling.yml b/.github/workflows/styling.yml index aa2042b..dbf449c 100644 --- a/.github/workflows/styling.yml +++ b/.github/workflows/styling.yml @@ -10,6 +10,7 @@ on: paths: - "**.py" workflow_dispatch: + workflow_call: jobs: code_style_checking: diff --git a/regexfactory/__init__.py b/regexfactory/__init__.py index e3d2b8a..06b92b3 100644 --- a/regexfactory/__init__.py +++ b/regexfactory/__init__.py @@ -23,7 +23,7 @@ WHITESPACE, WORD, ) -from .pattern import RegexPattern, escape, join +from .pattern import ESCAPED_CHARACTERS, RegexPattern, ValidPatternType, escape, join from .patterns import ( Amount, Comment, diff --git a/setup.cfg b/setup.cfg index 69fe130..c2e4b6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,23 @@ classifiers = zip_safe = True packages = regexfactory +[options.extras_require] +dev = + isort + black + flake8 + pylint + pytest + hypothesis + +[tool:pytest] +markers = + patterns: Tests for classes in regexfactory/patterns.py + pattern: Tests for classes in regexfactory/pattern.py +addopts = -ra --hypothesis-show-statistics --hypothesis-profile=default +testpaths = + tests + [flake8] ignore = E501 exclude = .git, __pycache__, .github diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e5c6d22 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +from hypothesis import settings + +# Set default profile to use 500 examples +settings.register_profile("default", max_examples=500) diff --git a/tests/strategies.py b/tests/strategies.py new file mode 100644 index 0000000..74d52c6 --- /dev/null +++ b/tests/strategies.py @@ -0,0 +1,22 @@ +from hypothesis import strategies as st + +from regexfactory.pattern import ESCAPED_CHARACTERS + +# Strategy to generate characters that are not used in escapes +non_escape_char = st.characters(blacklist_characters=list(ESCAPED_CHARACTERS)) + + +# Strategy to generate text that avoids escaped characters +non_escaped_text = st.text(min_size=1, alphabet=non_escape_char) + + +# Strategy to produce either None or a positive integer +optional_step = st.one_of(st.none(), st.integers(min_value=1)) + + +def build_bounds(lower_bound, step) -> range: + """ + Function to generate a tuple of (lower, upper) in which lower < upper + """ + upper_bound = lower_bound + step + return range(lower_bound, upper_bound + 1) diff --git a/tests/test_amount.py b/tests/test_amount.py new file mode 100644 index 0000000..7d96735 --- /dev/null +++ b/tests/test_amount.py @@ -0,0 +1,89 @@ +from typing import Optional + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from strategies import build_bounds, optional_step + +from regexfactory import Amount, ValidPatternType + + +def build_amount( + pattern: ValidPatternType, + start: int, + or_more: bool, + greedy: bool, + step: Optional[int], +): + """ + General Amount builder. Note that the `j` parameter is constructed as + the step plus start when step is defined. When step is None we assume + that no upper bound is present. + """ + stop_value = None + if step is not None: + stop_value = start + step + return Amount( + pattern=pattern, i=start, j=stop_value, or_more=or_more, greedy=greedy + ) + + +@pytest.mark.patterns +@given(st.text(min_size=1), st.integers(min_value=1)) +def test_amount_single_count(word, count): + """ + Test to ensure that when `or_more=False` and no upper bound is + provided the regex will be of the form word{count}. + """ + actual = Amount(word, i=count, or_more=False) + assert actual.regex == "{word}{{{count}}}".format(word=word, count=str(count)) + + +@pytest.mark.patterns +@given( + st.text(min_size=1), + st.builds( + build_bounds, + lower_bound=st.integers(min_value=1), + step=st.integers(min_value=1), + ), +) +def test_amount_lower_upper(word, bound: range): + """ + Test to ensure that if a lower and upper bound are provided then the + regex of the resulting `Amount` will be of the form {word}{lower,upper}. + """ + actual = Amount(word, bound.start, bound.stop) + expected = "{word}{{{lower},{upper}}}".format( + word=word, lower=str(bound.start), upper=str(bound.stop) + ) + assert actual.regex == expected + + +@pytest.mark.patterns +@given(st.text(min_size=1), st.integers(min_value=1)) +def test_amount_or_more(word, count): + """ + Test to ensure that when `or_more=True` and no upper bound is + provided the regex will be of the form word{count,}. + """ + actual = Amount(word, count, or_more=True) + assert actual.regex == "{word}{{{count},}}".format(word=word, count=str(count)) + + +@pytest.mark.patterns +@given( + st.builds( + build_amount, + pattern=st.text(min_size=1), + start=st.integers(min_value=1), + or_more=st.booleans(), + greedy=st.just(False), + step=optional_step, + ) +) +def test_amount_non_greedy(amt): + """ + Test to ensure that instances of Amount with greedy as False will end with "?" + """ + assert amt.regex.endswith("?") diff --git a/tests/test_join.py b/tests/test_join.py new file mode 100644 index 0000000..727cdf6 --- /dev/null +++ b/tests/test_join.py @@ -0,0 +1,18 @@ +import pytest +from hypothesis import example, given +from hypothesis import strategies as st +from strategies import non_escaped_text + +from regexfactory.pattern import join + + +@pytest.mark.pattern +@given(st.lists(elements=non_escaped_text, min_size=1, max_size=10, unique=True)) +@example(words=["0", "1"]) +def test_join(words: list): + """ + Tests to capture that the join function concatenates the expressions and + each word in the list is found in the larger regex. + """ + joined_regex = join(*words) + assert joined_regex.regex == "".join(words) diff --git a/tests/test_or.py b/tests/test_or.py new file mode 100644 index 0000000..b1b6579 --- /dev/null +++ b/tests/test_or.py @@ -0,0 +1,26 @@ +import re + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st +from strategies import non_escaped_text + +from regexfactory import Or + + +@pytest.mark.patterns +@given( + st.lists( + non_escaped_text, + min_size=1, + max_size=10, + ) +) +@example(arr=["0", "0"]) +def test_matching_or(arr: list): + actual = Or(*arr) + if len(arr) == 1: + assert isinstance(actual.match(arr[0]), re.Match) + else: + for value in arr: + assert isinstance(actual.match(value), re.Match) diff --git a/tests/test_range.py b/tests/test_range.py new file mode 100644 index 0000000..b969b61 --- /dev/null +++ b/tests/test_range.py @@ -0,0 +1,25 @@ +import pytest + +from regexfactory import Range + + +@pytest.mark.patterns +def test_numeric_range(): + start = "0" + end = "9" + assert Range(start, end).regex == "[0-9]" + + +@pytest.mark.patterns +@pytest.mark.parametrize( + "start, stop, expected", + [ + ("0", "9", "[0-9]"), + ("a", "f", "[a-f]"), + ("r", "q", "[r-q]"), + ("A", "Z", "[A-Z]"), + ], +) +def test_range_parameters(start, stop, expected): + actual = Range(start=start, stop=stop) + assert actual.regex == expected diff --git a/tests/test_set.py b/tests/test_set.py new file mode 100644 index 0000000..d400c8e --- /dev/null +++ b/tests/test_set.py @@ -0,0 +1,16 @@ +import re + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from strategies import non_escape_char + +from regexfactory import Set + + +@pytest.mark.patterns +@given(st.lists(elements=non_escape_char, min_size=1)) +def test_set(chars: list): + actual = Set(*chars) + for value in chars: + assert isinstance(actual.match(value), re.Match)