|
| 1 | +"""Read and apply [tool.slipcover] configuration from pyproject.toml.""" |
| 2 | + |
| 3 | +from pathlib import Path |
| 4 | + |
| 5 | +try: |
| 6 | + import tomllib # Python 3.11+ |
| 7 | +except ImportError: |
| 8 | + import tomli as tomllib # type: ignore[no-redef] |
| 9 | + |
| 10 | + |
| 11 | +# Markers that indicate a project root; stop climbing here. |
| 12 | +_ROOT_MARKERS = frozenset({".git", ".hg", ".svn", "setup.py", "setup.cfg"}) |
| 13 | + |
| 14 | +# Maximum number of parent directories to walk up from the start. |
| 15 | +_MAX_WALK = 3 |
| 16 | + |
| 17 | + |
| 18 | +def find_pyproject(start=None): |
| 19 | + """Walks up from 'start' (default cwd) looking for pyproject.toml. |
| 20 | +
|
| 21 | + The search stops and returns None when any of these boundaries is |
| 22 | + reached without finding the file: |
| 23 | +
|
| 24 | + - a directory containing a VCS/project root marker |
| 25 | + (.git, .hg, .svn, setup.py, setup.cfg) |
| 26 | + - the user's home directory |
| 27 | + - more than _MAX_WALK parent directories have been visited |
| 28 | + """ |
| 29 | + if start is None: |
| 30 | + start = Path.cwd() |
| 31 | + start = start.resolve() |
| 32 | + |
| 33 | + home = Path.home() |
| 34 | + |
| 35 | + for depth, directory in enumerate((start, *start.parents)): |
| 36 | + candidate = directory / "pyproject.toml" |
| 37 | + if candidate.is_file(): |
| 38 | + return candidate |
| 39 | + |
| 40 | + # Don't climb above a project root, the user's home directory, |
| 41 | + # or more than _MAX_WALK levels. |
| 42 | + if (depth >= _MAX_WALK |
| 43 | + or directory == home |
| 44 | + or any((directory / m).exists() for m in _ROOT_MARKERS)): |
| 45 | + break |
| 46 | + |
| 47 | + return None |
| 48 | + |
| 49 | + |
| 50 | +def read_config(path=None): |
| 51 | + """Returns the [tool.slipcover] table from a pyproject.toml. |
| 52 | +
|
| 53 | + If 'path' is None, find_pyproject() is used to locate the file. |
| 54 | + Returns an empty dict when no file is found or the section is absent. |
| 55 | + """ |
| 56 | + if path is None: |
| 57 | + path = find_pyproject() |
| 58 | + |
| 59 | + if path is None: |
| 60 | + return {} |
| 61 | + |
| 62 | + with open(path, "rb") as f: |
| 63 | + data = tomllib.load(f) |
| 64 | + |
| 65 | + return data.get("tool", {}).get("slipcover", {}) |
| 66 | + |
| 67 | + |
| 68 | +# Boolean flags (store_true in CLI). Excludes --silent/--dis/--debug/ |
| 69 | +# --dont-wrap-pytest: those are argparse.SUPPRESS'd, dev-only flags, not |
| 70 | +# part of the stable, user-facing config surface. |
| 71 | +_BOOL_KEYS = { |
| 72 | + "branch", |
| 73 | + "json", |
| 74 | + "pretty-print", |
| 75 | + "xml", |
| 76 | + "immediate", |
| 77 | + "skip-covered", |
| 78 | +} |
| 79 | + |
| 80 | +# Keys that take a value |
| 81 | +_VALUE_KEYS = { |
| 82 | + "out": Path, |
| 83 | + "source": str, |
| 84 | + "omit": str, |
| 85 | + "fail-under": float, |
| 86 | + "threshold": int, |
| 87 | + "missing-width": int, |
| 88 | + "xml-package-depth": int, |
| 89 | +} |
| 90 | + |
| 91 | + |
| 92 | +def apply_config(config, parsed_args, explicit_args=None): |
| 93 | + """Merges config values into parsed_args. |
| 94 | +
|
| 95 | + Keys whose dest name appears in 'explicit_args' are skipped so that |
| 96 | + command-line flags always take precedence over the config file. |
| 97 | +
|
| 98 | + Raises TypeError if a boolean key has a non-boolean value. |
| 99 | + Emits a UserWarning for unrecognised keys. |
| 100 | + """ |
| 101 | + if explicit_args is None: |
| 102 | + explicit_args = set() |
| 103 | + |
| 104 | + for key, value in config.items(): |
| 105 | + dest = key.replace("-", "_") |
| 106 | + |
| 107 | + # CLI flags always win |
| 108 | + if dest in explicit_args: |
| 109 | + continue |
| 110 | + |
| 111 | + if key in _BOOL_KEYS: |
| 112 | + if not isinstance(value, bool): |
| 113 | + raise TypeError( |
| 114 | + f"[tool.slipcover] key '{key}' must be a boolean, got {type(value).__name__}" |
| 115 | + ) |
| 116 | + setattr(parsed_args, dest, value) |
| 117 | + |
| 118 | + elif key in _VALUE_KEYS: |
| 119 | + # TOML's idiomatic way to express multiple values is an array; |
| 120 | + # join it the way --source/--omit's comma-separated CLI form |
| 121 | + # expects, rather than stringifying the Python list itself. |
| 122 | + if key in ("source", "omit") and isinstance(value, list): |
| 123 | + value = ",".join(str(v) for v in value) |
| 124 | + setattr(parsed_args, dest, _VALUE_KEYS[key](value)) |
| 125 | + |
| 126 | + else: |
| 127 | + import warnings |
| 128 | + warnings.warn(f"Unknown [tool.slipcover] key: '{key}'") |
0 commit comments