File size: 12,878 Bytes
4ecae3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
from __future__ import annotations

from collections.abc import Iterable
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import KW_ONLY
import os
from pathlib import Path
import sys
from typing import Literal
from typing import TypeAlias

import iniconfig

from .exceptions import UsageError
from _pytest.outcomes import fail
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.pathlib import safe_exists


@dataclass(frozen=True)
class ConfigValue:
    """Represents a configuration value with its origin and parsing mode.

    This allows tracking whether a value came from a configuration file
    or from a CLI override (--override-ini), which is important for
    determining precedence when dealing with ini option aliases.

    The mode tracks the parsing mode/data model used for the value:
    - "ini": from INI files or [tool.pytest.ini_options], where the only
      supported value types are `str` or `list[str]`.
    - "toml": from TOML files (not in INI mode), where native TOML types
       are preserved.
    """

    value: object
    _: KW_ONLY
    origin: Literal["file", "override"]
    mode: Literal["ini", "toml"]


ConfigDict: TypeAlias = dict[str, ConfigValue]


def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
    """Parse the given generic '.ini' file using legacy IniConfig parser, returning
    the parsed object.

    Raise UsageError if the file cannot be parsed.
    """
    try:
        return iniconfig.IniConfig(str(path))
    except iniconfig.ParseError as exc:
        raise UsageError(str(exc)) from exc


