Repository: espressif/pytest-embedded (language: python)

Issue:
v1.1.0 breaks the quick example (RDT-352)
The commit from #172 adds a dependency on pytest-embedded-idf without updating the pytest-embedded package's dependencies.

Now if users are following the quick example in the readme they will be treated to an error like this:
```console
pytest
Traceback (most recent call last):
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/bin/pytest", line 8, in <module>
    sys.exit(console_main())
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 190, in console_main
    code = main()
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 148, in main
    config = _prepareconfig(args, plugins)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 329, in _prepareconfig
    config = pluginmanager.hook.pytest_cmdline_parse(
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_hooks.py", line 265, in __call__
    return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_manager.py", line 80, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_callers.py", line 55, in _multicall
    gen.send(outcome)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/helpconfig.py", line 103, in pytest_cmdline_parse
    config: Config = outcome.get_result()
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_result.py", line 60, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_callers.py", line 39, in _multicall
    res = hook_impl.function(*args)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1058, in pytest_cmdline_parse
    self.parse(args)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1346, in parse
    self._preparse(args, addopts=addopts)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/config/__init__.py", line 1229, in _preparse
    self.pluginmanager.load_setuptools_entrypoints("pytest11")
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pluggy/_manager.py", line 287, in load_setuptools_entrypoints
    plugin = ep.load()
  File "/home/daltonv/.pyenv/versions/3.10.2/lib/python3.10/importlib/metadata/__init__.py", line 162, in load
    module = import_module(match.group('module'))
  File "/home/daltonv/.pyenv/versions/3.10.2/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/_pytest/assertion/rewrite.py", line 168, in exec_module
    exec(co, module.__dict__)
  File "/home/daltonv/scratch/pytest_embedded_test/.venv/lib/python3.10/site-packages/pytest_embedded/plugin.py", line 42, in <module>
    from pytest_embedded_idf import CaseTester
ModuleNotFoundError: No module named 'pytest_embedded_idf'
```

The `pytest-embedded/setup.py` file should be modified to explicitly add this new dependency:
```python
REQUIRES = [
    'pytest>=7.0',
    'pexpect>=4.4',
    'pytest_embedded>=1.1.0' # not sure if that exact version is the one needed
]
```



Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index e5a919ff..8332de34 100644
--- a/.flake8
+++ b/.flake8
@@ -1,143 +1,3 @@
 [flake8]
