├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── deploy.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── codecov.yaml ├── magic_filter ├── __init__.py ├── attrdict.py ├── exceptions.py ├── helper.py ├── magic.py ├── operations │ ├── __init__.py │ ├── base.py │ ├── call.py │ ├── cast.py │ ├── combination.py │ ├── comparator.py │ ├── extract.py │ ├── function.py │ ├── getattr.py │ ├── getitem.py │ └── selector.py ├── py.typed └── util.py ├── mypy.ini ├── pyproject.toml └── tests ├── __init__.py ├── test_attrdict.py ├── test_iterable.py ├── test_len.py └── test_magic.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | if TYPE_CHECKING: 5 | @abstractmethod 6 | @overload 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | select = C,E,F,W,B,B950 4 | ignore = E501,W503,E203 5 | exclude = 6 | .git 7 | build 8 | dist 9 | docs 10 | *.egg-info 11 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.11" 20 | 21 | - name: Install build dependencies 22 | run: python -m pip install --upgrade build hatch 23 | 24 | - name: Bump version 25 | run: hatch version $(echo ${{ github.ref }} | sed -e 's/refs\/tags\/v//') 26 | 27 | - name: Build 28 | run: python -m build . 29 | 30 | - name: Try install wheel 31 | run: | 32 | mkdir -p try_install 33 | cd try_install 34 | python -m venv venv 35 | venv/bin/pip install ../dist/magic_filter-*.whl 36 | venv/bin/python -c "import magic_filter; print(magic_filter.__version__)" 37 | 38 | - name: Publish artifacts 39 | uses: actions/upload-artifact@v2 40 | with: 41 | name: package 42 | path: dist/* 43 | 44 | publish: 45 | name: Publish 46 | needs: build 47 | if: "success() && startsWith(github.ref, 'refs/tags/')" 48 | runs-on: ubuntu-latest 49 | environment: 50 | name: pypi 51 | url: https://pypi.org/project/magic-filter/ 52 | permissions: 53 | id-token: write 54 | steps: 55 | - name: Download artifacts 56 | uses: actions/download-artifact@v1 57 | with: 58 | name: package 59 | path: dist 60 | 61 | - name: Publish a Python distribution to PyPI 62 | uses: pypa/gh-action-pypi-publish@release/v1 63 | 64 | 65 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: { } 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - ubuntu-latest 18 | - macos-latest 19 | - windows-latest 20 | python-version: 21 | - "3.7" 22 | - "3.8" 23 | - "3.9" 24 | - "3.10" 25 | - "3.11" 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@master 30 | 31 | - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Get pip cache dir 37 | id: pip-cache 38 | run: | 39 | echo "::set-output name=dir::$(pip cache dir)" 40 | 41 | - name: pip cache 42 | uses: actions/cache@v2 43 | with: 44 | path: ${{ steps.pip-cache.outputs.dir }} 45 | key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }} 46 | restore-keys: | 47 | ${{ runner.os }}-py-${{ matrix.python-version }}-pip- 48 | 49 | - name: Install dependencies 50 | run: | 51 | pip install -e .[dev] 52 | 53 | - name: Lint code 54 | run: | 55 | flake8 magic_filter 56 | isort --check-only -df magic_filter 57 | black --check --diff magic_filter 58 | mypy magic_filter 59 | 60 | - name: Run tests 61 | run: | 62 | pytest --cov=magic_filter --cov-config .coveragerc --cov-report=xml 63 | 64 | - uses: codecov/codecov-action@v1 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | file: coverage.xml 68 | flags: unittests 69 | name: py-${{ matrix.python-version }}-${{ matrix.os }} 70 | fail_ci_if_error: true 71 | 72 | build: 73 | name: Build 74 | needs: test 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Checkout code 78 | uses: actions/checkout@master 79 | 80 | - name: Set up Python 3.10 81 | uses: actions/setup-python@v4 82 | with: 83 | python-version: "3.11" 84 | 85 | - name: Install build dependencies 86 | run: python -m pip install --upgrade build 87 | 88 | - name: Build 89 | run: python -m build . 90 | 91 | - name: Try install wheel 92 | run: | 93 | mkdir -p try_install 94 | cd try_install 95 | python -m venv venv 96 | venv/bin/pip install ../dist/magic_filter-*.whl 97 | venv/bin/python -c "import magic_filter; print(magic_filter.__version__)" 98 | 99 | - name: Publish artifacts 100 | uses: actions/upload-artifact@v2 101 | with: 102 | name: package 103 | path: dist/* 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | __pycache__/ 4 | *.py[cod] 5 | 6 | env/ 7 | build/ 8 | dist/ 9 | site/ 10 | *.egg-info/ 11 | 12 | .mypy_cache 13 | .dmypy.json 14 | .pytest_cache 15 | .coverage 16 | coverage.xml 17 | 18 | dev/ 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | files: ^(magic_filter|tests) 7 | 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v2.3.0 10 | hooks: 11 | - id: flake8 12 | args: ['--config=.flake8'] 13 | files: magic_filter 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - id: check-merge-conflict 17 | 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.11.5 20 | hooks: 21 | - id: isort 22 | additional_dependencies: [toml] 23 | files: ^(magic_filter|tests) 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.4.1 27 | hooks: 28 | - id: mypy 29 | files: ^magic_filter 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2022 Alex Root Junior 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, 7 | and to permit persons to whom the Software is furnished to do so, subject to the 8 | following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies 11 | or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 15 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 16 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 18 | OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | base_python := python3 4 | 5 | reports_dir := reports 6 | 7 | package_dir := magic_filter 8 | code_dir := $(package_dir) tests 9 | 10 | help: 11 | @echo "magic-filter" 12 | 13 | 14 | # ================================================================================================= 15 | # Environment 16 | # ================================================================================================= 17 | 18 | clean: 19 | rm -rf `find . -name __pycache__` 20 | rm -f `find . -type f -name '*.py[co]' ` 21 | rm -f `find . -type f -name '*~' ` 22 | rm -f `find . -type f -name '.*~' ` 23 | rm -rf `find . -name .pytest_cache` 24 | rm -f `find . -type f -name '*.so' ` 25 | rm -f `find . -type f -name '*.c' ` 26 | rm -rf *.egg-info 27 | rm -f .coverage 28 | rm -f report.html 29 | rm -f .coverage.* 30 | rm -f .dmypy.json 31 | rm -rf {build,dist,site,.cache,.mypy_cache,reports,} 32 | 33 | 34 | # ================================================================================================= 35 | # Code quality 36 | # ================================================================================================= 37 | 38 | isort: 39 | isort $(code_dir) 40 | 41 | black: 42 | nlack $(code_dir) 43 | 44 | flake8: 45 | flake8 $(code_dir) 46 | 47 | flake8-report: 48 | mkdir -p $(reports_dir)/flake8 49 | flake8 --format=html --htmldir=$(reports_dir)/flake8 $(code_dir) 50 | 51 | mypy: 52 | mypy $(package_dir) 53 | 54 | mypy-report: 55 | mypy $(package_dir) --html-report $(reports_dir)/typechecking 56 | 57 | lint: 58 | isort --check-only $(code_dir) 59 | black --check --diff $(code_dir) 60 | flake8 $(code_dir) 61 | mypy $(package_dir) 62 | 63 | reformat: 64 | isort $(code_dir) 65 | black $(code_dir) 66 | 67 | # ================================================================================================= 68 | # Tests 69 | # ================================================================================================= 70 | 71 | test: 72 | $(py) pytest --cov=magic_filter --cov-config .coveragerc tests/ 73 | 74 | test-coverage: 75 | mkdir -p $(reports_dir)/tests/ 76 | pytest --cov=magic_filter --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ 77 | coverage html -d $(reports_dir)/coverage 78 | 79 | test-coverage-report: 80 | python -c "import webbrowser; webbrowser.open('file://$(shell pwd)/reports/coverage/index.html')" 81 | 82 | # ================================================================================================= 83 | # Project 84 | # ================================================================================================= 85 | 86 | build: clean flake8-report mypy-report test-coverage docs docs-copy-reports 87 | mkdir -p site/simple 88 | poetry build 89 | mv dist site/simple/magic-filter 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # magic-filter 2 | 3 | This package provides magic filter based on dynamic attribute getter 4 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: "reach,diff,flags,tree" 19 | behavior: default 20 | require_changes: no 21 | after_n_builds: 15 22 | -------------------------------------------------------------------------------- /magic_filter/__init__.py: -------------------------------------------------------------------------------- 1 | from . import operations 2 | from .attrdict import AttrDict 3 | from .magic import MagicFilter, MagicT, RegexpMode 4 | 5 | __all__ = ( 6 | "__version__", 7 | "operations", 8 | "MagicFilter", 9 | "MagicT", 10 | "RegexpMode", 11 | "F", 12 | "AttrDict", 13 | ) 14 | 15 | __version__ = "1" 16 | 17 | F = MagicFilter() 18 | -------------------------------------------------------------------------------- /magic_filter/attrdict.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, TypeVar 2 | 3 | KT = TypeVar("KT") 4 | VT = TypeVar("VT") 5 | 6 | 7 | class AttrDict(Dict[KT, VT]): 8 | """ 9 | A wrapper over dict which where element can be accessed as regular attributes 10 | """ 11 | 12 | def __init__(self, *args: Any, **kwargs: Any) -> None: 13 | super(AttrDict, self).__init__(*args, **kwargs) 14 | self.__dict__ = self # type: ignore 15 | -------------------------------------------------------------------------------- /magic_filter/exceptions.py: -------------------------------------------------------------------------------- 1 | class MagicFilterException(Exception): 2 | pass 3 | 4 | 5 | class SwitchMode(MagicFilterException): 6 | pass 7 | 8 | 9 | class SwitchModeToAll(SwitchMode): 10 | def __init__(self, key: slice) -> None: 11 | self.key = key 12 | 13 | 14 | class SwitchModeToAny(SwitchMode): 15 | pass 16 | 17 | 18 | class RejectOperations(MagicFilterException): 19 | pass 20 | 21 | 22 | class ParamsConflict(MagicFilterException): 23 | pass 24 | -------------------------------------------------------------------------------- /magic_filter/helper.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def resolve_if_needed(value: Any, initial_value: Any) -> Any: 5 | # To avoid circular imports here is used local import 6 | from magic_filter import MagicFilter 7 | 8 | if not isinstance(value, MagicFilter): 9 | return value 10 | return value.resolve(initial_value) 11 | -------------------------------------------------------------------------------- /magic_filter/magic.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import re 3 | from functools import wraps 4 | from typing import Any, Callable, Container, Optional, Pattern, Tuple, Type, TypeVar, Union 5 | from warnings import warn 6 | 7 | from magic_filter.exceptions import ( 8 | ParamsConflict, 9 | RejectOperations, 10 | SwitchModeToAll, 11 | SwitchModeToAny, 12 | ) 13 | from magic_filter.operations import ( 14 | BaseOperation, 15 | CallOperation, 16 | CastOperation, 17 | CombinationOperation, 18 | ComparatorOperation, 19 | ExtractOperation, 20 | FunctionOperation, 21 | GetAttributeOperation, 22 | GetItemOperation, 23 | ImportantCombinationOperation, 24 | ImportantFunctionOperation, 25 | RCombinationOperation, 26 | SelectorOperation, 27 | ) 28 | from magic_filter.util import and_op, contains_op, in_op, not_contains_op, not_in_op, or_op 29 | 30 | MagicT = TypeVar("MagicT", bound="MagicFilter") 31 | 32 | 33 | class RegexpMode: 34 | SEARCH = "search" 35 | MATCH = "match" 36 | FINDALL = "findall" 37 | FINDITER = "finditer" 38 | FULLMATCH = "fullmatch" 39 | 40 | 41 | class MagicFilter: 42 | __slots__ = ("_operations",) 43 | 44 | def __init__(self, operations: Tuple[BaseOperation, ...] = ()) -> None: 45 | self._operations = operations 46 | 47 | # An instance of MagicFilter cannot be used as an iterable object because objects 48 | # with a __getitem__ method can be endlessly iterated, which is not the desired behavior. 49 | __iter__ = None 50 | 51 | @classmethod 52 | def ilter(cls, magic: "MagicFilter") -> Callable[[Any], Any]: 53 | @wraps(magic.resolve) 54 | def wrapper(value: Any) -> Any: 55 | return magic.resolve(value) 56 | 57 | return wrapper 58 | 59 | @classmethod 60 | def _new(cls: Type[MagicT], operations: Tuple[BaseOperation, ...]) -> MagicT: 61 | return cls(operations=operations) 62 | 63 | def _extend(self: MagicT, operation: BaseOperation) -> MagicT: 64 | return self._new(self._operations + (operation,)) 65 | 66 | def _replace_last(self: MagicT, operation: BaseOperation) -> MagicT: 67 | return self._new(self._operations[:-1] + (operation,)) 68 | 69 | def _exclude_last(self: MagicT) -> MagicT: 70 | return self._new(self._operations[:-1]) 71 | 72 | def _resolve(self, value: Any, operations: Optional[Tuple[BaseOperation, ...]] = None) -> Any: 73 | initial_value = value 74 | if operations is None: 75 | operations = self._operations 76 | rejected = False 77 | for index, operation in enumerate(operations): 78 | if rejected and not operation.important: 79 | continue 80 | try: 81 | value = operation.resolve(value=value, initial_value=initial_value) 82 | except SwitchModeToAll: 83 | return all(self._resolve(value=item, operations=operations[index + 1 :]) for item in value) 84 | except SwitchModeToAny: 85 | return any(self._resolve(value=item, operations=operations[index + 1 :]) for item in value) 86 | except RejectOperations: 87 | rejected = True 88 | value = None 89 | continue 90 | rejected = False 91 | return value 92 | 93 | def __bool__(self) -> bool: 94 | return True 95 | 96 | def resolve(self: MagicT, value: Any) -> Any: 97 | return self._resolve(value=value) 98 | 99 | def __getattr__(self: MagicT, item: Any) -> MagicT: 100 | if item.startswith("_"): 101 | raise AttributeError(f"{type(self).__name__!r} object has no attribute {item!r}") 102 | return self._extend(GetAttributeOperation(name=item)) 103 | 104 | attr_ = __getattr__ 105 | 106 | def __getitem__(self: MagicT, item: Any) -> MagicT: 107 | if isinstance(item, MagicFilter): 108 | return self._extend(SelectorOperation(inner=item)) 109 | return self._extend(GetItemOperation(key=item)) 110 | 111 | def __len__(self) -> int: 112 | raise TypeError(f"Length can't be taken using len() function. Use {type(self).__name__}.len() instead.") 113 | 114 | def __eq__(self: MagicT, other: Any) -> MagicT: # type: ignore 115 | return self._extend(ComparatorOperation(right=other, comparator=operator.eq)) 116 | 117 | def __ne__(self: MagicT, other: Any) -> MagicT: # type: ignore 118 | return self._extend(ComparatorOperation(right=other, comparator=operator.ne)) 119 | 120 | def __lt__(self: MagicT, other: Any) -> MagicT: 121 | return self._extend(ComparatorOperation(right=other, comparator=operator.lt)) 122 | 123 | def __gt__(self: MagicT, other: Any) -> MagicT: 124 | return self._extend(ComparatorOperation(right=other, comparator=operator.gt)) 125 | 126 | def __le__(self: MagicT, other: Any) -> MagicT: 127 | return self._extend(ComparatorOperation(right=other, comparator=operator.le)) 128 | 129 | def __ge__(self: MagicT, other: Any) -> MagicT: 130 | return self._extend(ComparatorOperation(right=other, comparator=operator.ge)) 131 | 132 | def __invert__(self: MagicT) -> MagicT: 133 | if ( 134 | self._operations 135 | and isinstance(self._operations[-1], ImportantFunctionOperation) 136 | and self._operations[-1].function == operator.not_ 137 | ): 138 | return self._exclude_last() 139 | return self._extend(ImportantFunctionOperation(function=operator.not_)) 140 | 141 | def __call__(self: MagicT, *args: Any, **kwargs: Any) -> MagicT: 142 | return self._extend(CallOperation(args=args, kwargs=kwargs)) 143 | 144 | def __and__(self: MagicT, other: Any) -> MagicT: 145 | if isinstance(other, MagicFilter): 146 | return self._extend(CombinationOperation(right=other, combinator=and_op)) 147 | return self._extend(CombinationOperation(right=other, combinator=operator.and_)) 148 | 149 | def __rand__(self: MagicT, other: Any) -> MagicT: 150 | return self._extend(RCombinationOperation(left=other, combinator=operator.and_)) 151 | 152 | def __or__(self: MagicT, other: Any) -> MagicT: 153 | if isinstance(other, MagicFilter): 154 | return self._extend(ImportantCombinationOperation(right=other, combinator=or_op)) 155 | return self._extend(ImportantCombinationOperation(right=other, combinator=operator.or_)) 156 | 157 | def __ror__(self: MagicT, other: Any) -> MagicT: 158 | return self._extend(RCombinationOperation(left=other, combinator=operator.or_)) 159 | 160 | def __xor__(self: MagicT, other: Any) -> MagicT: 161 | return self._extend(CombinationOperation(right=other, combinator=operator.xor)) 162 | 163 | def __rxor__(self: MagicT, other: Any) -> MagicT: 164 | return self._extend(RCombinationOperation(left=other, combinator=operator.xor)) 165 | 166 | def __rshift__(self: MagicT, other: Any) -> MagicT: 167 | return self._extend(CombinationOperation(right=other, combinator=operator.rshift)) 168 | 169 | def __rrshift__(self: MagicT, other: Any) -> MagicT: 170 | return self._extend(RCombinationOperation(left=other, combinator=operator.rshift)) 171 | 172 | def __lshift__(self: MagicT, other: Any) -> MagicT: 173 | return self._extend(CombinationOperation(right=other, combinator=operator.lshift)) 174 | 175 | def __rlshift__(self: MagicT, other: Any) -> MagicT: 176 | return self._extend(RCombinationOperation(left=other, combinator=operator.lshift)) 177 | 178 | def __add__(self: MagicT, other: Any) -> MagicT: 179 | return self._extend(CombinationOperation(right=other, combinator=operator.add)) 180 | 181 | def __radd__(self: MagicT, other: Any) -> MagicT: 182 | return self._extend(RCombinationOperation(left=other, combinator=operator.add)) 183 | 184 | def __sub__(self: MagicT, other: Any) -> MagicT: 185 | return self._extend(CombinationOperation(right=other, combinator=operator.sub)) 186 | 187 | def __rsub__(self: MagicT, other: Any) -> MagicT: 188 | return self._extend(RCombinationOperation(left=other, combinator=operator.sub)) 189 | 190 | def __mul__(self: MagicT, other: Any) -> MagicT: 191 | return self._extend(CombinationOperation(right=other, combinator=operator.mul)) 192 | 193 | def __rmul__(self: MagicT, other: Any) -> MagicT: 194 | return self._extend(RCombinationOperation(left=other, combinator=operator.mul)) 195 | 196 | def __truediv__(self: MagicT, other: Any) -> MagicT: 197 | return self._extend(CombinationOperation(right=other, combinator=operator.truediv)) 198 | 199 | def __rtruediv__(self: MagicT, other: Any) -> MagicT: 200 | return self._extend(RCombinationOperation(left=other, combinator=operator.truediv)) 201 | 202 | def __floordiv__(self: MagicT, other: Any) -> MagicT: 203 | return self._extend(CombinationOperation(right=other, combinator=operator.floordiv)) 204 | 205 | def __rfloordiv__(self: MagicT, other: Any) -> MagicT: 206 | return self._extend(RCombinationOperation(left=other, combinator=operator.floordiv)) 207 | 208 | def __mod__(self: MagicT, other: Any) -> MagicT: 209 | return self._extend(CombinationOperation(right=other, combinator=operator.mod)) 210 | 211 | def __rmod__(self: MagicT, other: Any) -> MagicT: 212 | return self._extend(RCombinationOperation(left=other, combinator=operator.mod)) 213 | 214 | def __matmul__(self: MagicT, other: Any) -> MagicT: 215 | return self._extend(CombinationOperation(right=other, combinator=operator.matmul)) 216 | 217 | def __rmatmul__(self: MagicT, other: Any) -> MagicT: 218 | return self._extend(RCombinationOperation(left=other, combinator=operator.matmul)) 219 | 220 | def __pow__(self: MagicT, other: Any) -> MagicT: 221 | return self._extend(CombinationOperation(right=other, combinator=operator.pow)) 222 | 223 | def __rpow__(self: MagicT, other: Any) -> MagicT: 224 | return self._extend(RCombinationOperation(left=other, combinator=operator.pow)) 225 | 226 | def __pos__(self: MagicT) -> MagicT: 227 | return self._extend(FunctionOperation(function=operator.pos)) 228 | 229 | def __neg__(self: MagicT) -> MagicT: 230 | return self._extend(FunctionOperation(function=operator.neg)) 231 | 232 | def is_(self: MagicT, value: Any) -> MagicT: 233 | return self._extend(CombinationOperation(right=value, combinator=operator.is_)) 234 | 235 | def is_not(self: MagicT, value: Any) -> MagicT: 236 | return self._extend(CombinationOperation(right=value, combinator=operator.is_not)) 237 | 238 | def in_(self: MagicT, iterable: Union[Container[Any], MagicT]) -> MagicT: 239 | return self._extend(FunctionOperation(in_op, iterable)) 240 | 241 | def not_in(self: MagicT, iterable: Union[Container[Any], MagicT]) -> MagicT: 242 | return self._extend(FunctionOperation(not_in_op, iterable)) 243 | 244 | def contains(self: MagicT, value: Any) -> MagicT: 245 | return self._extend(FunctionOperation(contains_op, value)) 246 | 247 | def not_contains(self: MagicT, value: Any) -> MagicT: 248 | return self._extend(FunctionOperation(not_contains_op, value)) 249 | 250 | def len(self: MagicT) -> MagicT: 251 | return self._extend(FunctionOperation(len)) 252 | 253 | def regexp( 254 | self: MagicT, 255 | pattern: Union[str, Pattern[str]], 256 | *, 257 | mode: Optional[str] = None, 258 | search: Optional[bool] = None, 259 | flags: Union[int, re.RegexFlag] = 0, 260 | ) -> MagicT: 261 | 262 | if search is not None: 263 | warn( 264 | "Param 'search' is deprecated, use 'mode' instead.", 265 | DeprecationWarning, 266 | ) 267 | 268 | if mode is not None: 269 | msg = "Can't pass both 'search' and 'mode' params." 270 | raise ParamsConflict(msg) 271 | 272 | mode = RegexpMode.SEARCH if search else RegexpMode.MATCH 273 | 274 | if mode is None: 275 | mode = RegexpMode.MATCH 276 | 277 | if isinstance(pattern, str): 278 | pattern = re.compile(pattern, flags=flags) 279 | 280 | regexp_func = getattr(pattern, mode) 281 | return self._extend(FunctionOperation(regexp_func)) 282 | 283 | def func(self: MagicT, func: Callable[[Any], Any], *args: Any, **kwargs: Any) -> MagicT: 284 | return self._extend(FunctionOperation(func, *args, **kwargs)) 285 | 286 | def cast(self: MagicT, func: Callable[[Any], Any]) -> MagicT: 287 | return self._extend(CastOperation(func)) 288 | 289 | def extract(self: MagicT, magic: "MagicT") -> MagicT: 290 | return self._extend(ExtractOperation(magic)) 291 | -------------------------------------------------------------------------------- /magic_filter/operations/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseOperation 2 | from .call import CallOperation 3 | from .cast import CastOperation 4 | from .combination import CombinationOperation, ImportantCombinationOperation, RCombinationOperation 5 | from .comparator import ComparatorOperation 6 | from .extract import ExtractOperation 7 | from .function import FunctionOperation, ImportantFunctionOperation 8 | from .getattr import GetAttributeOperation 9 | from .getitem import GetItemOperation 10 | from .selector import SelectorOperation 11 | 12 | __all__ = ( 13 | "BaseOperation", 14 | "CallOperation", 15 | "CastOperation", 16 | "CombinationOperation", 17 | "ComparatorOperation", 18 | "FunctionOperation", 19 | "GetAttributeOperation", 20 | "GetItemOperation", 21 | "ImportantCombinationOperation", 22 | "ImportantFunctionOperation", 23 | "RCombinationOperation", 24 | "SelectorOperation", 25 | "ExtractOperation", 26 | ) 27 | -------------------------------------------------------------------------------- /magic_filter/operations/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | 5 | class BaseOperation(ABC): 6 | important: bool = False 7 | 8 | @abstractmethod 9 | def resolve(self, value: Any, initial_value: Any) -> Any: # pragma: no cover 10 | pass 11 | -------------------------------------------------------------------------------- /magic_filter/operations/call.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Tuple 2 | 3 | from ..exceptions import RejectOperations 4 | from .base import BaseOperation 5 | 6 | 7 | class CallOperation(BaseOperation): 8 | __slots__ = ("args", "kwargs") 9 | 10 | def __init__(self, args: Tuple[Any, ...], kwargs: Dict[str, Any]): 11 | self.args = args 12 | self.kwargs = kwargs 13 | 14 | def resolve(self, value: Any, initial_value: Any) -> Any: 15 | if not callable(value): 16 | raise RejectOperations(TypeError(f"{value} is not callable")) 17 | return value(*self.args, **self.kwargs) 18 | -------------------------------------------------------------------------------- /magic_filter/operations/cast.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from ..exceptions import RejectOperations 4 | from .base import BaseOperation 5 | 6 | 7 | class CastOperation(BaseOperation): 8 | __slots__ = ("func",) 9 | 10 | def __init__(self, func: Callable[[Any], Any]) -> None: 11 | self.func = func 12 | 13 | def resolve(self, value: Any, initial_value: Any) -> Any: 14 | try: 15 | return self.func(value) 16 | except Exception as e: 17 | raise RejectOperations(e) from e 18 | -------------------------------------------------------------------------------- /magic_filter/operations/combination.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from ..helper import resolve_if_needed 4 | from .base import BaseOperation 5 | 6 | 7 | class CombinationOperation(BaseOperation): 8 | __slots__ = ( 9 | "right", 10 | "combinator", 11 | ) 12 | 13 | def __init__(self, right: Any, combinator: Callable[[Any, Any], bool]) -> None: 14 | self.right = right 15 | self.combinator = combinator 16 | 17 | def resolve(self, value: Any, initial_value: Any) -> Any: 18 | return self.combinator(value, resolve_if_needed(self.right, initial_value=initial_value)) 19 | 20 | 21 | class ImportantCombinationOperation(CombinationOperation): 22 | important = True 23 | 24 | 25 | class RCombinationOperation(BaseOperation): 26 | __slots__ = ( 27 | "left", 28 | "combinator", 29 | ) 30 | 31 | def __init__(self, left: Any, combinator: Callable[[Any, Any], bool]) -> None: 32 | self.left = left 33 | self.combinator = combinator 34 | 35 | def resolve(self, value: Any, initial_value: Any) -> Any: 36 | return self.combinator(resolve_if_needed(self.left, initial_value), value) 37 | -------------------------------------------------------------------------------- /magic_filter/operations/comparator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from ..helper import resolve_if_needed 4 | from .base import BaseOperation 5 | 6 | 7 | class ComparatorOperation(BaseOperation): 8 | __slots__ = ( 9 | "right", 10 | "comparator", 11 | ) 12 | 13 | def __init__(self, right: Any, comparator: Callable[[Any, Any], bool]) -> None: 14 | self.right = right 15 | self.comparator = comparator 16 | 17 | def resolve(self, value: Any, initial_value: Any) -> Any: 18 | return self.comparator(value, resolve_if_needed(self.right, initial_value=initial_value)) 19 | -------------------------------------------------------------------------------- /magic_filter/operations/extract.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Iterable 2 | 3 | from magic_filter.operations import BaseOperation 4 | 5 | if TYPE_CHECKING: 6 | from magic_filter.magic import MagicFilter 7 | 8 | 9 | class ExtractOperation(BaseOperation): 10 | __slots__ = ("extractor",) 11 | 12 | def __init__(self, extractor: "MagicFilter") -> None: 13 | self.extractor = extractor 14 | 15 | def resolve(self, value: Any, initial_value: Any) -> Any: 16 | if not isinstance(value, Iterable): 17 | return None 18 | 19 | result = [] 20 | for item in value: 21 | if self.extractor.resolve(item): 22 | result.append(item) 23 | return result 24 | -------------------------------------------------------------------------------- /magic_filter/operations/function.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from ..exceptions import RejectOperations 4 | from ..helper import resolve_if_needed 5 | from .base import BaseOperation 6 | 7 | 8 | class FunctionOperation(BaseOperation): 9 | __slots__ = ( 10 | "function", 11 | "args", 12 | "kwargs", 13 | ) 14 | 15 | def __init__(self, function: Callable[..., Any], *args: Any, **kwargs: Any) -> None: 16 | self.function = function 17 | self.args = args 18 | self.kwargs = kwargs 19 | 20 | def resolve(self, value: Any, initial_value: Any) -> Any: 21 | try: 22 | return self.function( 23 | *(resolve_if_needed(arg, initial_value) for arg in self.args), 24 | value, 25 | **{key: resolve_if_needed(value, initial_value) for key, value in self.kwargs.items()}, 26 | ) 27 | except (TypeError, ValueError) as e: 28 | raise RejectOperations(e) from e 29 | 30 | 31 | class ImportantFunctionOperation(FunctionOperation): 32 | important = True 33 | -------------------------------------------------------------------------------- /magic_filter/operations/getattr.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Any 3 | 4 | from ..exceptions import RejectOperations 5 | from .base import BaseOperation 6 | 7 | 8 | class GetAttributeOperation(BaseOperation, ABC): 9 | __slots__ = ("name",) 10 | 11 | def __init__(self, name: str) -> None: 12 | self.name = name 13 | 14 | def resolve(self, value: Any, initial_value: Any) -> Any: 15 | try: 16 | return getattr(value, self.name) 17 | except AttributeError as e: 18 | raise RejectOperations(e) from e 19 | -------------------------------------------------------------------------------- /magic_filter/operations/getitem.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable 2 | 3 | from magic_filter.exceptions import RejectOperations, SwitchModeToAll, SwitchModeToAny 4 | 5 | from .base import BaseOperation 6 | 7 | EMPTY_SLICE = slice(None, None, None) 8 | 9 | 10 | class GetItemOperation(BaseOperation): 11 | __slots__ = ("key",) 12 | 13 | def __init__(self, key: Any) -> None: 14 | self.key = key 15 | 16 | def resolve(self, value: Any, initial_value: Any) -> Any: 17 | if isinstance(value, Iterable): 18 | if self.key is ...: 19 | raise SwitchModeToAny() 20 | if self.key == EMPTY_SLICE: 21 | raise SwitchModeToAll(self.key) 22 | try: 23 | return value[self.key] 24 | except (KeyError, IndexError, TypeError) as e: 25 | raise RejectOperations(e) from e 26 | -------------------------------------------------------------------------------- /magic_filter/operations/selector.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from magic_filter.exceptions import RejectOperations 4 | from magic_filter.operations import BaseOperation 5 | 6 | if TYPE_CHECKING: 7 | from magic_filter import MagicFilter 8 | 9 | 10 | class SelectorOperation(BaseOperation): 11 | __slots__ = ("inner",) 12 | 13 | def __init__(self, inner: "MagicFilter"): 14 | self.inner = inner 15 | 16 | def resolve(self, value: Any, initial_value: Any) -> Any: 17 | if self.inner.resolve(value): 18 | return value 19 | raise RejectOperations() 20 | -------------------------------------------------------------------------------- /magic_filter/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiogram/magic-filter/8ea680aa614e5e3c1a55975a87f5f0dc84a3bf15/magic_filter/py.typed -------------------------------------------------------------------------------- /magic_filter/util.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Container 2 | 3 | 4 | def in_op(a: Container[Any], b: Any) -> bool: 5 | try: 6 | return b in a 7 | except TypeError: 8 | return False 9 | 10 | 11 | def not_in_op(a: Container[Any], b: Any) -> bool: 12 | try: 13 | return b not in a 14 | except TypeError: 15 | return False 16 | 17 | 18 | def contains_op(a: Any, b: Container[Any]) -> bool: 19 | try: 20 | return a in b 21 | except TypeError: 22 | return False 23 | 24 | 25 | def not_contains_op(a: Any, b: Container[Any]) -> bool: 26 | try: 27 | return a not in b 28 | except TypeError: 29 | return False 30 | 31 | 32 | def and_op(a: Any, b: Any) -> Any: 33 | return a and b 34 | 35 | 36 | def or_op(a: Any, b: Any) -> Any: 37 | return a or b 38 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | show_error_codes = True 4 | show_error_context = True 5 | pretty = True 6 | ignore_missing_imports = False 7 | warn_unused_configs = True 8 | disallow_subclassing_any = False 9 | disallow_any_generics = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_defs = True 12 | disallow_incomplete_defs = True 13 | check_untyped_defs = True 14 | disallow_untyped_decorators = True 15 | no_implicit_optional = True 16 | warn_redundant_casts = True 17 | warn_unused_ignores = True 18 | warn_return_any = True 19 | follow_imports_for_stubs = True 20 | namespace_packages = True 21 | show_absolute_path = True 22 | 23 | [mypy-cython] 24 | ignore_missing_imports = True 25 | 26 | [mypy-importlib.metadata] 27 | ignore_missing_imports = True 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "magic-filter" 7 | dynamic = ["version"] 8 | description = '' 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = "MIT" 12 | keywords = [ 13 | "magic", 14 | "filter", 15 | "validation" 16 | ] 17 | authors = [ 18 | { name = "Alex Root Junior", email = "pypi@aiogram.dev" }, 19 | ] 20 | classifiers = [ 21 | "Development Status :: 3 - Alpha", 22 | "License :: OSI Approved :: MIT License", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: Implementation :: CPython", 30 | "Topic :: Utilities", 31 | "Typing :: Typed", 32 | ] 33 | dependencies = [] 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "pre-commit~=2.20.0", 38 | "pytest~=7.1.3", 39 | "pytest-cov~=3.0.0", 40 | "pytest-html~=3.1.1", 41 | "flake8~=5.0.4", 42 | "mypy~=1.4.1", 43 | "black~=22.8.0", 44 | "isort~=5.11.5", 45 | "types-setuptools~=65.3.0", 46 | ] 47 | 48 | [project.urls] 49 | Documentation = "https://docs.aiogram.dev/en/dev-3.x/dispatcher/filters/magic_filters.html" 50 | Issues = "https://github.com/aiogram/magic-filter/issues" 51 | Source = "https://github.com/aiogram/magic-filter" 52 | 53 | [tool.hatch.version] 54 | path = "magic_filter/__init__.py" 55 | 56 | [tool.hatch.envs.default] 57 | features = [ 58 | "dev" 59 | ] 60 | 61 | [tool.hatch.envs.default.scripts] 62 | test = "pytest {args:tests}" 63 | test-cov = "coverage run -m pytest {args:tests}" 64 | cov-report = [ 65 | "- coverage combine", 66 | "coverage report", 67 | ] 68 | cov = [ 69 | "test-cov", 70 | "cov-report", 71 | ] 72 | 73 | [tool.black] 74 | target-version = ["py37"] 75 | line-length = 120 76 | skip-string-normalization = true 77 | 78 | [tool.isort] 79 | profile = "black" 80 | line_length = 99 81 | known_first_party = [ 82 | "magic_filter" 83 | ] 84 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiogram/magic-filter/8ea680aa614e5e3c1a55975a87f5f0dc84a3bf15/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_attrdict.py: -------------------------------------------------------------------------------- 1 | from magic_filter import AttrDict 2 | 3 | 4 | class TestAttrDict: 5 | def test_attrdict(self): 6 | attr = AttrDict({"a": 1, "b": 2, "c": "d"}) 7 | 8 | assert attr["a"] == 1 9 | assert attr.a == 1 10 | 11 | assert attr["b"] == 2 12 | assert attr.b == 2 13 | 14 | assert attr["c"] == "d" 15 | assert attr.c == "d" 16 | -------------------------------------------------------------------------------- /tests/test_iterable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from magic_filter import F 4 | 5 | 6 | class TestIterable: 7 | def test_cannot_be_used_as_an_iterable(self): 8 | # If instance of MagicFilter be an Iterable object it can be used as "zip-bomb"-like object, 9 | # because `list(F)` can use 100% of the RAM and crash the application. 10 | with pytest.raises(TypeError): 11 | list(F) 12 | -------------------------------------------------------------------------------- /tests/test_len.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from magic_filter import F 6 | 7 | 8 | class TestLen: 9 | def test_has_no_len(self): 10 | # F object doesn't have len(). But F.len() can be used, 11 | # so it will raise error with suggestion to use F.len() 12 | error_message = "Length can't be taken using len() function. Use MagicFilter.len() instead." 13 | with pytest.raises(TypeError, match=re.escape(error_message)): 14 | len(F) 15 | -------------------------------------------------------------------------------- /tests/test_magic.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections import namedtuple 3 | from typing import Any, NamedTuple, Optional 4 | 5 | import pytest 6 | 7 | from magic_filter import F, MagicFilter, RegexpMode 8 | from magic_filter.exceptions import ParamsConflict 9 | 10 | Job = namedtuple("Job", ["place", "salary", "position"]) 11 | 12 | 13 | class User(NamedTuple): 14 | age: int 15 | about: str 16 | job: Job 17 | favorite_digits: Optional[str] 18 | 19 | def __matmul__(self, other: Any) -> int: # for testing 20 | if isinstance(other, Job): 21 | return self.job.place == other.place # User (at) Job 22 | return NotImplemented 23 | 24 | def resolve(self) -> int: 25 | return str(self) 26 | 27 | 28 | another_user = User( 29 | age=18, 30 | about="18 y.o. junior dev from NY", 31 | job=Job(place="New York", salary=200_000, position="junior developer"), 32 | favorite_digits="test", 33 | ) 34 | 35 | 36 | @pytest.fixture(name="user") 37 | def user(): 38 | yield User( 39 | age=19, 40 | about="Gonna fly to the 'Factory'", 41 | job=Job(place="New York", salary=200_000, position="lead architect"), 42 | favorite_digits="", 43 | ) 44 | 45 | 46 | class TestMagicFilter: 47 | @pytest.mark.parametrize( 48 | "case", 49 | [ 50 | F.age == 19, 51 | F.about.lower() == "gonna fly to the 'factory'", 52 | F.job.place == "New York", 53 | F.job.place.len() == 8, 54 | F.job.salary == 200_000, 55 | ~(F.age == 42), 56 | ~(~(F.age != 42)), 57 | F.age != 42, 58 | F.job.place != "Hogwarts", 59 | F.job.place.len() != 5, 60 | F.job.salary > 100_000, 61 | F.job.salary < 1_000_000, 62 | F.job.salary >= 200_000, 63 | F.job.salary <= 200_000, 64 | F.age, 65 | F.job, 66 | F.age == F.age, 67 | F.age + 1 == 20, 68 | 5 + F.age - 1 == 42 - F.age, 69 | 19 // F.age == F.age // 19, 70 | F.job.salary / F.job.salary == 1 * F.age * (1 / F.age), 71 | F.job.salary % 100 == 0, 72 | 23 % F.age != 0, 73 | -F.job.salary == -(+F.job.salary), 74 | 1**F.age == F.age * (F.age**-1), 75 | F.age >> 2 == 1 << (F.job.salary // 100_000), 76 | (F.age << 2) // 38 == 1_048_576 >> F.age, 77 | F.age & 16 == 16 & F.age, 78 | F.age | 4 == 4 | F.age, 79 | 11 ^ F.age == F.age ^ 11, 80 | F @ F.job, 81 | another_user @ F.job, 82 | F.job.is_not(None), 83 | F.is_(F), 84 | F.attr_("resolve")().contains("User"), 85 | F.job.position.lower().in_(("lead architect",)), 86 | F.age.in_(range(15, 40)), 87 | F.job.place.in_({"New York", "WDC"}), 88 | F.age.not_in(range(40, 100)), 89 | F.about.contains("Factory"), 90 | F.job.place.lower().contains("n"), 91 | F.job.place.upper().contains("N"), 92 | F.job.place.upper().not_contains("A"), 93 | F.job.place.startswith("New"), 94 | F.job.position.endswith("architect"), 95 | F.age.func(lambda v: v in range(142)), 96 | F.job.place.func(str.split, maxsplit=1)[0] == "New", 97 | (F.age == 19) & (F.about.contains("Factory")), 98 | (F.age == 42) | (F.about.contains("Factory")), 99 | F.age & F.job & F.job.place, 100 | F.about.len() == 26, 101 | F.about[0] == "G", 102 | F.about[0].lower() == "g", 103 | F.about[:5] == "Gonna", 104 | F.about[:5].lower() == "gonna", 105 | F.about[:5:2].lower() == "gna", 106 | F.about[6:9] == "fly", 107 | ~~F.about[6:9] == "fly", 108 | F.about[...].islower(), 109 | ~F.about[:].isdigit(), 110 | ~F.job.contains("test"), 111 | F.job[F.salary > 100_000].place == "New York", 112 | F.job[F.salary > 100_000][F.place == "New York"], 113 | ~F.job[F.salary < 100_000], 114 | (F.age.cast(str) + " years" == "19 years"), 115 | ], 116 | ) 117 | def test_operations(self, case: MagicFilter, user: User): 118 | assert case.resolve(user) 119 | assert F.ilter(case)(user) 120 | 121 | @pytest.mark.parametrize("case", [F.about["test"], F.about[100]]) 122 | def test_invalid_get_item(self, case: MagicFilter, user: User): 123 | assert not case.resolve(user) 124 | assert not F.ilter(case)(user) 125 | 126 | @pytest.mark.parametrize( 127 | "value,a,b", 128 | [[None, None, True], ["spam", False, True], ["321", True, False], [42, None, True]], 129 | ) 130 | def test_reject_has_no_attribute(self, value: Any, a: bool, b: bool): 131 | user = User( 132 | age=42, 133 | about="Developer", 134 | job=Job(position="senior-tomato", place="Italy", salary=300_000), 135 | favorite_digits=value, 136 | ) 137 | regular = F.favorite_digits.isdigit() 138 | inverted = ~regular 139 | 140 | assert regular.resolve(user) is a 141 | assert inverted.resolve(user) is b 142 | 143 | def test_exclude_mutually_exclusive_inversions(self, user: User): 144 | case = F.job 145 | assert len(case._operations) == 1 146 | case = ~case 147 | assert len(case._operations) == 2 148 | case = ~case 149 | assert len(case._operations) == 1 150 | case = ~case 151 | assert len(case._operations) == 2 152 | 153 | def test_extract_operation(self): 154 | case = F.extract(F > 2) 155 | assert case.resolve(range(5)) == [3, 4] 156 | 157 | assert not case.resolve(42) 158 | 159 | def test_bool(self): 160 | case = F.foo.bar.baz 161 | assert bool(case) is True 162 | 163 | 164 | class TestMagicRegexpFilter: 165 | @pytest.mark.parametrize( 166 | "case,result", 167 | [ 168 | (F.about.regexp(r"Gonna .+"), True), 169 | (F.about.regexp(r".+"), True), 170 | (F.about.regexp(r"Gonna .+", mode=RegexpMode.MATCH), True), 171 | (F.about.regexp(r"fly"), False), 172 | (F.about.regexp(r"fly", search=False), False), 173 | ], 174 | ) 175 | def test_match(self, case: MagicFilter, user: User, result: bool): 176 | assert bool(case.resolve(user)) is result 177 | 178 | @pytest.mark.parametrize( 179 | "case,result", 180 | [ 181 | (F.about.regexp(r"fly", search=True), True), 182 | (F.about.regexp(r"fly", mode=RegexpMode.SEARCH), True), 183 | (F.about.regexp(r"run", mode=RegexpMode.SEARCH), False), 184 | ], 185 | ) 186 | def test_search(self, case: MagicFilter, user: User, result: bool): 187 | assert bool(case.resolve(user)) is result 188 | 189 | @pytest.mark.parametrize( 190 | "case,result", 191 | [ 192 | (F.job.place.regexp(r"[A-Z]", mode=RegexpMode.FINDALL), ['N', 'Y']), 193 | ], 194 | ) 195 | def test_findall(self, case: MagicFilter, user: User, result: bool): 196 | assert case.resolve(user) == result 197 | 198 | @pytest.mark.parametrize( 199 | "case,result", 200 | [ 201 | (F.about.regexp(r"(\w{5,})", mode=RegexpMode.FINDITER), 202 | ['Gonna', 'Factory']), 203 | ], 204 | ) 205 | def test_finditer(self, case: MagicFilter, user: User, result: bool): 206 | assert [m.group() for m in case.resolve(user)] == result 207 | 208 | @pytest.mark.parametrize( 209 | "case,result", 210 | [ 211 | (F.job.place.regexp(r"New York", mode=RegexpMode.FULLMATCH), True), 212 | (F.job.place.regexp(r"Old York", mode=RegexpMode.FULLMATCH), False), 213 | ], 214 | ) 215 | def test_full_match(self, case: MagicFilter, user: User, result: bool): 216 | assert bool(case.resolve(user)) is result 217 | 218 | @pytest.mark.parametrize("search", [True, False]) 219 | def test_search_deprecation(self, search): 220 | with warnings.catch_warnings(record=True) as w: 221 | warnings.simplefilter("always") 222 | F.about.regexp(r"test deprecation", search=search) 223 | assert len(w) == 1 224 | assert issubclass(w[-1].category, DeprecationWarning) 225 | 226 | @pytest.mark.parametrize( 227 | "mode", 228 | [ 229 | RegexpMode.SEARCH, 230 | RegexpMode.MATCH, 231 | RegexpMode.FULLMATCH, 232 | RegexpMode.FINDALL, 233 | RegexpMode.FINDITER, 234 | ] 235 | ) 236 | @pytest.mark.parametrize("search", [True, False]) 237 | def test_params_conflict(self, search, mode): 238 | with pytest.raises(ParamsConflict): 239 | F.about.regexp(r"", search=search, mode=mode) 240 | --------------------------------------------------------------------------------