├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── re_assert.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── re_assert_test.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py39", "py310", "py311", "py312"]' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v2.8.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.15.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py39-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v3.2.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.20.0 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py39-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.3.2 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 7.2.0 36 | hooks: 37 | - id: flake8 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.15.0 40 | hooks: 41 | - id: mypy 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://github.com/asottile/re-assert/actions/workflows/main.yml/badge.svg)](https://github.com/asottile/re-assert/actions/workflows/main.yml) 2 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/re-assert/main.svg)](https://results.pre-commit.ci/latest/github/asottile/re-assert/main) 3 | 4 | re-assert 5 | ========= 6 | 7 | show where your regex match assertion failed! 8 | 9 | ## installation 10 | 11 | ```bash 12 | pip install re-assert 13 | ``` 14 | 15 | ## usage 16 | 17 | `re-assert` provides a helper class to make assertions of regexes simpler. 18 | 19 | ### `re_assert.Matches(pattern: str, *args, **kwargs)` 20 | 21 | construct a `Matches` object. 22 | 23 | _note_: under the hood, `re-assert` uses the [`regex`] library for matching, 24 | any `*args` / `**kwargs` that `regex.compile` supports will work. in general, 25 | the `regex` library is 100% compatible with the `re` library (and will even 26 | accept its flags, etc.) 27 | 28 | [`regex`]: https://pypi.org/project/regex/ 29 | 30 | ### `re_assert.Matches.from_pattern(pattern: Pattern[str]) -> Matches` 31 | 32 | construct a `Matches` object from an already-compiled regex. 33 | 34 | this is useful (for instance) if you're testing an existing compiled regex. 35 | 36 | ```pycon 37 | >>> import re 38 | >>> reg = re.compile('foo') 39 | >>> Matches.from_pattern(reg) == 'fork' 40 | False 41 | >>> Matches.from_pattern(reg) == 'food' 42 | True 43 | ``` 44 | 45 | ### `Matches.__eq__(other)` (`==`) 46 | 47 | the equality operator is overridden for use with assertion frameworks such 48 | as pytest 49 | 50 | ```pycon 51 | >>> pat = Matches('foo') 52 | >>> pat == 'bar' 53 | False 54 | >>> pat == 'food' 55 | True 56 | ``` 57 | 58 | ### `Matches.__repr__()` (`repr(...)`) 59 | 60 | a side-effect of an equality failure changes the `repr(...)` of a `Matches` 61 | object. this allows for useful pytest assertion messages: 62 | 63 | ```pytest 64 | > assert Matches('foo') == 'fork' 65 | E AssertionError: assert Matches('foo'...ork\n # ^ == 'fork' 66 | E -Matches('foo')\n 67 | E - # regex failed to match at:\n 68 | E - #\n 69 | E - #> fork\n 70 | E - # ^ 71 | E +'fork' 72 | ``` 73 | 74 | ### `Matches.assert_matches(s: str)` 75 | 76 | if you're using some other test framework, this method is useful for producing 77 | a readable traceback 78 | 79 | ```pycon 80 | >>> Matches('foo').assert_matches('food') 81 | >>> Matches('foo').assert_matches('fork') 82 | Traceback (most recent call last): 83 | File "", line 1, in 84 | File "/home/asottile/workspace/re-assert/re_assert.py", line 63, in assert_matches 85 | assert self == s, self._fail 86 | AssertionError: regex failed to match at: 87 | 88 | > fork 89 | ^ 90 | ``` 91 | -------------------------------------------------------------------------------- /re_assert.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from re import Pattern 4 | from typing import Any 5 | 6 | import regex 7 | 8 | 9 | class Matches: # TODO: Generic[AnyStr] (binary pattern support) 10 | def __init__(self, pattern: str, *args: Any, **kwargs: Any) -> None: 11 | self._pattern = regex.compile(pattern, *args, **kwargs) 12 | self._fail: str | None = None 13 | self._type = type(pattern) 14 | 15 | def _fail_message(self, fail: str) -> str: 16 | # binary search to find the longest substring match 17 | pos, bound = 0, len(fail) 18 | while pos < bound: 19 | pivot = pos + (bound - pos + 1) // 2 20 | match = self._pattern.match(fail[:pivot], partial=True) 21 | if match: 22 | pos = pivot 23 | else: 24 | bound = pivot - 1 25 | 26 | retv = [' regex failed to match at:', ''] 27 | for line in fail.splitlines(True): 28 | line_noeol = line.rstrip('\r\n') 29 | retv.append(f'> {line_noeol}') 30 | if 0 <= pos <= len(line_noeol): 31 | indent = ''.join(c if c.isspace() else ' ' for c in line[:pos]) 32 | retv.append(f' {indent}^') 33 | pos = -1 34 | else: 35 | pos -= len(line) 36 | if pos >= 0: 37 | retv.append('>') 38 | retv.append(' ^') 39 | return '\n'.join(retv) 40 | 41 | def __eq__(self, other: object) -> bool: 42 | if not isinstance(other, self._type): 43 | raise TypeError(f'expected {self._type}, got {type(other)}') 44 | if not self._pattern.match(other): 45 | self._fail = self._fail_message(other) 46 | return False 47 | else: 48 | self._fail = None 49 | return True 50 | 51 | def __repr__(self) -> str: 52 | pattern_repr = repr(self._pattern) 53 | params = pattern_repr[pattern_repr.index('(') + 1:-1] 54 | boring_flag = ', flags=regex.V0' 55 | if params.endswith(boring_flag): 56 | params = params[:-1 * len(boring_flag)] 57 | if self._fail is not None: 58 | fail_msg = ' #'.join(['\n'] + self._fail.splitlines(True)) 59 | else: 60 | fail_msg = '' 61 | return f'{type(self).__name__}({params}){fail_msg}' 62 | 63 | def assert_matches(self, s: str) -> None: 64 | assert self == s, self._fail 65 | 66 | @classmethod 67 | def from_pattern(cls, pattern: Pattern[str]) -> Matches: 68 | return cls(pattern.pattern, pattern.flags) 69 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = re_assert 3 | version = 1.1.0 4 | description = show where your regex match assertion failed! 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/re-assert 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: Implementation :: CPython 16 | Programming Language :: Python :: Implementation :: PyPy 17 | 18 | [options] 19 | py_modules = re_assert 20 | install_requires = 21 | regex 22 | python_requires = >=3.9 23 | 24 | [bdist_wheel] 25 | universal = True 26 | 27 | [coverage:run] 28 | plugins = covdefaults 29 | 30 | [mypy] 31 | check_untyped_defs = true 32 | disallow_any_generics = true 33 | disallow_incomplete_defs = true 34 | disallow_untyped_defs = true 35 | warn_redundant_casts = true 36 | warn_unused_ignores = true 37 | 38 | [mypy-testing.*] 39 | disallow_untyped_defs = false 40 | 41 | [mypy-tests.*] 42 | disallow_untyped_defs = false 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile/re-assert/944d2a075b286917774b433d64c116e5571e0957/tests/__init__.py -------------------------------------------------------------------------------- /tests/re_assert_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import pytest 6 | 7 | from re_assert import Matches 8 | 9 | 10 | def test_typeerror_equality_different_type(): 11 | with pytest.raises(TypeError): 12 | Matches('foo') == b'foo' 13 | 14 | 15 | def test_matches_repr_plain(): 16 | assert repr(Matches('^foo')) == "Matches('^foo')" 17 | 18 | 19 | def test_matches_repr_with_flags(): 20 | ret = repr(Matches('^foo', re.I)) 21 | assert ret == "Matches('^foo', flags=regex.I | regex.V0)" 22 | 23 | 24 | def test_repr_with_failure(): 25 | obj = Matches('^foo') 26 | assert obj != 'fa' 27 | assert repr(obj) == ( 28 | "Matches('^foo')\n" 29 | ' # regex failed to match at:\n' 30 | ' #\n' 31 | ' #> fa\n' 32 | ' # ^' 33 | ) 34 | 35 | 36 | def test_assert_success(): 37 | obj = Matches('foo') 38 | assert obj == 'food' 39 | obj.assert_matches('food') 40 | 41 | 42 | def test_fail_at_beginning(): 43 | with pytest.raises(AssertionError) as excinfo: 44 | Matches('foo').assert_matches('bar') 45 | msg, = excinfo.value.args 46 | assert msg == ( 47 | ' regex failed to match at:\n' 48 | '\n' 49 | '> bar\n' 50 | ' ^' 51 | ) 52 | 53 | 54 | def test_fail_at_end_of_line(): 55 | with pytest.raises(AssertionError) as excinfo: 56 | Matches('foo').assert_matches('fo') 57 | msg, = excinfo.value.args 58 | assert msg == ( 59 | ' regex failed to match at:\n' 60 | '\n' 61 | '> fo\n' 62 | ' ^' 63 | ) 64 | 65 | 66 | def test_fail_multiple_lines(): 67 | with pytest.raises(AssertionError) as excinfo: 68 | Matches('foo.bar', re.DOTALL).assert_matches('foo\nbr') 69 | msg, = excinfo.value.args 70 | assert msg == ( 71 | ' regex failed to match at:\n' 72 | '\n' 73 | '> foo\n' 74 | '> br\n' 75 | ' ^' 76 | ) 77 | 78 | 79 | def test_fail_end_of_line_with_newline(): 80 | with pytest.raises(AssertionError) as excinfo: 81 | Matches('foo.bar', re.DOTALL).assert_matches('foo\n') 82 | msg, = excinfo.value.args 83 | assert msg == ( 84 | ' regex failed to match at:\n' 85 | '\n' 86 | '> foo\n' 87 | '>\n' 88 | ' ^' 89 | ) 90 | 91 | 92 | def test_fail_at_end_of_line_mismatching_newline(): 93 | with pytest.raises(AssertionError) as excinfo: 94 | Matches('foo.', re.DOTALL).assert_matches('foo') 95 | msg, = excinfo.value.args 96 | assert msg == ( 97 | ' regex failed to match at:\n' 98 | '\n' 99 | '> foo\n' 100 | ' ^' 101 | ) 102 | 103 | 104 | def test_match_with_tabs(): 105 | with pytest.raises(AssertionError) as excinfo: 106 | Matches('f.o.o').assert_matches('f\to\tx\n') 107 | msg, = excinfo.value.args 108 | assert msg == ( 109 | ' regex failed to match at:\n' 110 | '\n' 111 | '> f\to\tx\n' 112 | ' \t \t^' 113 | ) 114 | 115 | 116 | def test_from_pattern(): 117 | pattern = re.compile('^foo', flags=re.I) 118 | assert Matches.from_pattern(pattern) == 'FOO' 119 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | --------------------------------------------------------------------------------