-
-select =
-    # Full lists are given in order to surpress all errors from other plugins
-    # Full list of pyflakes error codes:
-    F401,           # module imported but unused
-    F402,           # import module from line N shadowed by loop variable
-    F403,           # 'from module import *' used; unable to detect undefined names
-    F404,           # future import(s) name after other statements
-    F405,           # name may be undefined, or defined from star imports: module
-    F406,           # 'from module import *' only allowed at module level
-    F407,           # an undefined __future__ feature name was imported
-    F601,           # dictionary key name repeated with different values
-    F602,           # dictionary key variable name repeated with different values
-    F621,           # too many expressions in an assignment with star-unpacking
-    F622,           # two or more starred expressions in an assignment (a, *b, *c = d)
-    F631,           # assertion test is a tuple, which are always True
-    F701,           # a break statement outside of a while or for loop
-    F702,           # a continue statement outside of a while or for loop
-    F703,           # a continue statement in a finally block in a loop
-    F704,           # a yield or yield from statement outside of a function
-    F705,           # a return statement with arguments inside a generator
-    F706,           # a return statement outside of a function/method
-    F707,           # an except: block as not the last exception handler
-    F721, F722,     #    doctest syntax error syntax error in forward type annotation
-    F811,           # redefinition of unused name from line N
-    F812,           # list comprehension redefines name from line N
-    F821,           # undefined name name
-    F822,           # undefined name name in __all__
-    F823,           # local variable name referenced before assignment
-    F831,           # duplicate argument name in function definition
-    F841,           # local variable name is assigned to but never used
-    F901,           # raise NotImplemented should be raise NotImplementedError
-
-    # Full list of pycodestyle violations:
-    E101,           # indentation contains mixed spaces and tabs
-    E111,           # indentation is not a multiple of four
-    E112,           # expected an indented block
-    E113,           # unexpected indentation
-    E114,           # indentation is not a multiple of four (comment)
-    E115,           # expected an indented block (comment)
-    E116,           # unexpected indentation (comment)
-    E121,           # continuation line under-indented for hanging indent
-    E122,           # continuation line missing indentation or outdented
-    E123,           # closing bracket does not match indentation of opening bracket's line
-    E124,           # closing bracket does not match visual indentation
-    E125,           # continuation line with same indent as next logical line
-    E126,           # continuation line over-indented for hanging indent
-    E127,           # continuation line over-indented for visual indent
-    E128,           # continuation line under-indented for visual indent
-    E129,           # visually indented line with same indent as next logical line
-    E131,           # continuation line unaligned for hanging indent
-    E133,           # closing bracket is missing indentation
-    E201,           # whitespace after '('
-    E202,           # whitespace before ')'
-    E211,           # whitespace before '('
-    E221,           # multiple spaces before operator
-    E222,           # multiple spaces after operator
-    E223,           # tab before operator
-    E224,           # tab after operator
-    E225,           # missing whitespace around operator
-    E226,           # missing whitespace around arithmetic operator
-    E227,           # missing whitespace around bitwise or shift operator
-    E228,           # missing whitespace around modulo operator
-    E231,           # missing whitespace after ',', ';', or ':'
-    E241,           # multiple spaces after ','
-    E242,           # tab after ','
-    E251,           # unexpected spaces around keyword / parameter equals
-    E261,           # at least two spaces before inline comment
-    E262,           # inline comment should start with '# '
-    E265,           # block comment should start with '# '
-    E266,           # too many leading '#' for block comment
-    E271,           # multiple spaces after keyword
-    E272,           # multiple spaces before keyword
-    E273,           # tab after keyword
-    E274,           # tab before keyword
-    E275,           # missing whitespace after keyword
-    E301,           # expected 1 blank line, found 0
-    E302,           # expected 2 blank lines, found 0
-    E303,           # too many blank lines
-    E304,           # blank lines found after function decorator
-    E305,           # expected 2 blank lines after end of function or class
-    E306,           # expected 1 blank line before a nested definition
-    E401,           # multiple imports on one line
-    E402,           # module level import not at top of file
-    E501,           # line too long (82 > 79 characters)
-    E502,           # the backslash is redundant between brackets
-    E701,           # multiple statements on one line (colon)
-    E702,           # multiple statements on one line (semicolon)
-    E703,           # statement ends with a semicolon
-    E704,           # multiple statements on one line (def)
-    E711,           # comparison to None should be 'if cond is None:'
-    E712,           # comparison to True should be 'if cond is True:' or 'if cond:'
-    E713,           # test for membership should be 'not in'
-    E714,           # test for object identity should be 'is not'
-    E721,           # do not compare types, use 'isinstance()'
-    E722,           # do not use bare except, specify exception instead
-    E731,           # do not assign a lambda expression, use a def
-    E741,           # do not use variables named 'l', 'O', or 'I'
-    E742,           # do not define classes named 'l', 'O', or 'I'
-    E743,           # do not define functions named 'l', 'O', or 'I'
-    E901,           # SyntaxError or IndentationError
-    E902,           # IOError
-    W191,           # indentation contains tabs
-    W291,           # trailing whitespace
-    W292,           # no newline at end of file
-    W293,           # blank line contains whitespace
-    W391,           # blank line at end of file
-    W503,           # line break before binary operator
-    W504,           # line break after binary operator
-    W505,           # doc line too long (82 > 79 characters)
-    W601,           # .has_key() is deprecated, use 'in'
-    W602,           # deprecated form of raising exception
-    W603,           # '<>' is deprecated, use '!='
-    W604,           # backticks are deprecated, use 'repr()'
-    W605,           # invalid escape sequence 'x'
-    W606,           # 'async' and 'await' are reserved keywords starting with Python 3.7
-
-    # Full list of flake8 violations
-    E999,           # failed to compile a file into an Abstract Syntax Tree for the plugins that require it
-
-    # Full list of mccabe violations
-    C901            # complexity value provided by the user
-
-ignore =
-    E221,           # multiple spaces before operator
-    E231,           # missing whitespace after ',', ';', or ':'
-    E241,           # multiple spaces after ','
-    W503,           # line break before binary operator
-    W504            # line break after binary operator
-    # black required
-    E203,           # whitespace before ':'
-
 max-line-length = 120
