diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a58dc83639a..6238a6fedcc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,7 +54,7 @@ jobs: path: dist - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@v1.10.1 + uses: pypa/gh-action-pypi-publish@v1.12.2 with: attestations: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9158d6bcc72..3131f487ab9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -147,7 +147,7 @@ jobs: - name: "ubuntu-py313" python: "3.13-dev" os: ubuntu-latest - tox_env: "py313" + tox_env: "py313-pexpect" use_coverage: true - name: "ubuntu-pypy3" python: "pypy-3.9" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cb466cbb2c..7806f68e2d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,8 +32,6 @@ repos: hooks: - id: mypy files: ^(src/|testing/|scripts/) - args: [] - language_version: "3.8" additional_dependencies: - iniconfig>=1.1.0 - attrs>=19.2.0 diff --git a/AUTHORS b/AUTHORS index 4f1b172ff33..e25156cde19 100644 --- a/AUTHORS +++ b/AUTHORS @@ -116,6 +116,7 @@ Dave Hunt David Díaz-Barquero David Mohr David Paul Röthlisberger +David Peled David Szotten David Vierra Daw-Ran Liou @@ -159,6 +160,7 @@ Feng Ma Florian Bruhin Florian Dahlitz Floris Bruynooghe +Frank Hoffmann Fraser Stark Gabriel Landau Gabriel Reis @@ -243,6 +245,7 @@ Kristoffer Nordström Kyle Altendorf Lawrence Mitchell Lee Kamentsky +Leonardus Chen Lev Maximov Levon Saldamli Lewis Cowles diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 61e4a772beb..8a38df7a2e1 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-8.3.4 release-8.3.3 release-8.3.2 release-8.3.1 diff --git a/doc/en/announce/release-8.3.4.rst b/doc/en/announce/release-8.3.4.rst new file mode 100644 index 00000000000..f76d60396dc --- /dev/null +++ b/doc/en/announce/release-8.3.4.rst @@ -0,0 +1,30 @@ +pytest-8.3.4 +======================================= + +pytest 8.3.4 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/stable/changelog.html. + +Thanks to all of the contributors to this release: + +* Bruno Oliveira +* Florian Bruhin +* Frank Hoffmann +* Jakob van Santen +* Leonardus Chen +* Pierre Sassoulas +* Pradeep Kumar +* Ran Benita +* Serge Smertin +* Stefaan Lippens +* Sviatoslav Sydorenko (Святослав Сидоренко) +* dongfangtianyu +* suspe + + +Happy testing, +The pytest Development Team diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index 9b406a6a512..85d280da50f 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -178,16 +178,11 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a Return a :class:`pytest.TempdirFactory` instance for the test session. tmpdir -- .../_pytest/legacypath.py:305 - Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. If - ``--basetemp`` is used then it is cleared each session. See - :ref:`temporary directory location and retention`. - - The returned object is a `legacy_path`_ object. + Return a temporary directory (as `legacy_path`_ object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. .. note:: These days, it is preferred to use ``tmp_path``. @@ -234,25 +229,17 @@ For information about fixtures, see :ref:`fixtures`. To see a complete list of a recwarn -- .../_pytest/recwarn.py:35 Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. - See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information - on warning categories. + See :ref:`warnings` for information on warning categories. - tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:242 + tmp_path_factory [session scope] -- .../_pytest/tmpdir.py:241 Return a :class:`pytest.TempPathFactory` instance for the test session. - tmp_path -- .../_pytest/tmpdir.py:257 - Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. - This behavior can be configured with :confval:`tmp_path_retention_count` and - :confval:`tmp_path_retention_policy`. - If ``--basetemp`` is used then it is cleared each session. See - :ref:`temporary directory location and retention`. - - The returned object is a :class:`pathlib.Path` object. + tmp_path -- .../_pytest/tmpdir.py:256 + Return a temporary directory (as :class:`pathlib.Path` object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. ========================== no tests ran in 0.12s =========================== diff --git a/doc/en/changelog.rst b/doc/en/changelog.rst index 9f30c86be3a..a01c2589f1f 100644 --- a/doc/en/changelog.rst +++ b/doc/en/changelog.rst @@ -31,6 +31,47 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 8.3.4 (2024-12-01) +========================= + +Bug fixes +--------- + +- `#12592 `_: Fixed :class:`KeyError` crash when using ``--import-mode=importlib`` in a directory layout where a directory contains a child directory with the same name. + + +- `#12818 `_: Assertion rewriting now preserves the source ranges of the original instructions, making it play well with tools that deal with the ``AST``, like `executing `__. + + +- `#12849 `_: ANSI escape codes for colored output now handled correctly in :func:`pytest.fail` with `pytrace=False`. + + +- `#9353 `_: :func:`pytest.approx` now uses strict equality when given booleans. + + + +Improved documentation +---------------------- + +- `#10558 `_: Fix ambiguous docstring of :func:`pytest.Config.getoption`. + + +- `#10829 `_: Improve documentation on the current handling of the ``--basetemp`` option and its lack of retention functionality (:ref:`temporary directory location and retention`). + + +- `#12866 `_: Improved cross-references concerning the :fixture:`recwarn` fixture. + + +- `#12966 `_: Clarify :ref:`filterwarnings` docs on filter precedence/order when using multiple :ref:`@pytest.mark.filterwarnings ` marks. + + + +Contributor-facing changes +-------------------------- + +- `#12497 `_: Fixed two failing pdb-related tests on Python 3.13. + + pytest 8.3.3 (2024-09-09) ========================= diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index fa43308d045..f1f7deb909c 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -162,7 +162,7 @@ objects, they are still using the default pytest representation: rootdir: /home/sweet/project collected 8 items - + @@ -239,7 +239,7 @@ If you just collect tests you'll also nicely see 'advanced' and 'basic' as varia rootdir: /home/sweet/project collected 4 items - + @@ -318,7 +318,7 @@ Let's first see how it looks like at collection time: rootdir: /home/sweet/project collected 2 items - + diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 42a603f114c..1d1ce07c10a 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -152,7 +152,7 @@ The test collection would look like this: configfile: pytest.ini collected 2 items - + @@ -215,7 +215,7 @@ You can always peek at the collection tree without running tests like this: configfile: pytest.ini collected 3 items - + diff --git a/doc/en/explanation/goodpractices.rst b/doc/en/explanation/goodpractices.rst index 1390ba4e8fe..51c0b960aed 100644 --- a/doc/en/explanation/goodpractices.rst +++ b/doc/en/explanation/goodpractices.rst @@ -210,8 +210,8 @@ Note that this layout also works in conjunction with the ``src`` layout mentione to avoid surprises such as a test module getting imported twice. With ``--import-mode=importlib`` things are less convoluted because - pytest doesn't need to change ``sys.path`` or ``sys.modules``, making things - much less surprising. + pytest doesn't need to change ``sys.path``, making things much less + surprising. .. _which-import-mode: diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index faf81154c48..d4308e5aab3 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -22,7 +22,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - pytest 8.3.3 + pytest 8.3.4 .. _`simpletest`: diff --git a/doc/en/how-to/capture-stdout-stderr.rst b/doc/en/how-to/capture-stdout-stderr.rst index 5e23f0c024e..9f7ddce3499 100644 --- a/doc/en/how-to/capture-stdout-stderr.rst +++ b/doc/en/how-to/capture-stdout-stderr.rst @@ -106,9 +106,10 @@ of the failing function and hide the other one: Accessing captured output from a test function --------------------------------------------------- -The ``capsys``, ``capsysbinary``, ``capfd``, and ``capfdbinary`` fixtures -allow access to stdout/stderr output created during test execution. Here is -an example test function that performs some output related checks: +The :fixture:`capsys`, :fixture:`capsysbinary`, :fixture:`capfd`, and :fixture:`capfdbinary` fixtures +allow access to ``stdout``/``stderr`` output created during test execution. + +Here is an example test function that performs some output related checks: .. code-block:: python @@ -125,40 +126,27 @@ an example test function that performs some output related checks: The ``readouterr()`` call snapshots the output so far - and capturing will be continued. After the test function finishes the original streams will -be restored. Using ``capsys`` this way frees your +be restored. Using :fixture:`capsys` this way frees your test from having to care about setting/resetting output streams and also interacts well with pytest's own per-test capturing. -If you want to capture on filedescriptor level you can use -the ``capfd`` fixture which offers the exact -same interface but allows to also capture output from -libraries or subprocesses that directly write to operating -system level output streams (FD1 and FD2). - - - The return value from ``readouterr`` changed to a ``namedtuple`` with two attributes, ``out`` and ``err``. - - -If the code under test writes non-textual data, you can capture this using -the ``capsysbinary`` fixture which instead returns ``bytes`` from +If the code under test writes non-textual data (``bytes``), you can capture this using +the :fixture:`capsysbinary` fixture which instead returns ``bytes`` from the ``readouterr`` method. +If you want to capture at the file descriptor level you can use +the :fixture:`capfd` fixture which offers the exact +same interface but allows to also capture output from +libraries or subprocesses that directly write to operating +system level output streams (FD1 and FD2). Similarly to :fixture:`capsysbinary`, :fixture:`capfdbinary` can be +used to capture ``bytes`` at the file descriptor level. - -If the code under test writes non-textual data, you can capture this using -the ``capfdbinary`` fixture which instead returns ``bytes`` from -the ``readouterr`` method. The ``capfdbinary`` fixture operates on the -filedescriptor level. - - - - -To temporarily disable capture within a test, both ``capsys`` -and ``capfd`` have a ``disabled()`` method that can be used +To temporarily disable capture within a test, the capture fixtures +have a ``disabled()`` method that can be used as a context manager, disabling capture inside the ``with`` block: .. code-block:: python diff --git a/doc/en/how-to/capture-warnings.rst b/doc/en/how-to/capture-warnings.rst index afabad5da14..65a43cec6e8 100644 --- a/doc/en/how-to/capture-warnings.rst +++ b/doc/en/how-to/capture-warnings.rst @@ -128,7 +128,7 @@ is performed. -You can use the ``@pytest.mark.filterwarnings`` to add warning filters to specific test items, +You can use the :ref:`@pytest.mark.filterwarnings ` mark to add warning filters to specific test items, allowing you to have finer control of which warnings should be captured at test, class or even module level: @@ -147,10 +147,30 @@ even module level: assert api_v1() == 1 +You can specify multiple filters with separate decorators: + +.. code-block:: python + + # Ignore "api v1" warnings, but fail on all other warnings + @pytest.mark.filterwarnings("ignore:api v1") + @pytest.mark.filterwarnings("error") + def test_one(): + assert api_v1() == 1 + +.. important:: + + Regarding decorator order and filter precedence: + it's important to remember that decorators are evaluated in reverse order, + so you have to list the warning filters in the reverse order + compared to traditional :py:func:`warnings.filterwarnings` and :option:`-W option ` usage. + This means in practice that filters from earlier :ref:`@pytest.mark.filterwarnings ` decorators + take precedence over filters from later decorators, as illustrated in the example above. + + Filters applied using a mark take precedence over filters passed on the command line or configured -by the ``filterwarnings`` ini option. +by the :confval:`filterwarnings` ini option. -You may apply a filter to all tests of a class by using the ``filterwarnings`` mark as a class +You may apply a filter to all tests of a class by using the :ref:`filterwarnings ` mark as a class decorator or to all tests in a module by setting the :globalvar:`pytestmark` variable: .. code-block:: python @@ -159,6 +179,13 @@ decorator or to all tests in a module by setting the :globalvar:`pytestmark` var pytestmark = pytest.mark.filterwarnings("error") +.. note:: + + If you want to apply multiple filters + (by assigning a list of :ref:`filterwarnings ` mark to :globalvar:`pytestmark`), + you must use the traditional :py:func:`warnings.filterwarnings` ordering approach (later filters take precedence), + which is the reverse of the decorator approach mentioned above. + *Credits go to Florian Schulze for the reference implementation in the* `pytest-warnings`_ *plugin.* @@ -195,7 +222,7 @@ user code and third-party libraries, as recommended by :pep:`565`. This helps users keep their code modern and avoid breakages when deprecated warnings are effectively removed. However, in the specific case where users capture any type of warnings in their test, either with -:func:`pytest.warns`, :func:`pytest.deprecated_call` or using the :ref:`recwarn ` fixture, +:func:`pytest.warns`, :func:`pytest.deprecated_call` or using the :fixture:`recwarn` fixture, no warning will be displayed at all. Sometimes it is useful to hide some specific deprecation warnings that happen in code that you have no control over @@ -332,10 +359,10 @@ additional information: assert record[0].message.args[0] == "another warning" Alternatively, you can examine raised warnings in detail using the -:ref:`recwarn ` fixture (see below). +:fixture:`recwarn` fixture (see :ref:`below `). -The :ref:`recwarn ` fixture automatically ensures to reset the warnings +The :fixture:`recwarn` fixture automatically ensures to reset the warnings filter at the end of the test, so no global state is leaked. .. _`recording warnings`: @@ -345,8 +372,8 @@ filter at the end of the test, so no global state is leaked. Recording warnings ------------------ -You can record raised warnings either using :func:`pytest.warns` or with -the ``recwarn`` fixture. +You can record raised warnings either using the :func:`pytest.warns` context manager or with +the :fixture:`recwarn` fixture. To record with :func:`pytest.warns` without asserting anything about the warnings, pass no arguments as the expected warning type and it will default to a generic Warning: @@ -361,7 +388,7 @@ pass no arguments as the expected warning type and it will default to a generic assert str(record[0].message) == "user" assert str(record[1].message) == "runtime" -The ``recwarn`` fixture will record warnings for the whole function: +The :fixture:`recwarn` fixture will record warnings for the whole function: .. code-block:: python @@ -377,12 +404,11 @@ The ``recwarn`` fixture will record warnings for the whole function: assert w.filename assert w.lineno -Both ``recwarn`` and :func:`pytest.warns` return the same interface for recorded -warnings: a WarningsRecorder instance. To view the recorded warnings, you can +Both the :fixture:`recwarn` fixture and the :func:`pytest.warns` context manager return the same interface for recorded +warnings: a :class:`~_pytest.recwarn.WarningsRecorder` instance. To view the recorded warnings, you can iterate over this instance, call ``len`` on it to get the number of recorded warnings, or index into it to get a particular recorded warning. -Full API: :class:`~_pytest.recwarn.WarningsRecorder`. .. _`warns use cases`: diff --git a/doc/en/how-to/fixtures.rst b/doc/en/how-to/fixtures.rst index 4f6e8cbee06..9232c9e8449 100644 --- a/doc/en/how-to/fixtures.rst +++ b/doc/en/how-to/fixtures.rst @@ -1418,7 +1418,7 @@ Running the above tests results in the following test IDs being used: rootdir: /home/sweet/project collected 12 items - + diff --git a/doc/en/how-to/tmp_path.rst b/doc/en/how-to/tmp_path.rst index 3cc5152e992..d19950431e5 100644 --- a/doc/en/how-to/tmp_path.rst +++ b/doc/en/how-to/tmp_path.rst @@ -133,27 +133,47 @@ API for details. Temporary directory location and retention ------------------------------------------ -Temporary directories are by default created as sub-directories of -the system temporary directory. The base name will be ``pytest-NUM`` where -``NUM`` will be incremented with each test run. -By default, entries older than 3 temporary directories will be removed. -This behavior can be configured with :confval:`tmp_path_retention_count` and -:confval:`tmp_path_retention_policy`. +The temporary directories, +as returned by the :fixture:`tmp_path` and (now deprecated) :fixture:`tmpdir` fixtures, +are automatically created under a base temporary directory, +in a structure that depends on the ``--basetemp`` option: -Using the ``--basetemp`` -option will remove the directory before every run, effectively meaning the temporary directories -of only the most recent run will be kept. +- By default (when the ``--basetemp`` option is not set), + the temporary directories will follow this template: -You can override the default temporary directory setting like this: + .. code-block:: text -.. code-block:: bash + {temproot}/pytest-of-{user}/pytest-{num}/{testname}/ - pytest --basetemp=mydir + where: -.. warning:: + - ``{temproot}`` is the system temporary directory + as determined by :py:func:`tempfile.gettempdir`. + It can be overridden by the :envvar:`PYTEST_DEBUG_TEMPROOT` environment variable. + - ``{user}`` is the user name running the tests, + - ``{num}`` is a number that is incremented with each test suite run + - ``{testname}`` is a sanitized version of :py:attr:`the name of the current test <_pytest.nodes.Node.name>`. - The contents of ``mydir`` will be completely removed, so make sure to use a directory - for that purpose only. + The auto-incrementing ``{num}`` placeholder provides a basic retention feature + and avoids that existing results of previous test runs are blindly removed. + By default, the last 3 temporary directories are kept, + but this behavior can be configured with + :confval:`tmp_path_retention_count` and :confval:`tmp_path_retention_policy`. + +- When the ``--basetemp`` option is used (e.g. ``pytest --basetemp=mydir``), + it will be used directly as base temporary directory: + + .. code-block:: text + + {basetemp}/{testname}/ + + Note that there is no retention feature in this case: + only the results of the most recent run will be kept. + + .. warning:: + + The directory given to ``--basetemp`` will be cleared blindly before each test run, + so make sure to use a directory for that purpose only. When distributing tests on the local machine using ``pytest-xdist``, care is taken to automatically configure a `basetemp` directory for the sub processes such that all temporary diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index f1110097ecc..31cf363100a 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -54,7 +54,7 @@ pytest.fail **Tutorial**: :ref:`skipping` -.. autofunction:: pytest.fail(reason, [pytrace=True, msg=None]) +.. autofunction:: pytest.fail(reason, [pytrace=True]) .. class:: pytest.fail.Exception @@ -63,7 +63,7 @@ pytest.fail pytest.skip ~~~~~~~~~~~ -.. autofunction:: pytest.skip(reason, [allow_module_level=False, msg=None]) +.. autofunction:: pytest.skip(reason, [allow_module_level=False]) .. class:: pytest.skip.Exception @@ -88,7 +88,7 @@ pytest.xfail pytest.exit ~~~~~~~~~~~ -.. autofunction:: pytest.exit(reason, [returncode=None, msg=None]) +.. autofunction:: pytest.exit(reason, [returncode=None]) .. class:: pytest.exit.Exception @@ -529,13 +529,14 @@ record_testsuite_property recwarn ~~~~~~~ -**Tutorial**: :ref:`assertwarnings` +**Tutorial**: :ref:`recwarn` .. autofunction:: _pytest.recwarn.recwarn() :no-auto-options: .. autoclass:: pytest.WarningsRecorder() :members: + :special-members: __getitem__, __iter__, __len__ .. fixture:: request @@ -1145,6 +1146,11 @@ processes can inspect it, see :ref:`pytest current test env` for more informatio When set, pytest will print tracing and debug information. +.. envvar:: PYTEST_DEBUG_TEMPROOT + +Root for temporary directories produced by fixtures like :fixture:`tmp_path` +as discussed in :ref:`temporary directory location and retention`. + .. envvar:: PYTEST_DISABLE_PLUGIN_AUTOLOAD When set, disables plugin auto-loading through :std:doc:`entry point packaging diff --git a/pyproject.toml b/pyproject.toml index 12764f3d127..c7941f9dafa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -479,6 +479,7 @@ files = [ mypy_path = [ "src", ] +python_version = "3.8" check_untyped_defs = true disallow_any_generics = true disallow_untyped_defs = true diff --git a/scripts/update-plugin-list.py b/scripts/update-plugin-list.py index 75df0ddba40..556004d9e98 100644 --- a/scripts/update-plugin-list.py +++ b/scripts/update-plugin-list.py @@ -66,6 +66,7 @@ "logot", "nuts", "flask_fixture", + "databricks-labs-pytester", } diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 8fac39ea298..fec627b3a36 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1221,6 +1221,15 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: if not self.lines: return + if self.style == "value": + # Using tw.write instead of tw.line for testing purposes due to TWMock implementation; + # lines written with TWMock.line and TWMock._write_source cannot be distinguished + # from each other, whereas lines written with TWMock.write are marked with TWMock.WRITE + for line in self.lines: + tw.write(line) + tw.write("\n") + return + # separate indents and source lines that are not failures: we want to # highlight the code but not the indentation, which may contain markers # such as "> assert 0" @@ -1236,11 +1245,8 @@ def _write_entry_lines(self, tw: TerminalWriter) -> None: failure_lines.extend(self.lines[index:]) break else: - if self.style == "value": - source_lines.append(line) - else: - indents.append(line[:indent_size]) - source_lines.append(line[indent_size:]) + indents.append(line[:indent_size]) + source_lines.append(line[indent_size:]) tw._write_source(source_lines, indents) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index a7a92c0f1fe..37c09b03467 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -792,7 +792,7 @@ def assign(self, expr: ast.expr) -> ast.Name: """Give *expr* a name.""" name = self.variable() self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr)) - return ast.Name(name, ast.Load()) + return ast.copy_location(ast.Name(name, ast.Load()), expr) def display(self, expr: ast.expr) -> ast.expr: """Call saferepr on the expression.""" @@ -975,7 +975,10 @@ def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]: # Fix locations (line numbers/column offsets). for stmt in self.statements: for node in traverse_node(stmt): - ast.copy_location(node, assert_) + if getattr(node, "lineno", None) is None: + # apply the assertion location to all generated ast nodes without source location + # and preserve the location of existing nodes or generated nodes with an correct location. + ast.copy_location(node, assert_) return self.statements def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]: @@ -1052,7 +1055,7 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]: def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]: pattern = UNARY_MAP[unary.op.__class__] operand_res, operand_expl = self.visit(unary.operand) - res = self.assign(ast.UnaryOp(unary.op, operand_res)) + res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary)) return res, pattern % (operand_expl,) def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]: @@ -1060,7 +1063,9 @@ def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]: left_expr, left_expl = self.visit(binop.left) right_expr, right_expl = self.visit(binop.right) explanation = f"({left_expl} {symbol} {right_expl})" - res = self.assign(ast.BinOp(left_expr, binop.op, right_expr)) + res = self.assign( + ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop) + ) return res, explanation def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]: @@ -1089,7 +1094,7 @@ def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]: arg_expls.append("**" + expl) expl = "{}({})".format(func_expl, ", ".join(arg_expls)) - new_call = ast.Call(new_func, new_args, new_kwargs) + new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call) res = self.assign(new_call) res_expl = self.explanation_param(self.display(res)) outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}" @@ -1105,7 +1110,9 @@ def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]: if not isinstance(attr.ctx, ast.Load): return self.generic_visit(attr) value, value_expl = self.visit(attr.value) - res = self.assign(ast.Attribute(value, attr.attr, ast.Load())) + res = self.assign( + ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr) + ) res_expl = self.explanation_param(self.display(res)) pat = "%s\n{%s = %s.%s\n}" expl = pat % (res_expl, res_expl, value_expl, attr.attr) @@ -1146,7 +1153,7 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]: syms.append(ast.Constant(sym)) expl = f"{left_expl} {sym} {next_expl}" expls.append(ast.Constant(expl)) - res_expr = ast.Compare(left_res, [op], [next_res]) + res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp) self.statements.append(ast.Assign([store_names[i]], res_expr)) left_res, left_expl = next_res, next_expl # Use pytest.assertion.util._reprcompare if that's available. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 3cca1479381..710e03e4fe0 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1682,11 +1682,12 @@ def _get_override_ini_value(self, name: str) -> str | None: def getoption(self, name: str, default=notset, skip: bool = False): """Return command line option value. - :param name: Name of the option. You may also specify + :param name: Name of the option. You may also specify the literal ``--OPT`` option instead of the "dest" option name. - :param default: Default value if no option of that name exists. - :param skip: If True, raise pytest.skip if option does not exists - or has a None value. + :param default: Fallback value if no option of that name is **declared** via :hook:`pytest_addoption`. + Note this parameter will be ignored when the option is **declared** even if the option's value is ``None``. + :param skip: If ``True``, raise :func:`pytest.skip` if option is undeclared or has a ``None`` value. + Note that even if ``True``, if a default was specified it will be returned instead of a skip. """ name = self._opt2dest.get(name, name) try: diff --git a/src/_pytest/legacypath.py b/src/_pytest/legacypath.py index 61476d68932..59e8ef6e742 100644 --- a/src/_pytest/legacypath.py +++ b/src/_pytest/legacypath.py @@ -304,16 +304,11 @@ def tmpdir_factory(request: FixtureRequest) -> TempdirFactory: @staticmethod @fixture def tmpdir(tmp_path: Path) -> LEGACY_PATH: - """Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. If - ``--basetemp`` is used then it is cleared each session. See - :ref:`temporary directory location and retention`. - - The returned object is a `legacy_path`_ object. + """Return a temporary directory (as `legacy_path`_ object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. .. note:: These days, it is preferred to use ``tmp_path``. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 81e52ea729d..dd36559ce1b 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -10,6 +10,7 @@ import fnmatch from functools import partial from importlib.machinery import ModuleSpec +from importlib.machinery import PathFinder import importlib.util import itertools import os @@ -37,8 +38,12 @@ from _pytest.warning_types import PytestWarning -LOCK_TIMEOUT = 60 * 60 * 24 * 3 +if sys.version_info < (3, 11): + from importlib._bootstrap_external import _NamespaceLoader as NamespaceLoader +else: + from importlib.machinery import NamespaceLoader +LOCK_TIMEOUT = 60 * 60 * 24 * 3 _AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath) @@ -611,13 +616,78 @@ def _import_module_using_spec( module_name: str, module_path: Path, module_location: Path, *, insert_modules: bool ) -> ModuleType | None: """ - Tries to import a module by its canonical name, path to the .py file, and its - parent location. + Tries to import a module by its canonical name, path, and its parent location. + + :param module_name: + The expected module name, will become the key of `sys.modules`. + + :param module_path: + The file path of the module, for example `/foo/bar/test_demo.py`. + If module is a package, pass the path to the `__init__.py` of the package. + If module is a namespace package, pass directory path. + + :param module_location: + The parent location of the module. + If module is a package, pass the directory containing the `__init__.py` file. :param insert_modules: - If True, will call insert_missing_modules to create empty intermediate modules - for made-up module names (when importing test files not reachable from sys.path). + If True, will call `insert_missing_modules` to create empty intermediate modules + with made-up module names (when importing test files not reachable from `sys.path`). + + Example 1 of parent_module_*: + + module_name: "a.b.c.demo" + module_path: Path("a/b/c/demo.py") + module_location: Path("a/b/c/") + if "a.b.c" is package ("a/b/c/__init__.py" exists), then + parent_module_name: "a.b.c" + parent_module_path: Path("a/b/c/__init__.py") + parent_module_location: Path("a/b/c/") + else: + parent_module_name: "a.b.c" + parent_module_path: Path("a/b/c") + parent_module_location: Path("a/b/") + + Example 2 of parent_module_*: + + module_name: "a.b.c" + module_path: Path("a/b/c/__init__.py") + module_location: Path("a/b/c/") + if "a.b" is package ("a/b/__init__.py" exists), then + parent_module_name: "a.b" + parent_module_path: Path("a/b/__init__.py") + parent_module_location: Path("a/b/") + else: + parent_module_name: "a.b" + parent_module_path: Path("a/b/") + parent_module_location: Path("a/") """ + # Attempt to import the parent module, seems is our responsibility: + # https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311 + parent_module_name, _, name = module_name.rpartition(".") + parent_module: ModuleType | None = None + if parent_module_name: + parent_module = sys.modules.get(parent_module_name) + if parent_module is None: + # Get parent_location based on location, get parent_path based on path. + if module_path.name == "__init__.py": + # If the current module is in a package, + # need to leave the package first and then enter the parent module. + parent_module_path = module_path.parent.parent + else: + parent_module_path = module_path.parent + + if (parent_module_path / "__init__.py").is_file(): + # If the parent module is a package, loading by __init__.py file. + parent_module_path = parent_module_path / "__init__.py" + + parent_module = _import_module_using_spec( + parent_module_name, + parent_module_path, + parent_module_path.parent, + insert_modules=insert_modules, + ) + # Checking with sys.meta_path first in case one of its hooks can import this module, # such as our own assertion-rewrite hook. for meta_importer in sys.meta_path: @@ -627,36 +697,18 @@ def _import_module_using_spec( if spec_matches_module_path(spec, module_path): break else: - spec = importlib.util.spec_from_file_location(module_name, str(module_path)) + loader = None + if module_path.is_dir(): + # The `spec_from_file_location` matches a loader based on the file extension by default. + # For a namespace package, need to manually specify a loader. + loader = NamespaceLoader(name, module_path, PathFinder()) + + spec = importlib.util.spec_from_file_location( + module_name, str(module_path), loader=loader + ) if spec_matches_module_path(spec, module_path): assert spec is not None - # Attempt to import the parent module, seems is our responsibility: - # https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311 - parent_module_name, _, name = module_name.rpartition(".") - parent_module: ModuleType | None = None - if parent_module_name: - parent_module = sys.modules.get(parent_module_name) - if parent_module is None: - # Find the directory of this module's parent. - parent_dir = ( - module_path.parent.parent - if module_path.name == "__init__.py" - else module_path.parent - ) - # Consider the parent module path as its __init__.py file, if it has one. - parent_module_path = ( - parent_dir / "__init__.py" - if (parent_dir / "__init__.py").is_file() - else parent_dir - ) - parent_module = _import_module_using_spec( - parent_module_name, - parent_module_path, - parent_dir, - insert_modules=insert_modules, - ) - # Find spec and import this module. mod = importlib.util.module_from_spec(spec) sys.modules[module_name] = mod @@ -675,10 +727,21 @@ def _import_module_using_spec( def spec_matches_module_path(module_spec: ModuleSpec | None, module_path: Path) -> bool: """Return true if the given ModuleSpec can be used to import the given module path.""" - if module_spec is None or module_spec.origin is None: + if module_spec is None: return False - return Path(module_spec.origin) == module_path + if module_spec.origin: + return Path(module_spec.origin) == module_path + + # Compare the path with the `module_spec.submodule_Search_Locations` in case + # the module is part of a namespace package. + # https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations + if module_spec.submodule_search_locations: # can be None. + for path in module_spec.submodule_search_locations: + if Path(path) == module_path: + return True + + return False # Implement a special _is_same function on Windows which returns True if the two filenames diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 4174a55b589..f0035f0c393 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -262,19 +262,22 @@ def _repr_compare(self, other_side: Mapping[object, float]) -> list[str]: ): if approx_value != other_value: if approx_value.expected is not None and other_value is not None: - max_abs_diff = max( - max_abs_diff, abs(approx_value.expected - other_value) - ) - if approx_value.expected == 0.0: - max_rel_diff = math.inf - else: - max_rel_diff = max( - max_rel_diff, - abs( - (approx_value.expected - other_value) - / approx_value.expected - ), + try: + max_abs_diff = max( + max_abs_diff, abs(approx_value.expected - other_value) ) + if approx_value.expected == 0.0: + max_rel_diff = math.inf + else: + max_rel_diff = max( + max_rel_diff, + abs( + (approx_value.expected - other_value) + / approx_value.expected + ), + ) + except ZeroDivisionError: + pass different_ids.append(approx_key) message_data = [ @@ -398,8 +401,10 @@ def __repr__(self) -> str: # Don't show a tolerance for values that aren't compared using # tolerances, i.e. non-numerics and infinities. Need to call abs to # handle complex numbers, e.g. (inf + 1j). - if (not isinstance(self.expected, (Complex, Decimal))) or math.isinf( - abs(self.expected) + if ( + isinstance(self.expected, bool) + or (not isinstance(self.expected, (Complex, Decimal))) + or math.isinf(abs(self.expected) or isinstance(self.expected, bool)) ): return str(self.expected) @@ -427,14 +432,17 @@ def __eq__(self, actual) -> bool: # numpy<1.13. See #3748. return all(self.__eq__(a) for a in asarray.flat) - # Short-circuit exact equality. - if actual == self.expected: + # Short-circuit exact equality, except for bool + if isinstance(self.expected, bool) and not isinstance(actual, bool): + return False + elif actual == self.expected: return True # If either type is non-numeric, fall back to strict equality. # NB: we need Complex, rather than just Number, to ensure that __abs__, - # __sub__, and __float__ are defined. - if not ( + # __sub__, and __float__ are defined. Also, consider bool to be + # nonnumeric, even though it has the required arithmetic. + if isinstance(self.expected, bool) or not ( isinstance(self.expected, (Complex, Decimal)) and isinstance(actual, (Complex, Decimal)) ): diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 85d8de84abb..0dc002edd94 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -35,8 +35,7 @@ def recwarn() -> Generator[WarningsRecorder]: """Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. - See https://docs.pytest.org/en/latest/how-to/capture-warnings.html for information - on warning categories. + See :ref:`warnings` for information on warning categories. """ wrec = WarningsRecorder(_ispytest=True) with wrec: diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index de0cbcfeb1c..1731a4b8d0d 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -41,9 +41,8 @@ @final @dataclasses.dataclass class TempPathFactory: - """Factory for temporary directories under the common base temp directory. - - The base directory can be configured using the ``--basetemp`` option. + """Factory for temporary directories under the common base temp directory, + as discussed at :ref:`temporary directory location and retention`. """ _given_basetemp: Path | None @@ -257,18 +256,11 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path: def tmp_path( request: FixtureRequest, tmp_path_factory: TempPathFactory ) -> Generator[Path]: - """Return a temporary directory path object which is unique to each test - function invocation, created as a sub directory of the base temporary - directory. - - By default, a new base temporary directory is created each test session, - and old bases are removed after 3 sessions, to aid in debugging. - This behavior can be configured with :confval:`tmp_path_retention_count` and - :confval:`tmp_path_retention_policy`. - If ``--basetemp`` is used then it is cleared each session. See - :ref:`temporary directory location and retention`. - - The returned object is a :class:`pathlib.Path` object. + """Return a temporary directory (as :class:`pathlib.Path` object) + which is unique to each test function invocation. + The temporary directory is created as a subdirectory + of the base temporary directory, with configurable retention, + as discussed in :ref:`temporary directory location and retention`. """ path = _mk_tmp(request, tmp_path_factory) yield path diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index fc60ae9ac99..97c207e9795 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1194,6 +1194,23 @@ def f(): line = tw_mock.lines[-1] assert line == ":3: ValueError" + def test_toterminal_value(self, importasmod, tw_mock): + mod = importasmod( + """ + def g(x): + raise ValueError(x) + def f(): + g('some_value') + """ + ) + excinfo = pytest.raises(ValueError, mod.f) + excinfo.traceback = excinfo.traceback.filter(excinfo) + repr = excinfo.getrepr(style="value") + repr.toterminal(tw_mock) + + assert tw_mock.get_write_msg(0) == "some_value" + assert tw_mock.get_write_msg(1) == "\n" + @pytest.mark.parametrize( "reproptions", [ diff --git a/testing/conftest.py b/testing/conftest.py index 046bb77a109..69af03324d6 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -119,8 +119,8 @@ def markup(self, text, **kw): return text def get_write_msg(self, idx): - flag, msg = self.lines[idx] - assert flag == TWMock.WRITE + assert self.lines[idx][0] == TWMock.WRITE + msg = self.lines[idx][1] return msg fullwidth = 80 diff --git a/testing/python/approx.py b/testing/python/approx.py index 69743cdbe17..cb1704f6ec4 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -90,12 +90,25 @@ def do_assert(lhs, rhs, expected_message, verbosity_level=0): return do_assert -SOME_FLOAT = r"[+-]?([0-9]*[.])?[0-9]+\s*" +SOME_FLOAT = r"[+-]?((?:([0-9]*[.])?[0-9]+(e-?[0-9]+)?)|inf|nan)\s*" SOME_INT = r"[0-9]+\s*" class TestApprox: def test_error_messages_native_dtypes(self, assert_approx_raises_regex): + # Treat bool exactly. + assert_approx_raises_regex( + {"a": 1.0, "b": True}, + {"a": 1.0, "b": False}, + [ + "", + " comparison failed. Mismatched elements: 1 / 2:", + f" Max absolute difference: {SOME_FLOAT}", + f" Max relative difference: {SOME_FLOAT}", + r" Index\s+\| Obtained\s+\| Expected", + r".*(True|False)\s+", + ], + ) assert_approx_raises_regex( 2.0, 1.0, @@ -590,6 +603,13 @@ def test_complex(self): assert approx(x, rel=5e-6, abs=0) == a assert approx(x, rel=5e-7, abs=0) != a + def test_expecting_bool(self) -> None: + assert True == approx(True) # noqa: E712 + assert False == approx(False) # noqa: E712 + assert True != approx(False) # noqa: E712 + assert True != approx(False, abs=2) # noqa: E712 + assert 1 != approx(True) + def test_list(self): actual = [1 + 1e-7, 2 + 1e-8] expected = [1, 2] @@ -655,6 +675,7 @@ def test_dict_wrong_len(self): def test_dict_nonnumeric(self): assert {"a": 1.0, "b": None} == pytest.approx({"a": 1.0, "b": None}) assert {"a": 1.0, "b": 1} != pytest.approx({"a": 1.0, "b": None}) + assert {"a": 1.0, "b": True} != pytest.approx({"a": 1.0, "b": False}, abs=2) def test_dict_vs_other(self): assert 1 != approx({"a": 0}) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 46f0a762cbb..c939b221f22 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2996,7 +2996,7 @@ def test_finish(): *3 passed* """ ) - result.stdout.no_fnmatch_line("*error*") + assert result.ret == 0 def test_fixture_finalizer(self, pytester: Pytester) -> None: pytester.makeconftest( diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 73c11a1a9d8..7be473d897a 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -2,10 +2,12 @@ from __future__ import annotations import ast +import dis import errno from functools import partial import glob import importlib +import inspect import marshal import os from pathlib import Path @@ -131,10 +133,211 @@ def test_location_is_set(self) -> None: continue for n in [node, *ast.iter_child_nodes(node)]: assert isinstance(n, (ast.stmt, ast.expr)) - assert n.lineno == 3 - assert n.col_offset == 0 - assert n.end_lineno == 6 - assert n.end_col_offset == 3 + for location in [ + (n.lineno, n.col_offset), + (n.end_lineno, n.end_col_offset), + ]: + assert (3, 0) <= location <= (6, 3) + + def test_positions_are_preserved(self) -> None: + """Ensure AST positions are preserved during rewriting (#12818).""" + + def preserved(code: str) -> None: + s = textwrap.dedent(code) + locations = [] + + def loc(msg: str | None = None) -> None: + frame = inspect.currentframe() + assert frame + frame = frame.f_back + assert frame + frame = frame.f_back + assert frame + + offset = frame.f_lasti + + instructions = {i.offset: i for i in dis.get_instructions(frame.f_code)} + + # skip CACHE instructions + while offset not in instructions and offset >= 0: + offset -= 1 + + instruction = instructions[offset] + if sys.version_info >= (3, 11): + position = instruction.positions + else: + position = instruction.starts_line + + locations.append((msg, instruction.opname, position)) + + globals = {"loc": loc} + + m = rewrite(s) + mod = compile(m, "", "exec") + exec(mod, globals, globals) + transformed_locations = locations + locations = [] + + mod = compile(s, "", "exec") + exec(mod, globals, globals) + original_locations = locations + + assert len(original_locations) > 0 + assert original_locations == transformed_locations + + preserved(""" + def f(): + loc() + return 8 + + assert f() in [8] + assert (f() + in + [8]) + """) + + preserved(""" + class T: + def __init__(self): + loc("init") + def __getitem__(self,index): + loc("getitem") + return index + + assert T()[5] == 5 + assert (T + () + [5] + == + 5) + """) + + for name, op in [ + ("pos", "+"), + ("neg", "-"), + ("invert", "~"), + ]: + preserved(f""" + class T: + def __{name}__(self): + loc("{name}") + return "{name}" + + assert {op}T() == "{name}" + assert ({op} + T + () + == + "{name}") + """) + + for name, op in [ + ("add", "+"), + ("sub", "-"), + ("mul", "*"), + ("truediv", "/"), + ("floordiv", "//"), + ("mod", "%"), + ("pow", "**"), + ("lshift", "<<"), + ("rshift", ">>"), + ("or", "|"), + ("xor", "^"), + ("and", "&"), + ("matmul", "@"), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc("{name}") + return other + + def __r{name}__(self,other): + loc("r{name}") + return other + + assert T() {op} 2 == 2 + assert 2 {op} T() == 2 + + assert (T + () + {op} + 2 + == + 2) + + assert (2 + {op} + T + () + == + 2) + """) + + for name, op in [ + ("eq", "=="), + ("ne", "!="), + ("lt", "<"), + ("le", "<="), + ("gt", ">"), + ("ge", ">="), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc() + return True + + assert T() {op} 5 + assert (T + () + {op} + 5) + """) + + for name, op in [ + ("eq", "=="), + ("ne", "!="), + ("lt", ">"), + ("le", ">="), + ("gt", "<"), + ("ge", "<="), + ("contains", "in"), + ]: + preserved(f""" + class T: + def __{name}__(self,other): + loc() + return True + + assert 5 {op} T() + assert (5 + {op} + T + ()) + """) + + preserved(""" + def func(value): + loc("func") + return value + + class T: + def __iter__(self): + loc("iter") + return iter([5]) + + assert func(*T()) == 5 + """) + + preserved(""" + class T: + def __getattr__(self,name): + loc() + return name + + assert T().attr == "attr" + """) def test_dont_rewrite(self) -> None: s = """'PYTEST_DONT_REWRITE'\nassert 14""" diff --git a/testing/test_config.py b/testing/test_config.py index daf69845bb1..bdeaed14721 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -636,7 +636,7 @@ def test_config_trace(self, pytester: Pytester) -> None: assert len(values) == 1 assert values[0] == "hello [config]\n" - def test_config_getoption(self, pytester: Pytester) -> None: + def test_config_getoption_declared_option_name(self, pytester: Pytester) -> None: pytester.makeconftest( """ def pytest_addoption(parser): @@ -648,6 +648,18 @@ def pytest_addoption(parser): assert config.getoption(x) == "this" pytest.raises(ValueError, config.getoption, "qweqwe") + config_novalue = pytester.parseconfig() + assert config_novalue.getoption("hello") is None + assert config_novalue.getoption("hello", default=1) is None + assert config_novalue.getoption("hello", default=1, skip=True) == 1 + + def test_config_getoption_undeclared_option_name(self, pytester: Pytester) -> None: + config = pytester.parseconfig() + with pytest.raises(ValueError): + config.getoption("x") + assert config.getoption("x", default=1) == 1 + assert config.getoption("x", default=1, skip=True) == 1 + def test_config_getoption_unicode(self, pytester: Pytester) -> None: pytester.makeconftest( """ @@ -675,12 +687,6 @@ def pytest_addoption(parser): with pytest.raises(pytest.skip.Exception): config.getvalueorskip("hello") - def test_getoption(self, pytester: Pytester) -> None: - config = pytester.parseconfig() - with pytest.raises(ValueError): - config.getvalue("x") - assert config.getoption("x", 1) == 1 - def test_getconftest_pathlist(self, pytester: Pytester, tmp_path: Path) -> None: somepath = tmp_path.joinpath("x", "y", "z") p = tmp_path.joinpath("conftest.py") diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 37032f92354..73a4b769ff7 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -768,9 +768,13 @@ def test_pdb_used_outside_test(self, pytester: Pytester) -> None: x = 5 """ ) + if sys.version_info[:2] >= (3, 13): + break_line = "pytest.set_trace()" + else: + break_line = "x = 5" child = pytester.spawn(f"{sys.executable} {p1}") - child.expect("x = 5") - child.expect("Pdb") + child.expect_exact(break_line) + child.expect_exact("Pdb") child.sendeof() self.flush(child) @@ -785,9 +789,13 @@ def test_foo(a): pass """ ) + if sys.version_info[:2] >= (3, 13): + break_line = "pytest.set_trace()" + else: + break_line = "x = 5" child = pytester.spawn_pytest(str(p1)) - child.expect("x = 5") - child.expect("Pdb") + child.expect_exact(break_line) + child.expect_exact("Pdb") child.sendeof() self.flush(child) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 7c0a0e03d76..62359303f3b 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -17,7 +17,9 @@ from typing import Sequence import unittest.mock +from _pytest.config import ExitCode from _pytest.monkeypatch import MonkeyPatch +from _pytest.pathlib import _import_module_using_spec from _pytest.pathlib import bestrelpath from _pytest.pathlib import commonpath from _pytest.pathlib import compute_module_name @@ -36,6 +38,7 @@ from _pytest.pathlib import resolve_package_path from _pytest.pathlib import resolve_pkg_root_and_module_name from _pytest.pathlib import safe_exists +from _pytest.pathlib import spec_matches_module_path from _pytest.pathlib import symlink_or_skip from _pytest.pathlib import visit from _pytest.pytester import Pytester @@ -416,7 +419,7 @@ def test_no_meta_path_found( del sys.modules[module.__name__] monkeypatch.setattr( - importlib.util, "spec_from_file_location", lambda *args: None + importlib.util, "spec_from_file_location", lambda *args, **kwargs: None ) with pytest.raises(ImportError): import_path( @@ -780,6 +783,62 @@ def test_insert_missing_modules( insert_missing_modules(modules, "") assert modules == {} + @pytest.mark.parametrize("b_is_package", [True, False]) + @pytest.mark.parametrize("insert_modules", [True, False]) + def test_import_module_using_spec( + self, b_is_package, insert_modules, tmp_path: Path + ): + """ + Verify that `_import_module_using_spec` can obtain a spec based on the path, thereby enabling the import. + When importing, not only the target module is imported, but also the parent modules are recursively imported. + """ + file_path = tmp_path / "a/b/c/demo.py" + file_path.parent.mkdir(parents=True) + file_path.write_text("my_name='demo'", encoding="utf-8") + + if b_is_package: + (tmp_path / "a/b/__init__.py").write_text( + "my_name='b.__init__'", encoding="utf-8" + ) + + mod = _import_module_using_spec( + "a.b.c.demo", + file_path, + file_path.parent, + insert_modules=insert_modules, + ) + + # target module is imported + assert mod is not None + assert spec_matches_module_path(mod.__spec__, file_path) is True + + mod_demo = sys.modules["a.b.c.demo"] + assert "demo.py" in str(mod_demo) + assert mod_demo.my_name == "demo" # Imported and available for use + + # parent modules are recursively imported. + mod_a = sys.modules["a"] + mod_b = sys.modules["a.b"] + mod_c = sys.modules["a.b.c"] + + assert mod_a.b is mod_b + assert mod_a.b.c is mod_c + assert mod_a.b.c.demo is mod_demo + + assert "namespace" in str(mod_a).lower() + assert "namespace" in str(mod_c).lower() + + # Compatibility package and namespace package. + if b_is_package: + assert "namespace" not in str(mod_b).lower() + assert "__init__.py" in str(mod_b).lower() # Imported __init__.py + assert mod_b.my_name == "b.__init__" # Imported and available for use + + else: + assert "namespace" in str(mod_b).lower() + with pytest.raises(AttributeError): # Not imported __init__.py + assert mod_b.my_name + def test_parent_contains_child_module_attribute( self, monkeypatch: MonkeyPatch, tmp_path: Path ): @@ -1542,6 +1601,19 @@ def test_full_ns_packages_without_init_files( ) == (tmp_path / "src/dist2", "ns.a.core.foo.m") +def test_ns_import_same_name_directory_12592( + tmp_path: Path, pytester: Pytester +) -> None: + """Regression for `--import-mode=importlib` with directory parent and child with same name (#12592).""" + y_dir = tmp_path / "x/y/y" + y_dir.mkdir(parents=True) + test_y = tmp_path / "x/y/test_y.py" + test_y.write_text("def test(): pass", encoding="UTF-8") + + result = pytester.runpytest("--import-mode=importlib", test_y) + assert result.ret == ExitCode.OK + + def test_is_importable(pytester: Pytester) -> None: pytester.syspathinsert()