1 import os

2 import sys

3 from pathlib import Path

4 from typing import Dict

5 from typing import Iterable

6 from typing import List

7 from typing import Optional

8 from typing import Sequence

9 from typing import Tuple

10 from typing import TYPE_CHECKING

11 from typing import Union

12

13 import iniconfig

14

15 from .exceptions import UsageError

16 from _pytest.outcomes import fail

17 from _pytest.pathlib import absolutepath

18 from _pytest.pathlib import commonpath

19

20 if TYPE_CHECKING:

21 from . import Config

22

23

24 def _parse_ini_config(path: Path) -> iniconfig.IniConfig:

25 """Parse the given generic '.ini' file using legacy IniConfig parser, returning

26 the parsed object.

27

28 Raise UsageError if the file cannot be parsed.

29 """

30 try:

31 return iniconfig.IniConfig(str(path))

32 except iniconfig.ParseError as exc:

33 raise UsageError(str(exc)) from exc

34

35

36 def load_config_dict_from_file(

37 filepath: Path,

38) -> Optional[Dict[str, Union[str, List[str]]]]:

39 """Load pytest configuration from the given file path, if supported.

40

41 Return None if the file does not contain valid pytest configuration.

42 """

43

44 # Configuration from ini files are obtained from the [pytest] section, if present.

45 if filepath.suffix == ".ini":

46 iniconfig = _parse_ini_config(filepath)

47

48 if "pytest" in iniconfig:

49 return dict(iniconfig["pytest"].items())

50 else:

51 # "pytest.ini" files are always the source of configuration, even if empty.

52 if filepath.name == "pytest.ini":

53 return {}

54

55 # '.cfg' files are considered if they contain a "[tool:pytest]" section.

56 elif filepath.suffix == ".cfg":

57 iniconfig = _parse_ini_config(filepath)

58

59 if "tool:pytest" in iniconfig.sections:

60 return dict(iniconfig["tool:pytest"].items())

61 elif "pytest" in iniconfig.sections:

62 # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that

63 # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086).

64 fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)

65

66 # '.toml' files are considered if they contain a [tool.pytest.ini_options] table.

67 elif filepath.suffix == ".toml":

68 if sys.version_info >= (3, 11):

69 import tomllib

70 else:

71 import tomli as tomllib

72

73 toml_text = filepath.read_text(encoding="utf-8")

74 try:

75 config = tomllib.loads(toml_text)

76 except tomllib.TOMLDecodeError as exc:

77 raise UsageError(f"{filepath}: {exc}") from exc

78

79 result = config.get("tool", {}).get("pytest", {}).get("ini_options", None)

80 if result is not None:

81 # TOML supports richer data types than ini files (strings, arrays, floats, ints, etc),

82 # however we need to convert all scalar values to str for compatibility with the rest

83 # of the configuration system, which expects strings only.

84 def make_scalar(v: object) -> Union[str, List[str]]:

85 return v if isinstance(v, list) else str(v)

86

87 return {k: make_scalar(v) for k, v in result.items()}

88

89 return None

90

91

92 def locate_config(

93 args: Iterable[Path],

94) -> Tuple[Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]]]:

95 """Search in the list of arguments for a valid ini-file for pytest,

96 and return a tuple of (rootdir, inifile, cfg-dict)."""

97 config_names = [

98 "pytest.ini",

99 ".pytest.ini",

100 "pyproject.toml",

101 "tox.ini",

102 "setup.cfg",

103 ]

104 args = [x for x in args if not str(x).startswith("-")]

105 if not args:

106 args = [Path.cwd()]

107 for arg in args:

108 argpath = absolutepath(arg)

109 for base in (argpath, *argpath.parents):

110 for config_name in config_names:

111 p = base / config_name

112 if p.is_file():

113 ini_config = load_config_dict_from_file(p)

114 if ini_config is not None:

115 return base, p, ini_config

116 return None, None, {}

117

118

119 def get_common_ancestor(paths: Iterable[Path]) -> Path:

120 common_ancestor: Optional[Path] = None

121 for path in paths:

122 if not path.exists():

123 continue

124 if common_ancestor is None:

125 common_ancestor = path

126 else:

127 if common_ancestor in path.parents or path == common_ancestor:

128 continue

129 elif path in common_ancestor.parents:

130 common_ancestor = path

131 else:

132 shared = commonpath(path, common_ancestor)

133 if shared is not None:

134 common_ancestor = shared

135 if common_ancestor is None:

136 common_ancestor = Path.cwd()

137 elif common_ancestor.is_file():

138 common_ancestor = common_ancestor.parent

139 return common_ancestor

140

141

142 def get_dirs_from_args(args: Iterable[str]) -> List[Path]:

143 def is_option(x: str) -> bool:

144 return x.startswith("-")

145

146 def get_file_part_from_node_id(x: str) -> str:

147 return x.split("::")[0]

148

149 def get_dir_from_path(path: Path) -> Path:

150 if path.is_dir():

151 return path

152 return path.parent

153

154 def safe_exists(path: Path) -> bool:

155 # This can throw on paths that contain characters unrepresentable at the OS level,

156 # or with invalid syntax on Windows (https://bugs.python.org/issue35306)

157 try:

158 return path.exists()

159 except OSError:

160 return False

161

162 # These look like paths but may not exist

163 possible_paths = (

164 absolutepath(get_file_part_from_node_id(arg))

165 for arg in args

166 if not is_option(arg)

167 )

168

169 return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]

170

171

172 CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."

173

174

175 def determine_setup(

176 inifile: Optional[str],

177 args: Sequence[str],

178 rootdir_cmd_arg: Optional[str] = None,

179 config: Optional["Config"] = None,

180) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]:

181 rootdir = None

182 dirs = get_dirs_from_args(args)

183 if inifile:

184 inipath_ = absolutepath(inifile)

185 inipath: Optional[Path] = inipath_

186 inicfg = load_config_dict_from_file(inipath_) or {}

187 if rootdir_cmd_arg is None:

188 rootdir = inipath_.parent

189 else:

190 ancestor = get_common_ancestor(dirs)

191 rootdir, inipath, inicfg = locate_config([ancestor])

192 if rootdir is None and rootdir_cmd_arg is None:

193 for possible_rootdir in (ancestor, *ancestor.parents):

194 if (possible_rootdir / "setup.py").is_file():

195 rootdir = possible_rootdir

196 break

197 else:

198 if dirs != [ancestor]:

199 rootdir, inipath, inicfg = locate_config(dirs)

200 if rootdir is None:

201 if config is not None:

202 cwd = config.invocation_params.dir

203 else:

204 cwd = Path.cwd()

205 rootdir = get_common_ancestor([cwd, ancestor])

206 if is_fs_root(rootdir):

207 rootdir = ancestor

208 if rootdir_cmd_arg:

209 rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))

210 if not rootdir.is_dir():

211 raise UsageError(

212 "Directory '{}' not found. Check your '--rootdir' option.".format(

213 rootdir

214 )

215 )

216 assert rootdir is not None

217 return rootdir, inipath, inicfg or {}

218

219

220 def is_fs_root(p: Path) -> bool:

221 r"""

222 Return True if the given path is pointing to the root of the

223 file system ("/" on Unix and "C:\\" on Windows for example).

224 """

225 return os.path.splitdrive(str(p))[1] == os.sep