├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── pytest_srcpaths.py ├── tests ├── conftest.py ├── test_pythonpath.py └── test_srcpaths.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python: ["3.7", "3.8", "3.9", "3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python }} 19 | - name: Install Tox and any other packages 20 | run: pip install tox 21 | - name: Run Tox 22 | run: tox -e py 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # PyCharm 132 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Brian Okken 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-srcpaths 2 | 3 | Add paths to `sys.path`. 4 | A pytest plugin to help pytest find the code you want to test. 5 | 6 | 7 | ## Installation 8 | 9 | Install with pip: 10 | 11 | pip install pytest-srcpaths 12 | 13 | 14 | ## Usage 15 | 16 | Add a line in your pytest.ini file with a key of `srcpaths`. 17 | It should contain a space-separated list of paths. 18 | 19 | [pytest] 20 | srcpaths = src lib 21 | 22 | Paths are relative to the directory that pytest.ini is in. 23 | You can include the top level directory with a dot. 24 | 25 | [pytest] 26 | srcpaths = . 27 | 28 | ## Alternatively, use `pythonpath` 29 | 30 | The option `pythonpath` also works the same. 31 | 32 | [pytest] 33 | pythonpath = src lib 34 | 35 | pytest 7 includes the `pythonpath` option. 36 | 37 | For pytest 6.2.x, this plugin will work. 38 | 39 | 40 | ## Changelog 41 | 42 | * 1.2.1 - Add `pythonpath` as an alternative to `srcpath` option for pytest versions < 7.0.0 43 | * this is to allow this project to act as a temporary workaround until pytest 7 is released 44 | 45 | ## Similar project 46 | 47 | This plugin was inspired by [pytest-pythonpath](https://pypi.org/project/pytest-pythonpath/) whose implementation and scope are a bit different. 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pytest-srcpaths" 7 | authors = [{name = "Brian Okken", email = "brian+pypi@pythontest.com"}] 8 | readme = "README.md" 9 | classifiers = ["License :: OSI Approved :: MIT License"] 10 | dynamic = ["version", "description"] 11 | dependencies = ["pytest>=6.2.0"] 12 | requires-python=">=3.7" 13 | 14 | [project.entry-points."pytest11"] 15 | plugin = "pytest_srcpaths" 16 | 17 | [project.optional-dependencies] 18 | test = [ "tox" ] 19 | 20 | [project.urls] 21 | Home = "https://github.com/okken/pytest-srcpaths" 22 | 23 | [tool.flit.module] 24 | name = "pytest_srcpaths" 25 | -------------------------------------------------------------------------------- /pytest_srcpaths.py: -------------------------------------------------------------------------------- 1 | """ 2 | Add paths to sys.path 3 | """ 4 | from packaging import version 5 | import sys 6 | 7 | import pytest 8 | 9 | __version__ = "1.2.1" 10 | 11 | def pytest_addoption(parser) -> None: 12 | parser.addini("srcpaths", 13 | type="pathlist", 14 | help="Add paths to sys.path", 15 | default=[]) 16 | if version.parse(pytest.__version__) < version.parse("7.0.0"): 17 | parser.addini("pythonpath", 18 | type="pathlist", 19 | help="Add paths to sys.path", 20 | default=[]) 21 | 22 | 23 | @pytest.hookimpl(tryfirst=True) 24 | def pytest_configure(config) -> None: 25 | # `srcpaths = a b` will set `sys.path` to `[a, b, x, y, z, ...]` 26 | for path in reversed(config.getini("srcpaths")): 27 | sys.path.insert(0, str(path)) 28 | if version.parse(pytest.__version__) < version.parse("7.0.0"): 29 | for path in reversed(config.getini("pythonpath")): 30 | sys.path.insert(0, str(path)) 31 | 32 | 33 | @pytest.hookimpl(trylast=True) 34 | def pytest_unconfigure(config) -> None: 35 | for path in config.getini("srcpaths"): 36 | path_str = str(path) 37 | if path_str in sys.path: 38 | sys.path.remove(path_str) 39 | if version.parse(pytest.__version__) < version.parse("7.0.0"): 40 | for path in config.getini("pythonpath"): 41 | path_str = str(path) 42 | if path_str in sys.path: 43 | sys.path.remove(path_str) 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from textwrap import dedent 3 | from _pytest.pytester import Pytester 4 | 5 | pytest_plugins = "pytester" 6 | 7 | 8 | 9 | @pytest.fixture() 10 | def file_structure(pytester: Pytester) -> None: 11 | pytester.makepyfile( 12 | test_foo=""" 13 | from foo import foo 14 | 15 | def test_foo(): 16 | assert foo() == 1 17 | """ 18 | ) 19 | 20 | pytester.makepyfile( 21 | test_bar=""" 22 | from bar import bar 23 | 24 | def test_bar(): 25 | assert bar() == 2 26 | """ 27 | ) 28 | 29 | foo_py = pytester.mkdir("sub") / "foo.py" 30 | content = dedent( 31 | """ 32 | def foo(): 33 | return 1 34 | """ 35 | ) 36 | foo_py.write_text(content, encoding="utf-8") 37 | 38 | bar_py = pytester.mkdir("sub2") / "bar.py" 39 | content = dedent( 40 | """ 41 | def bar(): 42 | return 2 43 | """ 44 | ) 45 | bar_py.write_text(content, encoding="utf-8") 46 | 47 | -------------------------------------------------------------------------------- /tests/test_pythonpath.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Generator 3 | from typing import List 4 | from typing import Optional 5 | 6 | import pytest 7 | from _pytest.pytester import Pytester 8 | 9 | 10 | def test_one_dir_pythonpath(pytester: Pytester, file_structure) -> None: 11 | pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub\n") 12 | result = pytester.runpytest("test_foo.py") 13 | assert result.ret == 0 14 | result.assert_outcomes(passed=1) 15 | 16 | 17 | def test_two_dirs_pythonpath(pytester: Pytester, file_structure) -> None: 18 | pytester.makefile(".ini", pytest="[pytest]\npythonpath=sub sub2\n") 19 | result = pytester.runpytest("test_foo.py", "test_bar.py") 20 | assert result.ret == 0 21 | result.assert_outcomes(passed=2) 22 | 23 | 24 | def test_unconfigure_unadded_dir_pythonpath(pytester: Pytester) -> None: 25 | pytester.makeconftest( 26 | """ 27 | def pytest_configure(config): 28 | config.addinivalue_line("pythonpath", "sub") 29 | """ 30 | ) 31 | pytester.makepyfile( 32 | """ 33 | import sys 34 | 35 | def test_something(): 36 | pass 37 | """ 38 | ) 39 | result = pytester.runpytest() 40 | result.assert_outcomes(passed=1) 41 | 42 | 43 | def test_clean_up_pythonpath(pytester: Pytester) -> None: 44 | """Test that the srcpaths plugin cleans up after itself.""" 45 | pytester.makefile(".ini", pytest="[pytest]\npythonpath=I_SHALL_BE_REMOVED\n") 46 | pytester.makepyfile(test_foo="""def test_foo(): pass""") 47 | 48 | before: Optional[List[str]] = None 49 | after: Optional[List[str]] = None 50 | 51 | class Plugin: 52 | @pytest.hookimpl(hookwrapper=True, tryfirst=True) 53 | def pytest_unconfigure(self) -> Generator[None, None, None]: 54 | nonlocal before, after 55 | before = sys.path.copy() 56 | yield 57 | after = sys.path.copy() 58 | 59 | result = pytester.runpytest_inprocess(plugins=[Plugin()]) 60 | assert result.ret == 0 61 | 62 | assert before is not None 63 | assert after is not None 64 | assert any("I_SHALL_BE_REMOVED" in entry for entry in before) 65 | assert not any("I_SHALL_BE_REMOVED" in entry for entry in after) 66 | -------------------------------------------------------------------------------- /tests/test_srcpaths.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Generator 3 | from typing import List 4 | from typing import Optional 5 | 6 | import pytest 7 | from _pytest.pytester import Pytester 8 | 9 | 10 | 11 | def test_one_dir(pytester: Pytester, file_structure) -> None: 12 | pytester.makefile(".ini", pytest="[pytest]\nsrcpaths=sub\n") 13 | result = pytester.runpytest("test_foo.py") 14 | assert result.ret == 0 15 | result.assert_outcomes(passed=1) 16 | 17 | 18 | def test_two_dirs(pytester: Pytester, file_structure) -> None: 19 | pytester.makefile(".ini", pytest="[pytest]\nsrcpaths=sub sub2\n") 20 | result = pytester.runpytest("test_foo.py", "test_bar.py") 21 | assert result.ret == 0 22 | result.assert_outcomes(passed=2) 23 | 24 | 25 | def test_module_not_found(pytester: Pytester, file_structure) -> None: 26 | """Without the srcpaths setting, the module should not be found.""" 27 | pytester.makefile(".ini", pytest="[pytest]\n") 28 | result = pytester.runpytest("test_foo.py") 29 | assert result.ret == pytest.ExitCode.INTERRUPTED 30 | result.assert_outcomes(errors=1) 31 | expected_error = "E ModuleNotFoundError: No module named 'foo'" 32 | result.stdout.fnmatch_lines([expected_error]) 33 | 34 | 35 | def test_no_ini(pytester: Pytester, file_structure) -> None: 36 | """If no ini file, test should error.""" 37 | result = pytester.runpytest("test_foo.py") 38 | assert result.ret == pytest.ExitCode.INTERRUPTED 39 | result.assert_outcomes(errors=1) 40 | expected_error = "E ModuleNotFoundError: No module named 'foo'" 41 | result.stdout.fnmatch_lines([expected_error]) 42 | 43 | 44 | def test_unconfigure_unadded_dir(pytester: Pytester) -> None: 45 | """ 46 | The srcpaths handling adds paths during pytest_load_initial_conftests. 47 | This test adds the ini pythopath setting after that, during pytest_configure. 48 | Really, any time before unconfigure, would work. 49 | Then, when pytest_unconfigure happens, it tries to remove the sub from sys.path, 50 | but it isn't there. 51 | 52 | Therefore, the point of this test is to make sure that behavior works, that unconfigure 53 | doesn't blow up if a directory isn't in sys.path before unconfigure. 54 | """ 55 | pytester.makeconftest( 56 | """ 57 | def pytest_configure(config): 58 | config.addinivalue_line("srcpaths", "sub") 59 | """ 60 | ) 61 | pytester.makepyfile( 62 | """ 63 | import sys 64 | 65 | def test_something(): 66 | pass 67 | """ 68 | ) 69 | result = pytester.runpytest() 70 | result.assert_outcomes(passed=1) 71 | 72 | 73 | def test_clean_up(pytester: Pytester) -> None: 74 | """Test that the srcpaths plugin cleans up after itself.""" 75 | # This is tough to test behaviorly because the cleanup really runs last. 76 | # So the test make several implementation assumptions: 77 | # - Cleanup is done in pytest_unconfigure(). 78 | # - Not a hookwrapper. 79 | # So we can add a hookwrapper ourselves to test what it does. 80 | pytester.makefile(".ini", pytest="[pytest]\nsrcpaths=I_SHALL_BE_REMOVED\n") 81 | pytester.makepyfile(test_foo="""def test_foo(): pass""") 82 | 83 | before: Optional[List[str]] = None 84 | after: Optional[List[str]] = None 85 | 86 | class Plugin: 87 | @pytest.hookimpl(hookwrapper=True, tryfirst=True) 88 | def pytest_unconfigure(self) -> Generator[None, None, None]: 89 | nonlocal before, after 90 | before = sys.path.copy() 91 | yield 92 | after = sys.path.copy() 93 | 94 | result = pytester.runpytest_inprocess(plugins=[Plugin()]) 95 | assert result.ret == 0 96 | 97 | assert before is not None 98 | assert after is not None 99 | assert any("I_SHALL_BE_REMOVED" in entry for entry in before) 100 | assert not any("I_SHALL_BE_REMOVED" in entry for entry in after) 101 | 102 | 103 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,py310 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = pytest 7 | commands = pytest tests 8 | description = Run pytest 9 | --------------------------------------------------------------------------------