Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/config/findpaths.py: 53%

135 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

1import os 

2import sys 

3from pathlib import Path 

4from typing import Dict 

5from typing import Iterable 

6from typing import List 

7from typing import Optional 

8from typing import Sequence 

9from typing import Tuple 

10from typing import TYPE_CHECKING 

11from typing import Union 

12 

13import iniconfig 

14 

15from .exceptions import UsageError 

16from _pytest.outcomes import fail 

17from _pytest.pathlib import absolutepath 

18from _pytest.pathlib import commonpath 

19 

20if TYPE_CHECKING: 

21 from . import Config 

22 

23 

24def _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 

36def 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 

92def 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 

119def 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 

142def 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 

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

173 

174 

175def 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 

220def 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