2021-02-28 18:09:32 +00:00
|
|
|
"""Pattern-matching utilities"""
|
|
|
|
|
|
|
|
import dataclasses
|
2023-09-24 13:19:59 +00:00
|
|
|
import itertools
|
2021-02-28 18:09:32 +00:00
|
|
|
import re
|
2021-03-01 19:18:09 +00:00
|
|
|
from typing import Dict, List, Optional, Union
|
2021-02-28 18:09:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Operator:
|
|
|
|
"""Used as a wildcards and operators when matching message arguments
|
|
|
|
(see assertMessageMatch and match_list)"""
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2022-03-05 08:52:18 +00:00
|
|
|
class _AnyStr(Operator):
|
2021-02-28 18:09:32 +00:00
|
|
|
"""Wildcard matching any string"""
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
2022-03-05 08:52:18 +00:00
|
|
|
return "ANYSTR"
|
2021-02-28 18:09:32 +00:00
|
|
|
|
|
|
|
|
2022-03-05 08:52:18 +00:00
|
|
|
class _AnyOptStr(Operator):
|
2021-03-01 19:18:09 +00:00
|
|
|
"""Wildcard matching any string as well as None"""
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
2022-03-05 08:52:18 +00:00
|
|
|
return "ANYOPTSTR"
|
2021-03-01 19:18:09 +00:00
|
|
|
|
|
|
|
|
2023-09-24 13:19:59 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class OptStrRe(Operator):
|
|
|
|
regexp: str
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
return f"OptStrRe(r'{self.regexp}')"
|
|
|
|
|
|
|
|
|
2021-03-01 19:18:09 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
2021-02-28 18:09:32 +00:00
|
|
|
class StrRe(Operator):
|
|
|
|
regexp: str
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
return f"StrRe(r'{self.regexp}')"
|
|
|
|
|
|
|
|
|
2021-07-03 07:31:51 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class NotStrRe(Operator):
|
|
|
|
regexp: str
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
return f"NotStrRe(r'{self.regexp}')"
|
|
|
|
|
|
|
|
|
2021-12-23 16:15:10 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class InsensitiveStr(Operator):
|
|
|
|
string: str
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
return f"InsensitiveStr({self.string!r})"
|
|
|
|
|
|
|
|
|
2021-03-01 19:18:09 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class RemainingKeys(Operator):
|
|
|
|
"""Used in a dict pattern to match all remaining keys.
|
|
|
|
May only be present once."""
|
|
|
|
|
|
|
|
key: Operator
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
2022-03-05 08:47:29 +00:00
|
|
|
return f"RemainingKeys({self.key!r})"
|
2021-03-01 19:18:09 +00:00
|
|
|
|
|
|
|
|
2022-03-05 08:52:18 +00:00
|
|
|
ANYSTR = _AnyStr()
|
2021-02-28 18:09:32 +00:00
|
|
|
"""Singleton, spares two characters"""
|
|
|
|
|
2022-03-05 08:52:18 +00:00
|
|
|
ANYOPTSTR = _AnyOptStr()
|
|
|
|
"""Singleton, spares two characters"""
|
|
|
|
|
|
|
|
ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR}
|
2021-03-01 19:18:09 +00:00
|
|
|
"""Matches any dictionary; useful to compare tags dict, eg.
|
|
|
|
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
|
|
|
|
|
|
|
|
|
2021-12-11 12:02:47 +00:00
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
|
|
class ListRemainder:
|
|
|
|
item: Operator
|
|
|
|
min_length: int = 0
|
|
|
|
|
2021-07-02 19:41:35 +00:00
|
|
|
def __repr__(self) -> str:
|
2021-12-11 12:02:47 +00:00
|
|
|
if self.min_length:
|
2022-03-05 08:47:29 +00:00
|
|
|
return f"ListRemainder({self.item!r}, min_length={self.min_length})"
|
2022-03-05 08:53:24 +00:00
|
|
|
elif self.item is ANYSTR:
|
|
|
|
return "*ANYLIST"
|
2021-12-11 12:02:47 +00:00
|
|
|
else:
|
2022-03-05 08:47:29 +00:00
|
|
|
return f"ListRemainder({self.item!r})"
|
2021-07-02 19:41:35 +00:00
|
|
|
|
|
|
|
|
2021-12-11 12:02:47 +00:00
|
|
|
ANYLIST = [ListRemainder(ANYSTR)]
|
2021-07-02 19:41:35 +00:00
|
|
|
"""Matches any list remainder"""
|
|
|
|
|
|
|
|
|
2021-03-01 19:18:09 +00:00
|
|
|
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
|
2022-03-05 08:52:18 +00:00
|
|
|
if isinstance(expected, _AnyOptStr):
|
2021-03-01 19:18:09 +00:00
|
|
|
return True
|
2022-03-05 08:52:18 +00:00
|
|
|
elif isinstance(expected, _AnyStr) and got is not None:
|
2021-03-01 19:18:09 +00:00
|
|
|
return True
|
|
|
|
elif isinstance(expected, StrRe):
|
2024-04-16 19:05:25 +00:00
|
|
|
if got is None or not re.match(expected.regexp + "$", got):
|
2021-03-01 19:18:09 +00:00
|
|
|
return False
|
2023-09-24 13:19:59 +00:00
|
|
|
elif isinstance(expected, OptStrRe):
|
|
|
|
if got is None:
|
|
|
|
return True
|
2024-04-16 19:05:25 +00:00
|
|
|
if not re.match(expected.regexp + "$", got):
|
2023-09-24 13:19:59 +00:00
|
|
|
return False
|
2021-07-03 07:31:51 +00:00
|
|
|
elif isinstance(expected, NotStrRe):
|
2024-04-16 19:05:25 +00:00
|
|
|
if got is None or re.match(expected.regexp + "$", got):
|
2021-07-03 07:31:51 +00:00
|
|
|
return False
|
2021-12-23 16:15:10 +00:00
|
|
|
elif isinstance(expected, InsensitiveStr):
|
|
|
|
if got is None or got.lower() != expected.string.lower():
|
|
|
|
return False
|
2021-03-01 19:18:09 +00:00
|
|
|
elif isinstance(expected, Operator):
|
|
|
|
raise NotImplementedError(f"Unsupported operator: {expected}")
|
|
|
|
elif got != expected:
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2021-02-28 18:09:32 +00:00
|
|
|
|
2021-03-01 19:18:09 +00:00
|
|
|
def match_list(
|
|
|
|
got: List[Optional[str]], expected: List[Union[str, None, Operator]]
|
|
|
|
) -> bool:
|
2021-02-28 18:09:32 +00:00
|
|
|
"""Returns True iff the list are equal.
|
2021-03-01 19:18:09 +00:00
|
|
|
|
|
|
|
The ANYSTR operator can be used on the 'expected' side as a wildcard,
|
|
|
|
matching any *single* value; and StrRe("<regexp>") can be used to match regular
|
|
|
|
expressions"""
|
2021-12-11 12:02:47 +00:00
|
|
|
if expected and isinstance(expected[-1], ListRemainder):
|
|
|
|
# Expand the 'expected' list to have as many items as the 'got' list
|
|
|
|
expected = list(expected) # copy
|
|
|
|
remainder = expected.pop()
|
|
|
|
nb_remaining_items = len(got) - len(expected)
|
|
|
|
expected += [remainder.item] * max(nb_remaining_items, remainder.min_length)
|
|
|
|
|
2023-09-24 13:19:59 +00:00
|
|
|
nb_optionals = 0
|
|
|
|
for expected_value in expected:
|
|
|
|
if isinstance(expected_value, (_AnyOptStr, OptStrRe)):
|
|
|
|
nb_optionals += 1
|
|
|
|
else:
|
|
|
|
if nb_optionals > 0:
|
|
|
|
raise NotImplementedError("Optional values in non-final position")
|
|
|
|
|
|
|
|
if not (len(expected) - nb_optionals <= len(got) <= len(expected)):
|
2021-02-28 18:09:32 +00:00
|
|
|
return False
|
2021-03-01 19:18:09 +00:00
|
|
|
return all(
|
|
|
|
match_string(got_value, expected_value)
|
2023-09-24 13:19:59 +00:00
|
|
|
for (got_value, expected_value) in itertools.zip_longest(got, expected)
|
2021-03-01 19:18:09 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def match_dict(
|
|
|
|
got: Dict[str, Optional[str]],
|
|
|
|
expected: Dict[Union[str, Operator], Union[str, Operator, None]],
|
|
|
|
) -> bool:
|
|
|
|
"""Returns True iff the list are equal.
|
|
|
|
|
|
|
|
The ANYSTR operator can be used on the 'expected' side as a wildcard,
|
|
|
|
matching any *single* value; and StrRe("<regexp>") can be used to match regular
|
|
|
|
expressions
|
|
|
|
Additionally, the Keys() operator can be used to match remaining keys, and
|
|
|
|
ANYDICT to match any remaining dict"""
|
|
|
|
got = dict(got) # shallow copy, as we will remove keys
|
|
|
|
|
|
|
|
# Set to not-None if we find a Keys() operator in the dict keys
|
|
|
|
remaining_keys_wildcard = None
|
|
|
|
|
Bump linter versions (#188)
The isort we had has some weird poetry issue, I figured I might as well
bump the other linters at the same time
```
[INFO] Installing environment for https://github.com/PyCQA/isort.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
An unexpected error has occurred: CalledProcessError: command: ('/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/bin/python', '-mpip', 'install', '.')
return code: 1
stdout:
Processing /home/runner/.cache/pre-commit/repo0m3eczdf
Installing build dependencies: started
Installing build dependencies: finished with status 'done'
Getting requirements to build wheel: started
Getting requirements to build wheel: finished with status 'done'
Preparing metadata (pyproject.toml): started
Preparing metadata (pyproject.toml): finished with status 'error'
stderr:
error: subprocess-exited-with-error
× Preparing metadata (pyproject.toml) did not run successfully.
│ exit code: 1
╰─> [14 lines of output]
Traceback (most recent call last):
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
main()
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
json_out['return_val'] = hook(**hook_input['kwargs'])
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 149, in prepare_metadata_for_build_wheel
return hook(metadata_directory, config_settings)
File "/tmp/pip-build-env-beaf5dxh/overlay/lib/python3.7/site-packages/poetry/core/masonry/api.py", line 40, in prepare_metadata_for_build_wheel
poetry = Factory().create_poetry(Path(".").resolve(), with_groups=False)
File "/tmp/pip-build-env-beaf5dxh/overlay/lib/python3.7/site-packages/poetry/core/factory.py", line 57, in create_poetry
raise RuntimeError("The Poetry configuration is invalid:\n" + message)
RuntimeError: The Poetry configuration is invalid:
- [extras.pipfile_deprecated_finder.2] 'pip-shims<=0.3.4' does not match '^[a-zA-Z-_.0-9]+$'
[end of output]
note: This error originates from a subprocess, and is likely not a problem with pip.
error: metadata-generation-failed
× Encountered error while generating package metadata.
╰─> See above for output.
note: This is an issue with the package mentioned above, not pip.
hint: See above for details.
```
2023-03-04 09:51:40 +00:00
|
|
|
for expected_key, expected_value in expected.items():
|
2021-03-01 19:18:09 +00:00
|
|
|
if isinstance(expected_key, RemainingKeys):
|
|
|
|
remaining_keys_wildcard = (expected_key.key, expected_value)
|
2021-02-28 18:09:32 +00:00
|
|
|
else:
|
2022-04-28 18:12:18 +00:00
|
|
|
for key in got:
|
|
|
|
if match_string(key, expected_key) and match_string(
|
|
|
|
got[key], expected_value
|
|
|
|
):
|
|
|
|
got.pop(key)
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
# Found no (key, value) pair matching the request
|
2021-03-01 19:18:09 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
if remaining_keys_wildcard:
|
|
|
|
(expected_key, expected_value) = remaining_keys_wildcard
|
Bump linter versions (#188)
The isort we had has some weird poetry issue, I figured I might as well
bump the other linters at the same time
```
[INFO] Installing environment for https://github.com/PyCQA/isort.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
An unexpected error has occurred: CalledProcessError: command: ('/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/bin/python', '-mpip', 'install', '.')
return code: 1
stdout:
Processing /home/runner/.cache/pre-commit/repo0m3eczdf
Installing build dependencies: started
Installing build dependencies: finished with status 'done'
Getting requirements to build wheel: started
Getting requirements to build wheel: finished with status 'done'
Preparing metadata (pyproject.toml): started
Preparing metadata (pyproject.toml): finished with status 'error'
stderr:
error: subprocess-exited-with-error
× Preparing metadata (pyproject.toml) did not run successfully.
│ exit code: 1
╰─> [14 lines of output]
Traceback (most recent call last):
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
main()
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
json_out['return_val'] = hook(**hook_input['kwargs'])
File "/home/runner/.cache/pre-commit/repo0m3eczdf/py_env-python3.7/lib/python3.7/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 149, in prepare_metadata_for_build_wheel
return hook(metadata_directory, config_settings)
File "/tmp/pip-build-env-beaf5dxh/overlay/lib/python3.7/site-packages/poetry/core/masonry/api.py", line 40, in prepare_metadata_for_build_wheel
poetry = Factory().create_poetry(Path(".").resolve(), with_groups=False)
File "/tmp/pip-build-env-beaf5dxh/overlay/lib/python3.7/site-packages/poetry/core/factory.py", line 57, in create_poetry
raise RuntimeError("The Poetry configuration is invalid:\n" + message)
RuntimeError: The Poetry configuration is invalid:
- [extras.pipfile_deprecated_finder.2] 'pip-shims<=0.3.4' does not match '^[a-zA-Z-_.0-9]+$'
[end of output]
note: This error originates from a subprocess, and is likely not a problem with pip.
error: metadata-generation-failed
× Encountered error while generating package metadata.
╰─> See above for output.
note: This is an issue with the package mentioned above, not pip.
hint: See above for details.
```
2023-03-04 09:51:40 +00:00
|
|
|
for key, value in got.items():
|
2021-03-01 19:18:09 +00:00
|
|
|
if not match_string(key, expected_key):
|
|
|
|
return False
|
|
|
|
if not match_string(value, expected_value):
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
else:
|
|
|
|
# There should be nothing left unmatched in the dict
|
|
|
|
return got == {}
|