-
-show_source = True
-
-statistics = True
-
-exclude = .git,.tox,node_modules/*
-inline-quotes = double
-multiline-quotes = double
-docstring-quotes = double
+extend-ignore = E203
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 00565019..1d75c77f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,7 +8,7 @@ exclude: |
 
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.3.0
+    rev: v4.4.0
     hooks:
       - id: trailing-whitespace
         exclude: &whitespace_excludes |
@@ -22,20 +22,20 @@ repos:
         args: [ '-f=lf' ]
       - id: double-quote-string-fixer
   - repo: https://github.com/ikamensh/flynt/
-    rev: '0.76'
+    rev: '0.77'
     hooks:
       - id: flynt
   - repo: https://github.com/psf/black
-    rev: 22.10.0
+    rev: 22.12.0
     hooks:
       - id: black
         args: [ '--skip-string-normalization' ]
   - repo: https://github.com/pycqa/flake8
-    rev: 5.0.4
+    rev: 6.0.0
     hooks:
       - id: flake8
   - repo: https://github.com/pycqa/isort
-    rev: 5.10.1
+    rev: 5.11.4
     hooks:
       - id: isort
         name: isort (python)


Test patch:
diff --git a/conftest.py b/conftest.py
index 64de6517..52dabddd 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,8 +1,13 @@
 import os
+import sys
+from copy import deepcopy
 from distutils.dir_util import copy_tree
 from typing import List, Pattern
 
+import pkg_resources
 import pytest
+from _pytest.config import Config
+from _pytest.fixtures import FixtureRequest
 
 pytest_plugins = [
     'pytester',
@@ -25,3 +30,25 @@ def _fake(_pattern: Pattern, _messages: List[str], _start: int = 0) -> int:
         raise AssertionError(f'Not found {_pattern.pattern}')
 
     return _fake
+
+
+@pytest.fixture(autouse=True)
+def temp_disable_packages(monkeypatch, request: FixtureRequest):
+    temp_marker = request.node.get_closest_marker('temp_disable_packages')
+    if not temp_marker:
+        return
+
+    packages = temp_marker.args
+    for name in list(sys.modules):
+        if name in packages or name.split('.')[0] in packages:
+            monkeypatch.setitem(sys.modules, name, None)
+
+    deepcopy_sys_path = deepcopy(sys.path)
+    for package in packages:
+        deepcopy_sys_path.remove(pkg_resources.get_distribution(package).location)
+    monkeypatch.setattr(sys, 'path', deepcopy_sys_path)
+
+
+def pytest_configure(config: Config) -> None:
+    for name, description in {'temp_disable_packages': 'disable packages in function scope'}.items():
+        config.addinivalue_line('markers', f'{name}: {description}')
diff --git a/pytest-embedded-idf/pytest_embedded_idf/dut.py b/pytest-embedded-idf/pytest_embedded_idf/dut.py
index d1304aec..dfdcb6e0 100644
--- a/pytest-embedded-idf/pytest_embedded_idf/dut.py
+++ b/pytest-embedded-idf/pytest_embedded_idf/dut.py
@@ -68,7 +68,7 @@ def toolchain_prefix(self) -> str:
         if self.target in self.XTENSA_TARGETS:
             return f'xtensa-{self.target}-elf-'
         elif self.target in self.RISCV32_TARGETS:
-            return f'riscv32-esp-elf-'
+            return 'riscv32-esp-elf-'
         else:
             raise ValueError(f'Unknown target: {self.target}')
 
diff --git a/pytest-embedded-idf/tests/test_idf.py b/pytest-embedded-idf/tests/test_idf.py
index 94d0fb90..0fb9caf7 100644
--- a/pytest-embedded-idf/tests/test_idf.py
+++ b/pytest-embedded-idf/tests/test_idf.py
@@ -1,11 +1,9 @@
 import os
 import platform
 import re
-import sys
 import tempfile
 import xml.etree.ElementTree as ET
 
-import pkg_resources
 import pytest
 from pytest_embedded_idf.dut import IdfDut
 
@@ -15,17 +13,6 @@
 )
 
 
-@pytest.fixture
-def remove_serial(monkeypatch):
-    for name in list(sys.modules):
-        if name == 'pytest_embedded_serial' or name.startswith('pytest_embedded_serial.'):
-            monkeypatch.setitem(sys.modules, name, None)
-    _path = pkg_resources.get_distribution('pytest_embedded_serial').location
-    sys.path.remove(_path)
-    yield
-    sys.path.append(_path)
-
-
 def test_idf_serial_flash(testdir):
     testdir.makepyfile("""
         import pexpect
@@ -372,7 +359,8 @@ def test_detect_port(dut):
 
 
 @pytest.mark.skipif(platform.machine() != 'x86_64', reason='The test is intended to be run on an x86_64 machine.')
-def test_hello_world_linux(testdir, remove_serial):
+@pytest.mark.temp_disable_packages('pytest_embedded_serial')
+def test_hello_world_linux(testdir):
     testdir.makepyfile(r"""
         def test_hello_world_linux(dut):
             dut.expect('Hello world!')
diff --git a/pytest-embedded/pytest_embedded/plugin.py b/pytest-embedded/pytest_embedded/plugin.py
index cf00c0a1..08542fa3 100644
--- a/pytest-embedded/pytest_embedded/plugin.py
+++ b/pytest-embedded/pytest_embedded/plugin.py
@@ -40,7 +40,6 @@
 from _pytest.main import Session
 from _pytest.outcomes import TEST_OUTCOME
 from _pytest.python import Function
-from pytest_embedded_idf import CaseTester
 
 from .app import App
 from .dut import Dut
@@ -49,12 +48,10 @@
 from .utils import Meta, find_by_suffix, to_list, to_str
 
 if TYPE_CHECKING:
-    from pytest_embedded_idf.dut import IdfDut
-    from pytest_embedded_idf.serial import LinuxSerial
-    from pytest_embedded_jtag.gdb import Gdb
-    from pytest_embedded_jtag.openocd import OpenOcd
-    from pytest_embedded_qemu.qemu import Qemu
-    from pytest_embedded_serial.serial import Serial
+    from pytest_embedded_idf import CaseTester, IdfDut, LinuxSerial
+    from pytest_embedded_jtag import Gdb, OpenOcd
+    from pytest_embedded_qemu import Qemu
+    from pytest_embedded_serial import Serial
 
 
 _T = TypeVar('_T')
@@ -1310,13 +1307,18 @@ def dut(
 
 
 @pytest.fixture
-def unity_tester(dut: Union['IdfDut', Tuple['IdfDut']]) -> Optional[CaseTester]:
-    # all dut instance must be IdfDut to use this fixture
-    for _dut in to_list(dut):
-        if _dut.__class__.__name__ != 'IdfDut':  # hard code string to avoid ImportError
-            yield None
+def unity_tester(dut: Union['IdfDut', Tuple['IdfDut']]) -> Optional['CaseTester']:
+    try:
+        from pytest_embedded_idf import CaseTester, IdfDut
+    except ImportError:
+        yield None
+    else:
+        # all dut instance must be IdfDut to use this fixture
+        for _dut in to_list(dut):
+            if not isinstance(_dut, IdfDut):
+                yield None
 
-    yield CaseTester(to_list(dut))
+        yield CaseTester(to_list(dut))
 
 
 ##################
diff --git a/pytest-embedded/tests/test_base.py b/pytest-embedded/tests/test_base.py
index fc2790a5..418de13e 100644
--- a/pytest-embedded/tests/test_base.py
+++ b/pytest-embedded/tests/test_base.py
@@ -516,3 +516,38 @@ def test_duplicate_two():
     testdir.runpytest('--check-duplicates', 'y')
 
     assert "ValueError: Duplicated test scripts: ['test_duplicate_module.py']" in capsys.readouterr().out
+
+
+@pytest.mark.temp_disable_packages('pytest_embedded_serial', 'pytest_embedded_idf')
+def test_temp_disable_packages():
+    with pytest.raises(ImportError):
+        import pytest_embedded_serial.serial  # noqa
+
+    with pytest.raises(ImportError):
+        import pytest_embedded_serial  # noqa
+
+    with pytest.raises(ImportError):
+        import pytest_embedded_idf  # noqa
+
+
+@pytest.mark.temp_disable_packages('pytest_embedded_serial',
+                                   'pytest_embedded_serial_esp',
+                                   'pytest_embedded_idf',
+                                   'pytest_embedded_qemu',
+                                   'pytest_embedded_jtag',
+                                   'pytest_embedded_arduino')
+def test_quick_example(testdir):
+    testdir.makepyfile(r"""
+    from pytest_embedded import Dut
+
+    def test_quick_example(redirect, dut: Dut):
+        with redirect():
+            print('this would be redirected')
+
+        dut.expect('this')
+        dut.expect_exact('would')
+        dut.expect('[be]{2}')
+        dut.expect_exact('redirected')
+    """)
+    result = testdir.runpytest('--root-logdir', testdir.tmpdir)
+    result.assert_outcomes(passed=1)