findpaths.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. from __future__ import annotations
  2. from collections.abc import Iterable
  3. from collections.abc import Sequence
  4. import os
  5. from pathlib import Path
  6. import sys
  7. from typing import TYPE_CHECKING
  8. import iniconfig
  9. from .exceptions import UsageError
  10. from _pytest.outcomes import fail
  11. from _pytest.pathlib import absolutepath
  12. from _pytest.pathlib import commonpath
  13. from _pytest.pathlib import safe_exists
  14. if TYPE_CHECKING:
  15. from typing import Union
  16. from typing_extensions import TypeAlias
  17. # Even though TOML supports richer data types, all values are converted to str/list[str] during
  18. # parsing to maintain compatibility with the rest of the configuration system.
  19. ConfigDict: TypeAlias = dict[str, Union[str, list[str]]]
  20. def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
  21. """Parse the given generic '.ini' file using legacy IniConfig parser, returning
  22. the parsed object.
  23. Raise UsageError if the file cannot be parsed.
  24. """
  25. try:
  26. return iniconfig.IniConfig(str(path))
  27. except iniconfig.ParseError as exc:
  28. raise UsageError(str(exc)) from exc
  29. def load_config_dict_from_file(
  30. filepath: Path,
  31. ) -> ConfigDict | None:
  32. """Load pytest configuration from the given file path, if supported.
  33. Return None if the file does not contain valid pytest configuration.
  34. """
  35. # Configuration from ini files are obtained from the [pytest] section, if present.
  36. if filepath.suffix == ".ini":
  37. iniconfig = _parse_ini_config(filepath)
  38. if "pytest" in iniconfig:
  39. return dict(iniconfig["pytest"].items())
  40. else:
  41. # "pytest.ini" files are always the source of configuration, even if empty.
  42. if filepath.name == "pytest.ini":
  43. return {}
  44. # '.cfg' files are considered if they contain a "[tool:pytest]" section.
  45. elif filepath.suffix == ".cfg":
  46. iniconfig = _parse_ini_config(filepath)
  47. if "tool:pytest" in iniconfig.sections:
  48. return dict(iniconfig["tool:pytest"].items())
  49. elif "pytest" in iniconfig.sections:
  50. # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that
  51. # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086).
  52. fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)
  53. # '.toml' files are considered if they contain a [tool.pytest.ini_options] table.
  54. elif filepath.suffix == ".toml":
  55. if sys.version_info >= (3, 11):
  56. import tomllib
  57. else:
  58. import tomli as tomllib
  59. toml_text = filepath.read_text(encoding="utf-8")
  60. try:
  61. config = tomllib.loads(toml_text)
  62. except tomllib.TOMLDecodeError as exc:
  63. raise UsageError(f"{filepath}: {exc}") from exc
  64. result = config.get("tool", {}).get("pytest", {}).get("ini_options", None)
  65. if result is not None:
  66. # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc),
  67. # however we need to convert all scalar values to str for compatibility with the rest
  68. # of the configuration system, which expects strings only.
  69. def make_scalar(v: object) -> str | list[str]:
  70. return v if isinstance(v, list) else str(v)
  71. return {k: make_scalar(v) for k, v in result.items()}
  72. return None
  73. def locate_config(
  74. invocation_dir: Path,
  75. args: Iterable[Path],
  76. ) -> tuple[Path | None, Path | None, ConfigDict]:
  77. """Search in the list of arguments for a valid ini-file for pytest,
  78. and return a tuple of (rootdir, inifile, cfg-dict)."""
  79. config_names = [
  80. "pytest.ini",
  81. ".pytest.ini",
  82. "pyproject.toml",
  83. "tox.ini",
  84. "setup.cfg",
  85. ]
  86. args = [x for x in args if not str(x).startswith("-")]
  87. if not args:
  88. args = [invocation_dir]
  89. found_pyproject_toml: Path | None = None
  90. for arg in args:
  91. argpath = absolutepath(arg)
  92. for base in (argpath, *argpath.parents):
  93. for config_name in config_names:
  94. p = base / config_name
  95. if p.is_file():
  96. if p.name == "pyproject.toml" and found_pyproject_toml is None:
  97. found_pyproject_toml = p
  98. ini_config = load_config_dict_from_file(p)
  99. if ini_config is not None:
  100. return base, p, ini_config
  101. if found_pyproject_toml is not None:
  102. return found_pyproject_toml.parent, found_pyproject_toml, {}
  103. return None, None, {}
  104. def get_common_ancestor(
  105. invocation_dir: Path,
  106. paths: Iterable[Path],
  107. ) -> Path:
  108. common_ancestor: Path | None = None
  109. for path in paths:
  110. if not path.exists():
  111. continue
  112. if common_ancestor is None:
  113. common_ancestor = path
  114. else:
  115. if common_ancestor in path.parents or path == common_ancestor:
  116. continue
  117. elif path in common_ancestor.parents:
  118. common_ancestor = path
  119. else:
  120. shared = commonpath(path, common_ancestor)
  121. if shared is not None:
  122. common_ancestor = shared
  123. if common_ancestor is None:
  124. common_ancestor = invocation_dir
  125. elif common_ancestor.is_file():
  126. common_ancestor = common_ancestor.parent
  127. return common_ancestor
  128. def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
  129. def is_option(x: str) -> bool:
  130. return x.startswith("-")
  131. def get_file_part_from_node_id(x: str) -> str:
  132. return x.split("::")[0]
  133. def get_dir_from_path(path: Path) -> Path:
  134. if path.is_dir():
  135. return path
  136. return path.parent
  137. # These look like paths but may not exist
  138. possible_paths = (
  139. absolutepath(get_file_part_from_node_id(arg))
  140. for arg in args
  141. if not is_option(arg)
  142. )
  143. return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]
  144. CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."
  145. def determine_setup(
  146. *,
  147. inifile: str | None,
  148. args: Sequence[str],
  149. rootdir_cmd_arg: str | None,
  150. invocation_dir: Path,
  151. ) -> tuple[Path, Path | None, ConfigDict]:
  152. """Determine the rootdir, inifile and ini configuration values from the
  153. command line arguments.
  154. :param inifile:
  155. The `--inifile` command line argument, if given.
  156. :param args:
  157. The free command line arguments.
  158. :param rootdir_cmd_arg:
  159. The `--rootdir` command line argument, if given.
  160. :param invocation_dir:
  161. The working directory when pytest was invoked.
  162. """
  163. rootdir = None
  164. dirs = get_dirs_from_args(args)
  165. if inifile:
  166. inipath_ = absolutepath(inifile)
  167. inipath: Path | None = inipath_
  168. inicfg = load_config_dict_from_file(inipath_) or {}
  169. if rootdir_cmd_arg is None:
  170. rootdir = inipath_.parent
  171. else:
  172. ancestor = get_common_ancestor(invocation_dir, dirs)
  173. rootdir, inipath, inicfg = locate_config(invocation_dir, [ancestor])
  174. if rootdir is None and rootdir_cmd_arg is None:
  175. for possible_rootdir in (ancestor, *ancestor.parents):
  176. if (possible_rootdir / "setup.py").is_file():
  177. rootdir = possible_rootdir
  178. break
  179. else:
  180. if dirs != [ancestor]:
  181. rootdir, inipath, inicfg = locate_config(invocation_dir, dirs)
  182. if rootdir is None:
  183. rootdir = get_common_ancestor(
  184. invocation_dir, [invocation_dir, ancestor]
  185. )
  186. if is_fs_root(rootdir):
  187. rootdir = ancestor
  188. if rootdir_cmd_arg:
  189. rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
  190. if not rootdir.is_dir():
  191. raise UsageError(
  192. f"Directory '{rootdir}' not found. Check your '--rootdir' option."
  193. )
  194. assert rootdir is not None
  195. return rootdir, inipath, inicfg or {}
  196. def is_fs_root(p: Path) -> bool:
  197. r"""
  198. Return True if the given path is pointing to the root of the
  199. file system ("/" on Unix and "C:\\" on Windows for example).
  200. """
  201. return os.path.splitdrive(str(p))[1] == os.sep