def load_config_dict_from_file(
    filepath: Path,
) -> ConfigDict | None:
    """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 {
                k: ConfigValue(v, origin="file", mode="ini")
                for k, v in iniconfig["pytest"].items()
            }
        else:
            # "pytest.ini" files are always the source of configuration, even if empty.
            if filepath.name in {"pytest.ini", ".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 {
                k: ConfigValue(v, origin="file", mode="ini")
                for k, v in 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] table (toml mode)
    # or [tool.pytest.ini_options] table (ini mode) for pyproject.toml,
    # or [pytest] table (toml mode) for pytest.toml/.pytest.toml.
    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

        # pytest.toml and .pytest.toml use [pytest] table directly.
        if filepath.name in ("pytest.toml", ".pytest.toml"):
            pytest_config = config.get("pytest", {})
            if pytest_config:
                # TOML mode - preserve native TOML types.
                return {
                    k: ConfigValue(v, origin="file", mode="toml")
                    for k, v in pytest_config.items()
                }
            # "pytest.toml" files are always the source of configuration, even if empty.
            return {}

        # pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options].
        else:
            tool_pytest = config.get("tool", {}).get("pytest", {})

            # Check for toml mode config: [tool.pytest] with content outside of ini_options.
            toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"}
            # Check for ini mode config: [tool.pytest.ini_options].
            ini_config = tool_pytest.get("ini_options", None)

            if toml_config and ini_config:
                raise UsageError(
                    f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and "
                    "[tool.pytest.ini_options] (string-based INI format) simultaneously. "
                    "Please use [tool.pytest] with native TOML types (recommended) "
                    "or [tool.pytest.ini_options] for backwards compatibility."
                )

            if toml_config:
                # TOML mode - preserve native TOML types.
                return {
                    k: ConfigValue(v, origin="file", mode="toml")
                    for k, v in toml_config.items()
                }

            elif ini_config is not None:
                # INI mode - TOML supports richer data types than INI files, but we need to
                # convert all scalar values to str for compatibility with the INI system.
                def make_scalar(v: object) -> str | list[str]:
                    return v if isinstance(v, list) else str(v)

                return {
                    k: ConfigValue(make_scalar(v), origin="file", mode="ini")
                    for k, v in ini_config.items()
                }

    return None


def locate_config(
    invocation_dir: Path,
    args: Iterable[Path],
) -> tuple[Path | None, Path | None, ConfigDict, Sequence[str]]:
    """Search in the list of arguments for a valid ini-file for pytest,
    and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where
    ignored-config-files is a list of config basenames found that contain
    pytest configuration but were ignored."""
    config_names = [
        "pytest.toml",
        ".pytest.toml",
        "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 = [invocation_dir]
    found_pyproject_toml: Path | None = None
    ignored_config_files: list[str] = []

    for arg in args:
        argpath = absolutepath(arg)
        for base in (argpath, *argpath.parents):
            for config_name in config_names:
                p = base / config_name
                if p.is_file():
                    if p.name == "pyproject.toml" and found_pyproject_toml is None:
                        found_pyproject_toml = p
                    ini_config = load_config_dict_from_file(p)
                    if ini_config is not None:
                        index = config_names.index(config_name)
                        for remainder in config_names[index + 1 :]:
                            p2 = base / remainder
                            if (
                                p2.is_file()
                                and load_config_dict_from_file(p2) is not None
                            ):
                                ignored_config_files.append(remainder)
                        return base, p, ini_config, ignored_config_files
    if found_pyproject_toml is not None:
        return found_pyproject_toml.parent, found_pyproject_toml, {}, []
    return None, None, {}, []


def get_common_ancestor(
    invocation_dir: Path,
    paths: Iterable[Path],
) -> Path:
    common_ancestor: Path | None = None
    for path in paths:
        if not path.exists():
            continue
        if common_ancestor is None:
            common_ancestor = path
        else:
            if common_ancestor in path.parents or path == common_ancestor:
                continue
            elif path in common_ancestor.parents:
                common_ancestor = path
            else:
                shared = commonpath(path, common_ancestor)
                if shared is not None:
                    common_ancestor = shared
    if common_ancestor is None:
        common_ancestor = invocation_dir
    elif common_ancestor.is_file():
        common_ancestor = common_ancestor.parent
    return common_ancestor


def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
    def is_option(x: str) -> bool:
        return x.startswith("-")

    def get_file_part_from_node_id(x: str) -> str:
        return x.split("::")[0]

    def get_dir_from_path(path: Path) -> Path:
        if path.is_dir():
            return path
        return path.parent

    # These look like paths but may not exist
    possible_paths = (
        absolutepath(get_file_part_from_node_id(arg))
        for arg in args
        if not is_option(arg)
    )

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


def parse_override_ini(override_ini: Sequence[str] | None) -> ConfigDict:
    """Parse the -o/--override-ini command line arguments and return the overrides.

    :raises UsageError:
        If one of the values is malformed.
    """
    overrides = {}
    # override_ini is a list of "ini=value" options.
    # Always use the last item if multiple values are set for same ini-name,
    # e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2.
    for ini_config in override_ini or ():
        try:
            key, user_ini_value = ini_config.split("=", 1)
        except ValueError as e:
            raise UsageError(
                f"-o/--override-ini expects option=value style (got: {ini_config!r})."
            ) from e
        else:
            overrides[key] = ConfigValue(user_ini_value, origin="override", mode="ini")
    return overrides


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


def determine_setup(
    *,
    inifile: str | None,
    override_ini: Sequence[str] | None,
    args: Sequence[str],
    rootdir_cmd_arg: str | None,
    invocation_dir: Path,
) -> tuple[Path, Path | None, ConfigDict, Sequence[str]]:
    """Determine the rootdir, inifile and ini configuration values from the
    command line arguments.

    :param inifile:
        The `--inifile` command line argument, if given.
    :param override_ini:
        The -o/--override-ini command line arguments, if given.
    :param args:
        The free command line arguments.
    :param rootdir_cmd_arg:
        The `--rootdir` command line argument, if given.
    :param invocation_dir:
        The working directory when pytest was invoked.

    :raises UsageError:
    """
    rootdir = None
    dirs = get_dirs_from_args(args)
    ignored_config_files: Sequence[str] = []

    if inifile:
        inipath_ = absolutepath(inifile)
        inipath: Path | None = inipath_
        inicfg = load_config_dict_from_file(inipath_) or {}
        if rootdir_cmd_arg is None:
            rootdir = inipath_.parent
    else:
        ancestor = get_common_ancestor(invocation_dir, dirs)
        rootdir, inipath, inicfg, ignored_config_files = locate_config(
            invocation_dir, [ancestor]
        )
        if rootdir is None and rootdir_cmd_arg is None:
            for possible_rootdir in (ancestor, *ancestor.parents):
                if (possible_rootdir / "setup.py").is_file():
                    rootdir = possible_rootdir
                    break
            else:
                if dirs != [ancestor]:
                    rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs)
                if rootdir is None:
                    rootdir = get_common_ancestor(
                        invocation_dir, [invocation_dir, ancestor]
                    )
                    if is_fs_root(rootdir):
                        rootdir = ancestor
    if rootdir_cmd_arg:
        rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
        if not rootdir.is_dir():
            raise UsageError(
                f"Directory '{rootdir}' not found. Check your '--rootdir' option."
            )

    ini_overrides = parse_override_ini(override_ini)
    inicfg.update(ini_overrides)

    assert rootdir is not None
    return rootdir, inipath, inicfg, ignored_config_files


def is_fs_root(p: Path) -> bool:
    r"""
    Return True if the given path is pointing to the root of the
    file system ("/" on Unix and "C:\\" on Windows for example).
    """
    return os.path.splitdrive(str(p))[1] == os.sep