diff --git a/changelog/8358.trivial.rst b/changelog/8358.trivial.rst new file mode 100644 index 000000000..611703f98 --- /dev/null +++ b/changelog/8358.trivial.rst @@ -0,0 +1 @@ +Internal Refactoring for finding/loading config files. diff --git a/doc/en/reference/customize.rst b/doc/en/reference/customize.rst index b6d21445a..4cd3d2977 100644 --- a/doc/en/reference/customize.rst +++ b/doc/en/reference/customize.rst @@ -79,7 +79,7 @@ and can also be used to hold pytest configuration if they have a ``[pytest]`` se .. code-block:: ini # tox.ini - [pytest] + [tool:pytest] minversion = 6.0 addopts = -ra -q testpaths = @@ -90,26 +90,7 @@ and can also be used to hold pytest configuration if they have a ``[pytest]`` se setup.cfg ~~~~~~~~~ -``setup.cfg`` files are general purpose configuration files, used originally by :doc:`distutils `, and can also be used to hold pytest configuration -if they have a ``[tool:pytest]`` section. - -.. code-block:: ini - - # setup.cfg - [tool:pytest] - minversion = 6.0 - addopts = -ra -q - testpaths = - tests - integration - -.. warning:: - - Usage of ``setup.cfg`` is not recommended unless for very simple use cases. ``.cfg`` - files use a different parser than ``pytest.ini`` and ``tox.ini`` which might cause hard to track - down problems. - When possible, it is recommended to use the latter files, or ``pyproject.toml``, to hold your - pytest configuration. +``setup.cfg`` file usage for pytest has been deprecated, its recommended to use ``tox.ini`` or ``pyproject.toml`` .. _rootdir: diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 43c236779..6937554ae 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -10,8 +10,6 @@ from typing import Tuple from typing import TYPE_CHECKING from typing import Union -import iniconfig - from .exceptions import UsageError from _pytest.outcomes import fail from _pytest.pathlib import absolutepath @@ -19,100 +17,141 @@ from _pytest.pathlib import commonpath if TYPE_CHECKING: from . import Config + from iniconfig import IniConfig # NOQA: F401 + +PARSE_RESULT = Optional[Dict[str, Union[str, List[str]]]] -def _parse_ini_config(path: Path) -> iniconfig.IniConfig: +def _parse_ini_config(path: Path) -> "IniConfig": """Parse the given generic '.ini' file using legacy IniConfig parser, returning the parsed object. Raise UsageError if the file cannot be parsed. """ + from iniconfig import IniConfig, ParseError # NOQA: F811 + try: - return iniconfig.IniConfig(str(path)) - except iniconfig.ParseError as exc: + return IniConfig(os.fspath(path), data=path.read_text()) + except ParseError as exc: raise UsageError(str(exc)) from exc -def load_config_dict_from_file( +def _parse_pytest_ini(path: Path) -> PARSE_RESULT: + """Parse the legacy pytest.ini and return the contents of the pytest section + + if the file exists and lacks a pytest section, consider it empty""" + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return dict(iniconfig["pytest"].items()) + else: + # "pytest.ini" files are always the source of configuration, even if empty. + return {} + + +def _parse_ini_file(path: Path) -> PARSE_RESULT: + """Parses .ini files with expected pytest.ini sections + + todo: investigate if tool:pytest should be added + """ + iniconfig = _parse_ini_config(path) + + if "pytest" in iniconfig: + return dict(iniconfig["pytest"].items()) + return None + + +def _parse_cfg_file(path: Path) -> PARSE_RESULT: + """Parses .cfg files, specifically used for setup.cfg support + + tool:pytest as section name is required + """ + + iniconfig = _parse_ini_config(path) + + if "tool:pytest" in iniconfig.sections: + return dict(iniconfig["tool:pytest"].items()) + elif "pytest" in iniconfig.sections: + # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that + # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). + fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) + else: + return None + + +def _parse_pyproject_ini_options( filepath: Path, -) -> Optional[Dict[str, Union[str, List[str]]]]: +) -> PARSE_RESULT: + """Load backward compatible ini options from pyproject.toml""" + + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib + + toml_text = filepath.read_text(encoding="utf-8") + try: + config = tomllib.loads(toml_text) + except tomllib.TOMLDecodeError as exc: + raise UsageError(f"{filepath}: {exc}") from exc + + result = config.get("tool", {}).get("pytest", {}).get("ini_options", None) + if result is not None: + # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc), + # however we need to convert all scalar values to str for compatibility with the rest + # of the configuration system, which expects strings only. + def make_scalar(v: object) -> Union[str, List[str]]: + return v if isinstance(v, list) else str(v) + + return {k: make_scalar(v) for k, v in result.items()} + else: + return None + + +CONFIG_LOADERS = { + "pytest.ini": _parse_pytest_ini, + ".pytest.ini": _parse_pytest_ini, + "pyproject.toml": _parse_pyproject_ini_options, + "tox.ini": _parse_ini_file, + "setup.cfg": _parse_cfg_file, +} + +CONFIG_SUFFIXES = { + ".ini": _parse_ini_file, + ".cfg": _parse_cfg_file, + ".toml": _parse_pyproject_ini_options, +} + + +def load_config_dict_from_file(path: Path) -> PARSE_RESULT: """Load pytest configuration from the given file path, if supported. Return None if the file does not contain valid pytest configuration. """ - - # Configuration from ini files are obtained from the [pytest] section, if present. - if filepath.suffix == ".ini": - iniconfig = _parse_ini_config(filepath) - - if "pytest" in iniconfig: - return dict(iniconfig["pytest"].items()) - else: - # "pytest.ini" files are always the source of configuration, even if empty. - if filepath.name == "pytest.ini": - return {} - - # '.cfg' files are considered if they contain a "[tool:pytest]" section. - elif filepath.suffix == ".cfg": - iniconfig = _parse_ini_config(filepath) - - if "tool:pytest" in iniconfig.sections: - return dict(iniconfig["tool:pytest"].items()) - elif "pytest" in iniconfig.sections: - # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that - # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). - fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False) - - # '.toml' files are considered if they contain a [tool.pytest.ini_options] table. - elif filepath.suffix == ".toml": - if sys.version_info >= (3, 11): - import tomllib - else: - import tomli as tomllib - - toml_text = filepath.read_text(encoding="utf-8") - try: - config = tomllib.loads(toml_text) - except tomllib.TOMLDecodeError as exc: - raise UsageError(f"{filepath}: {exc}") from exc - - result = config.get("tool", {}).get("pytest", {}).get("ini_options", None) - if result is not None: - # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc), - # however we need to convert all scalar values to str for compatibility with the rest - # of the configuration system, which expects strings only. - def make_scalar(v: object) -> Union[str, List[str]]: - return v if isinstance(v, list) else str(v) - - return {k: make_scalar(v) for k, v in result.items()} - + if path.name in CONFIG_LOADERS: + return CONFIG_LOADERS[path.name](path) + if path.suffix in CONFIG_SUFFIXES: + return CONFIG_SUFFIXES[path.suffix](path) return None def locate_config( - args: Iterable[Path], + args: List[Path], ) -> Tuple[Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]]]: """Search in the list of arguments for a valid ini-file for pytest, and return a tuple of (rootdir, inifile, cfg-dict).""" - config_names = [ - "pytest.ini", - ".pytest.ini", - "pyproject.toml", - "tox.ini", - "setup.cfg", - ] - args = [x for x in args if not str(x).startswith("-")] + if not args: args = [Path.cwd()] for arg in args: argpath = absolutepath(arg) for base in (argpath, *argpath.parents): - for config_name in config_names: + for config_name, loader in CONFIG_LOADERS.items(): p = base / config_name if p.is_file(): - ini_config = load_config_dict_from_file(p) - if ini_config is not None: - return base, p, ini_config + config = loader(p) + if config is not None: + return base, p, config return None, None, {} diff --git a/testing/test_config.py b/testing/test_config.py index c2e3fe5bb..a1f4fbb8a 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -113,7 +113,7 @@ class TestParseIni: @pytest.mark.parametrize( "section, name", [ - ("tool:pytest", "setup.cfg"), + pytest.param("tool:pytest", "setup.cfg"), ("pytest", "tox.ini"), ("pytest", "pytest.ini"), ("pytest", ".pytest.ini"),