pytester-utils 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ from pytester_utils import errors
2
+ from pytester_utils._plugin import PYTESTER_RUN_METHODS, PytesterRunMethod
3
+ from pytester_utils.pytester_utils import (
4
+ OutputMatchPattern,
5
+ PytesterOutcomes,
6
+ PytesterTestCase,
7
+ TestFiles,
8
+ get_default_env,
9
+ get_default_pytest_args,
10
+ run_pytester,
11
+ set_default_env,
12
+ set_default_pytest_args,
13
+ )
14
+ from pytester_utils.test_file import (
15
+ AnyTestFile,
16
+ FunctionTestFile,
17
+ RawFileFunction,
18
+ TestFile,
19
+ TestFileBuilder,
20
+ TestFileMetadata,
21
+ )
22
+
23
+ __all__ = [
24
+ "PYTESTER_RUN_METHODS",
25
+ "AnyTestFile",
26
+ "FunctionTestFile",
27
+ "OutputMatchPattern",
28
+ "PytesterOutcomes",
29
+ "PytesterRunMethod",
30
+ "PytesterTestCase",
31
+ "RawFileFunction",
32
+ "TestFile",
33
+ "TestFileBuilder",
34
+ "TestFileMetadata",
35
+ "TestFiles",
36
+ "errors",
37
+ "get_default_env",
38
+ "get_default_pytest_args",
39
+ "run_pytester",
40
+ "set_default_env",
41
+ "set_default_pytest_args",
42
+ ]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import textwrap
6
+ import warnings
7
+ from collections.abc import Callable, Mapping
8
+ from typing import Any, cast
9
+
10
+ from pytester_utils.errors import (
11
+ ArgumentWithDefaultValueError,
12
+ MissingInjectionVariablesError,
13
+ UnusedInjectedVariablesWarning,
14
+ )
15
+
16
+
17
+ def _build_assign_statements(variables: Mapping[str, Any]) -> list[ast.Assign]:
18
+ variable_statements: list[ast.Assign] = []
19
+
20
+ for variable, value in variables.items():
21
+ if inspect.isfunction(value) or inspect.ismethod(value):
22
+ assignment_value = ast.Name(value.__name__)
23
+ else:
24
+ assignment_value = ast.Constant(value)
25
+
26
+ assign_statement = ast.Assign(
27
+ targets=[ast.Name(variable)],
28
+ value=assignment_value,
29
+ lineno=1,
30
+ )
31
+ variable_statements.append(assign_statement)
32
+
33
+ return variable_statements
34
+
35
+
36
+ def _create_variable_injection_lines(variables: Mapping[str, Any]) -> list[str]:
37
+ if len(variables) == 0:
38
+ return []
39
+
40
+ variable_statements = _build_assign_statements(variables)
41
+ lines = [ast.unparse(statement) for statement in variable_statements]
42
+ return lines
43
+
44
+
45
+ def _get_function_def[**P, R](func: Callable[P, R]) -> ast.FunctionDef:
46
+ source_lines = inspect.getsourcelines(func)[0]
47
+ source = "\n".join(source_lines)
48
+ source = textwrap.dedent(source)
49
+
50
+ module = ast.parse(source)
51
+ statements = module.body
52
+ function_def: ast.FunctionDef | None = None
53
+ for statement in statements:
54
+ if isinstance(statement, ast.FunctionDef):
55
+ function_def = statement
56
+ break
57
+ if function_def is None:
58
+ # This is a runtime error because it should not be possible as we always take a func as an argument
59
+ msg = "Failed to get ast function definition from function."
60
+ raise RuntimeError(msg)
61
+ return function_def
62
+
63
+
64
+ def _verify_valid_function(function_signature: inspect.Signature, variables: Mapping[str, Any]) -> None:
65
+ for param in function_signature.parameters.values():
66
+ if param.default is not inspect.Parameter.empty:
67
+ raise ArgumentWithDefaultValueError(str(param))
68
+
69
+ unused_variables = [variable for variable in variables if variable not in function_signature.parameters]
70
+ if len(unused_variables) > 0:
71
+ warnings.warn(UnusedInjectedVariablesWarning(unused_variables), stacklevel=2)
72
+
73
+ missing_variables = [variable for variable in function_signature.parameters if variable not in variables]
74
+ if len(missing_variables) > 0:
75
+ raise MissingInjectionVariablesError(missing_variables)
76
+
77
+
78
+ def _get_module_imports_from_function_def[**P, R](func: Callable[P, R]) -> list[str]:
79
+ module = inspect.getmodule(func)
80
+ if module is None:
81
+ # This is a runtime error because it should not be possible as we always take a func as an argument
82
+ msg = "Failed to get module from function."
83
+ raise RuntimeError(msg)
84
+ tree = ast.parse(inspect.getsource(module))
85
+
86
+ imports: list[str] = [ast.unparse(node) for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
87
+
88
+ return imports
89
+
90
+
91
+ def get_function_body_source_lines[**P, R](
92
+ func: Callable[P, R],
93
+ variables: Mapping[str, Any],
94
+ auto_inject_imports: bool,
95
+ ) -> str:
96
+ """
97
+ Get the source lines for a function body with optional injected imports and variables.
98
+
99
+ Args:
100
+ func (Callable[P, R]): Function to extract the source lines from.
101
+ variables (Mapping[str, Any]): Mapping of variables to inject to the top of the result.
102
+ auto_inject_imports (bool): Whether to automagically prepend the defining
103
+ module imports to the top of the result.
104
+
105
+ Returns:
106
+ str: Source lines of the given function, with the optional modifications.
107
+ """
108
+ function_signature = inspect.signature(func)
109
+ _verify_valid_function(function_signature, variables)
110
+ variables = {variable: value for variable, value in variables.items() if variable in function_signature.parameters}
111
+
112
+ function_def = _get_function_def(func)
113
+
114
+ function_contents = ast.unparse(cast("ast.AST", function_def.body))
115
+ function_contents = textwrap.dedent(function_contents)
116
+
117
+ variable_injection_lines = _create_variable_injection_lines(variables)
118
+ if len(variable_injection_lines) > 0:
119
+ variable_injection_lines.append("")
120
+
121
+ imports: list[str] = []
122
+ if auto_inject_imports:
123
+ imports = _get_module_imports_from_function_def(func)
124
+ if len(imports) > 0:
125
+ imports.append("")
126
+
127
+ function_contents = "\n".join(
128
+ [
129
+ *imports,
130
+ *variable_injection_lines,
131
+ function_contents,
132
+ ],
133
+ )
134
+ return function_contents
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import unittest.mock
6
+ from collections.abc import Callable, Generator, Mapping, Sequence
7
+ from contextlib import contextmanager
8
+ from typing import Any, cast
9
+
10
+ _ENV_VARS_TO_REMOVE_ON_SANITIZE = {
11
+ "LC_CTYPE",
12
+ "LINES",
13
+ "COLUMNS",
14
+ }
15
+ _ENV_VAR_PREFIXES_TO_REMOVE_ON_SANITIZE = {
16
+ "PYTEST_",
17
+ "PYTESTER_",
18
+ }
19
+
20
+
21
+ def _get_current_env() -> dict[str, str]:
22
+ # It is important to copy the environment dict, since we don't
23
+ # want it to be modified when the env itself is modified
24
+ return dict(os.environ)
25
+
26
+
27
+ def _set_current_env(env: Mapping[str, str]) -> None:
28
+ os.environ.clear()
29
+ os.environ.update(env)
30
+
31
+
32
+ def _sanitize_env(env: Mapping[str, str]) -> dict[str, str]:
33
+ env = dict(env)
34
+ keys_to_pop = {
35
+ key
36
+ for key in env
37
+ if key in _ENV_VARS_TO_REMOVE_ON_SANITIZE
38
+ or any(key.startswith(prefix) for prefix in _ENV_VAR_PREFIXES_TO_REMOVE_ON_SANITIZE)
39
+ }
40
+ for key in keys_to_pop:
41
+ env.pop(key, None)
42
+ return env
43
+
44
+
45
+ def _resolve_env_to_patch(
46
+ env: Mapping[str, str],
47
+ inherit: bool,
48
+ to_inherit: Mapping[str, str],
49
+ ) -> dict[str, str]:
50
+ new_env = {**(to_inherit if inherit else {}), **env}
51
+ sanitized_new_env = _sanitize_env(new_env)
52
+ return sanitized_new_env
53
+
54
+
55
+ @contextmanager
56
+ def _patch_current_env(env: Mapping[str, str], inherit: bool) -> Generator[None]:
57
+ snapshot = _get_current_env()
58
+ new_env = _resolve_env_to_patch(env, inherit, snapshot)
59
+ try:
60
+ _set_current_env(new_env)
61
+ yield
62
+ finally:
63
+ _set_current_env(snapshot)
64
+
65
+
66
+ @contextmanager
67
+ def _patch_popen_env(env: Mapping[str, str], inherit: bool) -> Generator[None]:
68
+ snapshot = _get_current_env()
69
+ new_env = _resolve_env_to_patch(env, inherit, snapshot)
70
+
71
+ original_popen = subprocess.Popen
72
+
73
+ def _patched_popen(*args: Any, **kwargs: Any) -> subprocess.Popen:
74
+ if "env" not in kwargs:
75
+ kwargs["env"] = new_env
76
+ proc = original_popen(*args, **kwargs)
77
+ return proc
78
+
79
+ if original_popen.__name__ == _patched_popen.__name__:
80
+ yield # TODO: #3 Properly support nested patching of Popen
81
+ else:
82
+ with unittest.mock.patch(
83
+ original_popen.__module__ + "." + original_popen.__name__,
84
+ _patched_popen,
85
+ ):
86
+ yield
87
+
88
+
89
+ type _EnvPatcher = Callable[[Mapping[str, str], bool], Generator[None]]
90
+ _ENV_PATCHERS = cast("list[_EnvPatcher]", [_patch_current_env, _patch_popen_env])
91
+
92
+
93
+ @contextmanager
94
+ def _run_env_patchers(
95
+ env: Mapping[str, str],
96
+ inherit: bool,
97
+ patchers: Sequence[_EnvPatcher] | None = None,
98
+ ) -> Generator[None]:
99
+ if patchers is None:
100
+ patchers = _ENV_PATCHERS
101
+
102
+ if len(patchers) == 0:
103
+ yield
104
+ else:
105
+ patchers = list(patchers)
106
+ patcher = patchers.pop(0)
107
+ with patcher(env, inherit), _run_env_patchers(env, inherit, patchers): # ty: ignore[invalid-context-manager]
108
+ yield
109
+
110
+
111
+ @contextmanager
112
+ def patch_env(env: Mapping[str, str], inherit: bool) -> Generator[None]:
113
+ """
114
+ Patch the current environment with the given environment.
115
+
116
+ Args:
117
+ env (Mapping[str, str]): Environment mapping, describing the new environment.
118
+ inherit (bool): Whether to inherit the current environment and apply the new env on top of it.
119
+ """
120
+ with _run_env_patchers(env, inherit):
121
+ yield
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Literal, cast, get_args
5
+
6
+ import pytest
7
+
8
+ from pytester_utils.errors import FixtureRequestNotAvailableError
9
+
10
+ type PytesterRunMethod = Literal["inprocess", "subprocess"]
11
+ """Method for running pytester sessions, either in the same process as the test or in a separate subprocess."""
12
+
13
+ PYTESTER_RUN_METHODS: set[PytesterRunMethod] = set(get_args(PytesterRunMethod.__value__))
14
+ _DEFAULT_PYTESTER_RUN_METHOD: PytesterRunMethod = "subprocess"
15
+
16
+ PYTESTER_RUN_METHOD_CLI_FLAG = "--pytester-run-method"
17
+ _PYTESTER_RUN_METHOD_PYTESTER_VAR = "pytester_run_method"
18
+ PYTESTER_RUN_METHOD_ENV_VAR = "PYTESTER_RUN_METHOD"
19
+
20
+ _CLI_PARSER_GROUP = "pytester-utils"
21
+
22
+
23
+ def pytest_addoption(parser: pytest.Parser) -> None:
24
+ group = parser.getgroup(_CLI_PARSER_GROUP)
25
+
26
+ pytester_run_methods = [
27
+ f"{_DEFAULT_PYTESTER_RUN_METHOD} (default)",
28
+ *[method for method in PYTESTER_RUN_METHODS if method != _DEFAULT_PYTESTER_RUN_METHOD],
29
+ ]
30
+ group.addoption(
31
+ PYTESTER_RUN_METHOD_CLI_FLAG,
32
+ dest=_PYTESTER_RUN_METHOD_PYTESTER_VAR,
33
+ help=f"Run method for pytester via {PYTESTER_RUN_METHOD_CLI_FLAG}, choices: {', '.join(pytester_run_methods)}",
34
+ type=str,
35
+ default=None,
36
+ action="store",
37
+ choices=list(PYTESTER_RUN_METHODS),
38
+ required=False,
39
+ )
40
+
41
+
42
+ def pytest_configure(config: pytest.Config) -> None:
43
+ _parse_pytester_run_method_from_cli(config)
44
+
45
+
46
+ def _parse_pytester_run_method(value: str | None, source: str) -> PytesterRunMethod | None:
47
+ if value is None or not isinstance(value, str):
48
+ return None
49
+
50
+ pytester_run_method = value.strip().lower()
51
+ if pytester_run_method in PYTESTER_RUN_METHODS:
52
+ return cast("PytesterRunMethod", pytester_run_method)
53
+
54
+ msg = f"Invalid value for {source}: {value!r}. Valid choices are: {list(PYTESTER_RUN_METHODS)!r}"
55
+ raise pytest.UsageError(msg)
56
+
57
+
58
+ def _parse_pytester_run_method_from_cli(config: pytest.Config) -> PytesterRunMethod | None:
59
+ pytester_run_method_arg = cast("str | None", config.getoption(PYTESTER_RUN_METHOD_CLI_FLAG, None))
60
+ return _parse_pytester_run_method(pytester_run_method_arg, f"command-line option {PYTESTER_RUN_METHOD_CLI_FLAG!r}")
61
+
62
+
63
+ def _parse_pytester_run_method_from_env() -> PytesterRunMethod | None:
64
+ pytester_run_method_env = os.environ.get(PYTESTER_RUN_METHOD_ENV_VAR, None)
65
+ return _parse_pytester_run_method(pytester_run_method_env, f"environment variable {PYTESTER_RUN_METHOD_ENV_VAR!r}")
66
+
67
+
68
+ def get_default_pytester_run_method(config: pytest.Config) -> PytesterRunMethod:
69
+ pytester_run_method_from_cli = _parse_pytester_run_method_from_cli(config)
70
+ if pytester_run_method_from_cli is not None:
71
+ return pytester_run_method_from_cli
72
+
73
+ pytester_run_method_from_env = _parse_pytester_run_method_from_env()
74
+ if pytester_run_method_from_env is not None:
75
+ return pytester_run_method_from_env
76
+
77
+ return _DEFAULT_PYTESTER_RUN_METHOD
78
+
79
+
80
+ _request: pytest.FixtureRequest | None = None
81
+
82
+
83
+ def get_request() -> pytest.FixtureRequest:
84
+ if _request is None:
85
+ raise FixtureRequestNotAvailableError
86
+ return _request
87
+
88
+
89
+ def pytest_runtest_setup(item: pytest.Item) -> None:
90
+ if not isinstance(item, pytest.Function):
91
+ return
92
+ global _request # noqa: PLW0603 - global-statement
93
+ _request = item._request # noqa: SLF001 - private-member-access
94
+
95
+
96
+ def pytest_runtest_teardown() -> None:
97
+ global _request # noqa: PLW0603 - global-statement
98
+ _request = None
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class InvalidFileFunctionError(SyntaxError):
7
+ """Raised when a file function is invalid."""
8
+
9
+ def __init__(self, reason: str, *args: Any) -> None:
10
+ self.reason = reason
11
+ super().__init__(f"Invalid file function: {reason}", *args)
12
+
13
+
14
+ class ArgumentWithDefaultValueError(InvalidFileFunctionError):
15
+ """Raised when a file function has an argument with a default value, which is invalid for file functions."""
16
+
17
+ def __init__(self, argument: str) -> None:
18
+ self.argument = argument
19
+ super().__init__(
20
+ f"The function has an argument '{argument}' with a default value, which is invalid "
21
+ "for file functions. Try using `TestFile.build()` instead.",
22
+ )
23
+
24
+
25
+ class NotConvertibleToTestFileError(ValueError):
26
+ """Raised when a function cannot be converted into a `TestFile`."""
27
+
28
+ def __init__(self, func: Any) -> None: # noqa: ANN401 - any-type
29
+ self.func = func
30
+ super().__init__(f"Cannot convert {func} into a TestFile.")
31
+
32
+
33
+ class FixtureRequestNotAvailableError(ValueError):
34
+ """Raised when the fixture request is not available at a desired stage."""
35
+
36
+ def __init__(self) -> None:
37
+ super().__init__("Fixture request is not available at this stage.")
38
+
39
+
40
+ class DuplicateSpecialFilesError(ValueError):
41
+ """Raised when special files are defined multiple times."""
42
+
43
+ def __init__(self, names: list[str]) -> None:
44
+ self.names = names
45
+ super().__init__(
46
+ f"The files {names!r} must be defined up to once each but were "
47
+ "defined twice, once via their fields and once via extra_files.",
48
+ )
49
+
50
+
51
+ class UnusedInjectedVariablesWarning(UserWarning):
52
+ """Raised when extra variables are provided for function body injection."""
53
+
54
+ def __init__(self, unused_variables: list[str]) -> None:
55
+ self.unused_variables = unused_variables
56
+ super().__init__(f"Unused variables were injected: {unused_variables!r}")
57
+
58
+
59
+ class MissingInjectionVariablesError(ValueError):
60
+ """Raised when required function parameters were not provided in injected variables."""
61
+
62
+ def __init__(self, missing_variables: list[str]) -> None:
63
+ self.missing_variables = missing_variables
64
+ super().__init__(f"Missing injection variables: {missing_variables!r}.")
File without changes
@@ -0,0 +1,484 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import abstractmethod
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Literal, Protocol, Self, overload
8
+
9
+ import pytest
10
+ from pydantic import (
11
+ BaseModel,
12
+ ConfigDict,
13
+ Field,
14
+ NonNegativeInt,
15
+ computed_field,
16
+ model_validator,
17
+ validate_call,
18
+ )
19
+
20
+ from pytester_utils._env_patching import patch_env
21
+ from pytester_utils._plugin import (
22
+ PYTESTER_RUN_METHOD_CLI_FLAG,
23
+ PytesterRunMethod,
24
+ get_default_pytester_run_method,
25
+ get_request,
26
+ )
27
+ from pytester_utils.errors import DuplicateSpecialFilesError
28
+ from pytester_utils.test_file import AnyTestFile, TestFile
29
+
30
+
31
+ class _DefaultParams(BaseModel):
32
+ pytest_args: list[str] = Field(default_factory=lambda: ["-l", *(["-v"] * 3), "-rA", "--log-cli-level=INFO"])
33
+ env_vars: dict[str, str] = Field(default_factory=dict)
34
+
35
+
36
+ _default_params = _DefaultParams()
37
+
38
+
39
+ def get_default_pytest_args() -> list[str]:
40
+ """Get a copy of the default pytest arguments used for running nested pytest sessions."""
41
+ return list(_default_params.pytest_args)
42
+
43
+
44
+ def get_default_env() -> dict[str, str]:
45
+ """Get a copy of the default environment variables used for running nested pytest sessions."""
46
+ return dict(_default_params.env_vars)
47
+
48
+
49
+ def set_default_pytest_args(pytest_args: Sequence[str]) -> None:
50
+ """Set the default pytest arguments used for running nested pytest sessions."""
51
+ _default_params.pytest_args = list(pytest_args)
52
+
53
+
54
+ def set_default_env(env_vars: Mapping[str, str]) -> None:
55
+ """Set the default environment variables used for running nested pytest sessions."""
56
+ _default_params.env_vars = dict(env_vars)
57
+
58
+
59
+ def _assert_pytester_exit_code(
60
+ result: pytest.RunResult,
61
+ assert_exit_code: pytest.ExitCode | None,
62
+ ) -> None:
63
+ if assert_exit_code is None:
64
+ return
65
+
66
+ assert result.ret == assert_exit_code, (
67
+ f"Expected pytest exit code to be {assert_exit_code}, but got {result.ret}. "
68
+ "See result.stdout and result.stderr for more details."
69
+ )
70
+
71
+
72
+ def _assert_pytester_outcomes(
73
+ result: pytest.RunResult,
74
+ assert_outcomes: PytesterOutcomes | None,
75
+ ) -> None:
76
+ if assert_outcomes is None:
77
+ return
78
+
79
+ assert_kwargs = assert_outcomes.model_dump(
80
+ exclude_defaults=True,
81
+ exclude_none=True,
82
+ exclude={"custom_outcomes"},
83
+ )
84
+ if len(assert_kwargs) > 0:
85
+ result.assert_outcomes(**assert_kwargs)
86
+
87
+ if len(assert_outcomes.custom_outcomes) > 0:
88
+ other_result_outcomes = {
89
+ outcome: amount
90
+ for outcome, amount in result.parseoutcomes().items()
91
+ if outcome not in PytesterOutcomes.model_fields
92
+ }
93
+ assert assert_outcomes.custom_outcomes == other_result_outcomes, (
94
+ "Unexpected pytest outcomes. See result.stdout and result.stderr for more details."
95
+ )
96
+
97
+
98
+ def _assert_pytester_output(
99
+ line_matcher: pytest.LineMatcher,
100
+ match_patterns: list[OutputMatchPattern],
101
+ ) -> None:
102
+ if len(match_patterns) == 0:
103
+ return
104
+
105
+ for match_pattern in match_patterns:
106
+ pattern_sequence = match_pattern.match_pattern
107
+
108
+ if match_pattern.line_match_method == "regex":
109
+ match_lines = line_matcher.re_match_lines
110
+ elif match_pattern.line_match_method == "glob":
111
+ match_lines = line_matcher.fnmatch_lines
112
+
113
+ if isinstance(pattern_sequence, Sequence) and not isinstance(pattern_sequence, str):
114
+ consecutive = True
115
+ actual_pattern_sequence = pattern_sequence
116
+ else:
117
+ consecutive = False
118
+ actual_pattern_sequence = [pattern_sequence]
119
+
120
+ match_lines(actual_pattern_sequence, consecutive=consecutive)
121
+
122
+
123
+ def _assert_pytester_result(
124
+ result: pytest.RunResult,
125
+ assert_exit_code: pytest.ExitCode | None,
126
+ assert_outcomes: PytesterOutcomes | None,
127
+ match_stdout_patterns: list[OutputMatchPattern],
128
+ match_stderr_patterns: list[OutputMatchPattern],
129
+ ) -> None:
130
+ try:
131
+ _assert_pytester_exit_code(result, assert_exit_code)
132
+ _assert_pytester_outcomes(result, assert_outcomes)
133
+ _assert_pytester_output(result.stdout, match_stdout_patterns)
134
+ _assert_pytester_output(result.stderr, match_stderr_patterns)
135
+ except ValueError as e:
136
+ for arg in e.args:
137
+ if not isinstance(arg, str):
138
+ continue
139
+ message = arg.lower()
140
+ if all((segment in message) for segment in ["terminal summary", "not found"]):
141
+ msg = (
142
+ "Pytest failed to capture terminal summary. This might be caused when running "
143
+ "pytester without the `-s` flag with pytester run method set to `inprocess`."
144
+ )
145
+ raise RuntimeError(msg) from e
146
+ raise
147
+
148
+
149
+ def _resolve_pytester_method(
150
+ pytester: pytest.Pytester,
151
+ pytester_run_method: PytesterRunMethod,
152
+ ) -> _PytesterRunProtocol:
153
+ run_pytest_methods = {
154
+ "inprocess": pytester.runpytest_inprocess,
155
+ "subprocess": pytester.runpytest_subprocess,
156
+ }
157
+ run_pytest_method = run_pytest_methods[pytester_run_method]
158
+ return run_pytest_method
159
+
160
+
161
+ class _PytesterRunProtocol(Protocol):
162
+ @abstractmethod
163
+ def __call__(self, *args: str) -> pytest.RunResult: ...
164
+
165
+
166
+ class PytesterOutcomes(BaseModel):
167
+ """Represents the expected outcomes of a pytest run."""
168
+
169
+ model_config = ConfigDict(extra="forbid", frozen=True)
170
+
171
+ passed: NonNegativeInt | None = None
172
+ skipped: NonNegativeInt | None = None
173
+ failed: NonNegativeInt | None = None
174
+ errors: NonNegativeInt | None = None
175
+ xpassed: NonNegativeInt | None = None
176
+ xfailed: NonNegativeInt | None = None
177
+ warnings: NonNegativeInt | None = None
178
+ deselected: NonNegativeInt | None = None
179
+ custom_outcomes: dict[str, NonNegativeInt] = Field(default_factory=dict)
180
+
181
+
182
+ class OutputMatchPattern(BaseModel):
183
+ """Represents a pattern to match against the output of a pytest run, along with the matching method."""
184
+
185
+ model_config = ConfigDict(extra="forbid", frozen=True)
186
+
187
+ match_pattern: Sequence[str | re.Pattern[str]] | str | re.Pattern[str]
188
+ line_match_method: Literal["regex", "glob"]
189
+
190
+ @overload
191
+ def __init__(
192
+ self,
193
+ match_pattern: Sequence[str | re.Pattern[str]] | str | re.Pattern[str],
194
+ line_match_method: Literal["regex"] = "regex",
195
+ ) -> None: ...
196
+
197
+ @overload
198
+ def __init__(
199
+ self,
200
+ match_pattern: Sequence[str] | str,
201
+ line_match_method: Literal["glob"],
202
+ ) -> None: ...
203
+
204
+ def __init__(
205
+ self,
206
+ match_pattern: Sequence[str | re.Pattern[str]] | str | re.Pattern[str],
207
+ line_match_method: Literal["regex", "glob"] = "regex",
208
+ ) -> None:
209
+ """
210
+ Pattern used for matching against stdout or stderr of the nested pytest session.
211
+
212
+ Pattern can be either regex or glob pattern.
213
+
214
+ Instead of a single pattern, it is possible to pass a sequence
215
+ of patterns to be matched for consecutive matching.
216
+ """
217
+ super().__init__(
218
+ match_pattern=match_pattern,
219
+ line_match_method=line_match_method,
220
+ )
221
+
222
+
223
+ class TestFiles(BaseModel):
224
+ """Represents a collection of test files, extra files, and special files to be used in a pytester session."""
225
+
226
+ model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
227
+
228
+ conftest: AnyTestFile | None = None
229
+ """Optional `conftest.py` file to add alongside the test files."""
230
+
231
+ test_files: Sequence[AnyTestFile]
232
+ """Files containing test cases, to be run inside pytest session(s)."""
233
+
234
+ extra_files: Mapping[str, AnyTestFile] = Field(default_factory=dict)
235
+ """Files without any test cases, useful for utilities."""
236
+
237
+ # Pytest tries to collect the class as a test class when imported by a test module.
238
+ # This variable tells pytest it doesn't contain tests even though it has the "Test" prefix in the name.
239
+ __test__ = False
240
+
241
+ @model_validator(mode="after")
242
+ def _validate_special_files(self) -> Self:
243
+ duplicate_file_names: list[str] = [
244
+ name
245
+ for name, field in {
246
+ "conftest": self.conftest,
247
+ }.items()
248
+ if field is not None and name in self.extra_files
249
+ ]
250
+
251
+ if len(duplicate_file_names) > 0:
252
+ raise DuplicateSpecialFilesError(duplicate_file_names)
253
+
254
+ return self
255
+
256
+ @computed_field
257
+ @property
258
+ def special_files(self) -> dict[str, AnyTestFile]:
259
+ return {
260
+ name: file
261
+ for name, file in {
262
+ "__init__": self.extra_files.get("__init__"),
263
+ "conftest": self.conftest or self.extra_files.get("conftest"),
264
+ }.items()
265
+ if file is not None
266
+ }
267
+
268
+ @computed_field
269
+ @property
270
+ def all_non_test_files(self) -> dict[str, AnyTestFile]:
271
+ return {
272
+ **self.extra_files,
273
+ **self.special_files,
274
+ }
275
+
276
+ @overload
277
+ def make_pytester_tmp_files(self, pytester: pytest.Pytester) -> list[str]: ...
278
+
279
+ @overload
280
+ def make_pytester_tmp_files(self, pytester: pytest.Pytester, tests_dir_name: str) -> Path: ...
281
+
282
+ def make_pytester_tmp_files(
283
+ self,
284
+ pytester: pytest.Pytester,
285
+ tests_dir_name: str | None = None,
286
+ ) -> list[str] | Path:
287
+ """
288
+ Register the files into the pytester session using `pytester.makepyfile(...)`.
289
+
290
+ Return the list of paths of the test files, which should be passed to pytester's run method.
291
+ When `tests_dir_name` is provided, return its created path instead.
292
+
293
+ Optionally specify a directory name to create for containing
294
+ the test files (excluding special files and extra files).
295
+ """
296
+ tests_dir_name = tests_dir_name.strip() if tests_dir_name is not None else ""
297
+ test_file_name_prefix = ""
298
+ tests_dir_path: Path | None = None
299
+ if len(tests_dir_name) > 0:
300
+ test_file_name_prefix = f"{tests_dir_name}/"
301
+ tests_dir_path = pytester.mkpydir(tests_dir_name)
302
+
303
+ for extra_file_name, extra_file_function in self.all_non_test_files.items():
304
+ file_function = TestFile.from_any(extra_file_function)
305
+ pytester.makepyfile(**{extra_file_name: file_function.source_lines})
306
+
307
+ test_file_paths: list[str] = []
308
+ for test_file_function in self.test_files:
309
+ file_function = TestFile.from_any(test_file_function)
310
+ test_file_name = f"{test_file_name_prefix}{file_function.metadata.name}"
311
+ test_file_path = pytester.makepyfile(**{test_file_name: file_function.source_lines})
312
+ test_path_str = str(test_file_path.absolute())
313
+ test_file_paths.append(test_path_str)
314
+
315
+ if tests_dir_path is not None:
316
+ return tests_dir_path
317
+ return test_file_paths
318
+
319
+
320
+ class PytesterTestCase(BaseModel):
321
+ """Represents a test case to be run in a pytester session."""
322
+
323
+ model_config = ConfigDict(extra="forbid", frozen=True)
324
+
325
+ test_files: TestFiles | Sequence[AnyTestFile] | AnyTestFile
326
+ """Files to be created in the pytester sessions."""
327
+
328
+ pytest_args: list[str] = Field(default_factory=list)
329
+ """Command line arguments to pass to the nested pytest session."""
330
+
331
+ include_default_pytest_args: bool = True
332
+ """
333
+ Whether to pass the default command line arguments to the nested pytest session.
334
+
335
+ Can be combined with `pytest_args` to pass additional arguments.
336
+
337
+ The default pytest arguments can be modified via `set_default_pytest_args(...)` and `get_default_pytest_args(...)`.
338
+ """
339
+
340
+ env: dict[str, str] = Field(default_factory=dict)
341
+ """
342
+ Environment variables to pass to the nested pytest session, optionally overriding the default environment variables.
343
+
344
+ Explicitly set environment variables take priority over default
345
+ environment variables. Both default and explicit environment
346
+ variables take priority over inherited environment variables.
347
+ """
348
+
349
+ include_default_env: bool = True
350
+ """
351
+ Whether to pass the default environment variables to the nested pytest session.
352
+
353
+ Can be combined with `env` to pass additional environment variables.
354
+
355
+ The default environment variables can be modified via `set_default_env(...)` and `get_default_env(...)`.
356
+
357
+ Explicitly set environment variables take priority over default
358
+ environment variables. Both default and explicit environment
359
+ variables take priority over inherited environment variables.
360
+ """
361
+
362
+ inherit_env: bool = True
363
+ """
364
+ Whether to pass the environment variables of the invoking process to the nested pytest session.
365
+
366
+ Can be combined with `env` to pass additional arguments
367
+
368
+ Explicitly set environment variables take priority over default
369
+ environment variables. Both default and explicit environment
370
+ variables take priority over inherited environment variables.
371
+ """
372
+
373
+ pytester_run_method: PytesterRunMethod | None = None
374
+ """
375
+ Desired method for running pytester.
376
+
377
+ Default value for the argument `pytester_run_method` can be set via the
378
+ cli flag `--pytester-run-method`. When not defined, an environment variable
379
+ `PYTESTER_RUN_METHOD` can be used to set the default value. When neither is
380
+ defined, the default value is `subprocess`.
381
+ """
382
+
383
+ assert_exit_code: pytest.ExitCode | None = pytest.ExitCode.OK
384
+ """Expected exit code used for asserting against the exit code of the nested pytest session's exit code."""
385
+
386
+ assert_outcomes: PytesterOutcomes | None = None
387
+ """Expected outcomes used for asserting against the nested pytest session's outcomes."""
388
+
389
+ match_stdout_patterns: list[OutputMatchPattern] | OutputMatchPattern = Field(default_factory=list)
390
+ """Expected patterns used for matching against the nested pytest session's stdout."""
391
+
392
+ match_stderr_patterns: list[OutputMatchPattern] | OutputMatchPattern = Field(default_factory=list)
393
+ """Expected patterns used for matching against the nested pytest session's stderr."""
394
+
395
+
396
+ def run_pytester(test_case: PytesterTestCase) -> pytest.RunResult:
397
+ request = get_request()
398
+ pytester = request.getfixturevalue("pytester")
399
+
400
+ pytest_args = [
401
+ *(_default_params.pytest_args if test_case.include_default_pytest_args else []),
402
+ *test_case.pytest_args,
403
+ ]
404
+ env = {
405
+ **(_default_params.env_vars if test_case.include_default_env else {}),
406
+ **test_case.env,
407
+ }
408
+
409
+ if isinstance(test_case.test_files, TestFiles):
410
+ test_files = test_case.test_files
411
+ elif isinstance(test_case.test_files, Sequence):
412
+ test_files = TestFiles(test_files=test_case.test_files)
413
+ elif isinstance(test_case.test_files, (TestFile, Callable)):
414
+ test_files = TestFiles(test_files=[test_case.test_files])
415
+
416
+ run_result = _run_pytester(
417
+ config=request.config,
418
+ pytester=pytester,
419
+ pytester_run_method=test_case.pytester_run_method,
420
+ test_files=test_files,
421
+ pytest_args=pytest_args,
422
+ env=env,
423
+ inherit_env=test_case.inherit_env,
424
+ )
425
+
426
+ _assert_pytester_result(
427
+ result=run_result,
428
+ assert_exit_code=test_case.assert_exit_code,
429
+ assert_outcomes=test_case.assert_outcomes,
430
+ match_stdout_patterns=test_case.match_stdout_patterns
431
+ if isinstance(test_case.match_stdout_patterns, list)
432
+ else [test_case.match_stdout_patterns],
433
+ match_stderr_patterns=test_case.match_stderr_patterns
434
+ if isinstance(test_case.match_stderr_patterns, list)
435
+ else [test_case.match_stderr_patterns],
436
+ )
437
+
438
+ return run_result
439
+
440
+
441
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
442
+ def _run_pytester( # noqa: PLR0913 - too-many-arguments
443
+ *,
444
+ config: pytest.Config,
445
+ pytester: pytest.Pytester,
446
+ pytester_run_method: PytesterRunMethod | None = None,
447
+ test_files: TestFiles,
448
+ pytest_args: Sequence[str] | None = None,
449
+ env: Mapping[str, str] | None = None,
450
+ inherit_env: bool = True,
451
+ ) -> pytest.RunResult:
452
+ """
453
+ Run pytester with the given files and args.
454
+
455
+ Default value for the argument `pytester_run_method` can be set via the
456
+ cli flag `--pytester-run-method`. When not defined, an environment variable
457
+ `PYTESTER_RUN_METHOD` can be used to set the default value. When neither is
458
+ defined, the default value is `subprocess`.
459
+
460
+ Args:
461
+ config (pytest.Config): Config object from the pytest session.
462
+ pytester (pytest.Pytester): Fixture for running pytester, obtained from the pytest session.
463
+ test_files (TestFiles): Files to be created in the pytester sessions.
464
+ pytester_run_method (PytesterRunMethod | None, optional): Desired method for running pytester.
465
+ pytest_args (Sequence[str] | None, optional): Command line arguments to pass to the nested pytest session.
466
+ env (Mapping[str, str] | None, optional): Environment variables to pass to the nested pytest
467
+ session, optionally overriding the default environment variables.
468
+ inherit_env (bool, optional): Whether to pass the environment variables of the invoking
469
+ process to the nested pytest session.
470
+
471
+ Returns:
472
+ pytest.RunResult: The result of running the nested pytest session.
473
+ """
474
+ pytest_args = list(pytest_args or [])
475
+ env = dict(env or {})
476
+
477
+ pytester_run_method = pytester_run_method or get_default_pytester_run_method(config)
478
+ pytester_run_method_flag = (PYTESTER_RUN_METHOD_CLI_FLAG, pytester_run_method)
479
+ run_pytest = _resolve_pytester_method(pytester, pytester_run_method)
480
+
481
+ test_file_paths = test_files.make_pytester_tmp_files(pytester)
482
+ with patch_env(env, inherit_env):
483
+ run_result = run_pytest(*test_file_paths, *pytest_args, *pytester_run_method_flag)
484
+ return run_result
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from types import FunctionType
6
+ from typing import Any, Self, overload, override
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ ConfigDict,
11
+ Field,
12
+ )
13
+
14
+ from pytester_utils._ast_utils import get_function_body_source_lines
15
+ from pytester_utils.errors import NotConvertibleToTestFileError
16
+
17
+ type RawFileFunction = Callable[..., Any]
18
+ """Raw Python function that can be converted into a Python file."""
19
+
20
+ type AnyTestFile = TestFile | RawFileFunction
21
+ """Any object that can be converted into a Python file."""
22
+
23
+
24
+ class TestFile(ABC):
25
+ __test__ = False
26
+
27
+ @property
28
+ @abstractmethod
29
+ def metadata(self) -> TestFileMetadata: ...
30
+
31
+ @property
32
+ @abstractmethod
33
+ def source_lines(self) -> str:
34
+ """
35
+ Get the source lines of the test file.
36
+
37
+ Multi-line source is returned as a single string with newline characters.
38
+ """
39
+
40
+ @classmethod
41
+ def build(cls) -> TestFileBuilder:
42
+ """Initialize a builder for the implementations of this class."""
43
+ return TestFileBuilder()
44
+
45
+ @staticmethod
46
+ def from_any(obj: AnyTestFile) -> TestFile:
47
+ """
48
+ Convert any object that can be converted into a Python file into a `TestFile`.
49
+
50
+ If the object is already a `TestFile`, it is returned as-is.
51
+ If the object is a raw Python function, it is wrapped in a `FunctionTestFile` with default metadata.
52
+ """
53
+ if isinstance(obj, TestFile):
54
+ return obj
55
+ if isinstance(obj, FunctionType):
56
+ return FunctionTestFile(obj, TestFileMetadata(name=obj.__name__) if hasattr(obj, "__name__") else None)
57
+ raise NotConvertibleToTestFileError(obj)
58
+
59
+ @overload
60
+ def inject(self, /, **variables_kwargs: Any) -> Self: ...
61
+
62
+ @overload
63
+ def inject(self, variables_dict: dict[str, Any], /) -> Self: ...
64
+
65
+ def inject(self, variables_dict: dict[str, Any] | None = None, /, **variables_kwargs: Any) -> Self:
66
+ """
67
+ Inject variables into the test file.
68
+
69
+ When generating a file based on a test file function, a variable is injected only
70
+ if there is an argument in the test file function's signature with the same name.
71
+ """
72
+ self.metadata.injected_variables.update(variables_dict or {})
73
+ self.metadata.injected_variables.update(variables_kwargs)
74
+ return self
75
+
76
+
77
+ class TestFileMetadata(BaseModel):
78
+ __test__ = False
79
+
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ name: str = "test_unnamed"
83
+ injected_variables: dict[str, Any] = Field(default_factory=dict)
84
+ auto_inject_imports: bool = True
85
+
86
+
87
+ class FunctionTestFile(TestFile):
88
+ """
89
+ Container for attaching metadata to a function that can be converted into a Python file.
90
+
91
+ For simplicity, it is advised to use `TestFile.build().finalize_from_raw()` as a decorator instead.
92
+ """
93
+
94
+ def __init__(self, func: RawFileFunction, metadata: TestFileMetadata | None = None) -> None:
95
+ self._func = func
96
+ self._metadata = metadata or (
97
+ TestFileMetadata(name=str(func.__name__)) if hasattr(func, "__name__") else TestFileMetadata()
98
+ )
99
+
100
+ @property
101
+ def func(self) -> RawFileFunction:
102
+ return self._func
103
+
104
+ @property
105
+ @override
106
+ def metadata(self) -> TestFileMetadata:
107
+ return self._metadata
108
+
109
+ @property
110
+ @override
111
+ def source_lines(self) -> str:
112
+ return get_function_body_source_lines(
113
+ self._func,
114
+ self._metadata.injected_variables,
115
+ self._metadata.auto_inject_imports,
116
+ )
117
+
118
+
119
+ class TestFileBuilder:
120
+ """
121
+ Builder class for the class `TestFile`.
122
+
123
+ Finalize building by using one of the methods with the prefix "finalize".
124
+ """
125
+
126
+ __test__ = False
127
+
128
+ def __init__(self) -> None:
129
+ self._raw_metadata: dict[str, Any] = {}
130
+
131
+ def finalize_from_raw(self) -> Callable[[RawFileFunction], TestFile]:
132
+ """Finalize building by decorating a file function with the returned decorator."""
133
+
134
+ def wrapper(func: RawFileFunction) -> TestFile:
135
+ raw_metadata = self._raw_metadata.copy()
136
+ if "name" not in raw_metadata and hasattr(func, "__name__"):
137
+ raw_metadata["name"] = func.__name__
138
+ metadata = TestFileMetadata.model_validate(raw_metadata)
139
+ test_file = FunctionTestFile(func, metadata)
140
+ return test_file
141
+
142
+ return wrapper
143
+
144
+ def set_name(self, name: str) -> Self:
145
+ """Set the name of the test file."""
146
+ self._raw_metadata["name"] = name
147
+ return self
148
+
149
+ @overload
150
+ def inject(self, /, **variables_kwargs: Any) -> Self: ...
151
+
152
+ @overload
153
+ def inject(self, variables_dict: dict[str, Any], /) -> Self: ...
154
+
155
+ def inject(self, variables_dict: dict[str, Any] | None = None, /, **variables_kwargs: Any) -> Self:
156
+ """
157
+ Inject variables into the test file.
158
+
159
+ When generating a file based on a test file function, a variable is injected only
160
+ if there is an argument in the test file function's signature with the same name.
161
+ """
162
+ injected_variables: dict[str, Any] = self._raw_metadata.setdefault("injected_variables", {})
163
+ injected_variables.update(variables_dict or {})
164
+ injected_variables.update(variables_kwargs)
165
+ return self
166
+
167
+ def set_auto_inject_imports(self, auto_inject_imports: bool) -> Self:
168
+ """Set whether to automatically inject imports from the defining module."""
169
+ self._raw_metadata["auto_inject_imports"] = auto_inject_imports
170
+ return self
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytester-utils
3
+ Version: 1.0.0
4
+ Summary: Powerful and ergonomic wrapper for pytester for your mental health.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pydantic~=2.13
8
+ Requires-Dist: pytest~=9.1
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Pytester Utils
12
+
13
+ [![CI](https://github.com/Guy-Yudovich/pytester-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/Guy-Yudovich/pytester-utils/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/Guy-Yudovich/pytester-utils/branch/main/graph/badge.svg)](https://codecov.io/gh/Guy-Yudovich/pytester-utils) [![Latest Release](https://img.shields.io/github/v/release/Guy-Yudovich/pytester-utils)](https://github.com/Guy-Yudovich/pytester-utils/releases) [![PyPI](https://img.shields.io/pypi/v/pytester-utils)](https://pypi.org/project/pytester-utils/) [![License](https://img.shields.io/github/license/Guy-Yudovich/pytester-utils)](https://github.com/Guy-Yudovich/pytester-utils/blob/main/LICENSE) [![Python](https://img.shields.io/badge/python-3.12+-blue?logo=python&logoColor=white)](https://www.python.org) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![Tested with pytest | pytester | pytester-utils](https://img.shields.io/badge/tested_with-pytest_%7C_pytester_%7C_pytester--utils-0A9EDC?logo=pytest&logoColor=white)](https://github.com/Guy-Yudovich/pytester-utils)
14
+
15
+ Powerful and ergonomic wrapper for pytester for your mental health.
@@ -0,0 +1,13 @@
1
+ pytester_utils/__init__.py,sha256=BtyGVb8vaMh6bpI76d9evsjYa82Vo_MZxT1wvv6PNQc,944
2
+ pytester_utils/_ast_utils.py,sha256=Rav38Fj7oa3U-2Wl15DbO54747JHyDO5qGaEDzOSM78,4745
3
+ pytester_utils/_env_patching.py,sha256=RTKwZ_PueaaiCXyRJBMkoQRudr88RlstsSf9Dm030YY,3401
4
+ pytester_utils/_plugin.py,sha256=Sxz7yDVx0mchFbodz7B1mLT3WewbBbR7zbiRutNQUEQ,3518
5
+ pytester_utils/errors.py,sha256=Mxi8k_Jcq8ii73dOkrWX472v0gCosM5MbhEjGLi3XpY,2350
6
+ pytester_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pytester_utils/pytester_utils.py,sha256=ZleckPWMKWuy6tLw4hJ5Wu2selG0vP2kCQHjccNP5Mg,17847
8
+ pytester_utils/test_file.py,sha256=iKiTtE08kFetOedK_yyPoS33t4lhGMAqjCgKMh7Oi5o,5611
9
+ pytester_utils-1.0.0.dist-info/METADATA,sha256=RBB1V6V-l178bnordwS7DSAod84W63RgMNa0ouL8ar4,2004
10
+ pytester_utils-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ pytester_utils-1.0.0.dist-info/entry_points.txt,sha256=lPbBb4rtljNzEFKzJq8nbHxV4VmApGs-ncoxfIc6-vU,51
12
+ pytester_utils-1.0.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
13
+ pytester_utils-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytester_utils = pytester_utils._plugin
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.