|
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
|
2 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
|
3 |
|
4 """TOML configuration support for coverage.py""" |
|
5 |
|
6 import configparser |
|
7 import os |
|
8 import re |
|
9 |
|
10 from coverage import env |
|
11 from coverage.exceptions import ConfigError |
|
12 from coverage.misc import import_third_party, substitute_variables |
|
13 |
|
14 |
|
15 if env.PYVERSION >= (3, 11): |
|
16 import tomllib # pylint: disable=import-error |
|
17 else: |
|
18 # TOML support on Python 3.10 and below is an install-time extra option. |
|
19 # (Import typing is here because import_third_party will unload any module |
|
20 # that wasn't already imported. tomli imports typing, and if we unload it, |
|
21 # later it's imported again, and on Python 3.6, this causes infinite |
|
22 # recursion.) |
|
23 import typing # pylint: disable=unused-import |
|
24 tomllib = import_third_party("tomli") |
|
25 |
|
26 |
|
27 class TomlDecodeError(Exception): |
|
28 """An exception class that exists even when toml isn't installed.""" |
|
29 pass |
|
30 |
|
31 |
|
32 class TomlConfigParser: |
|
33 """TOML file reading with the interface of HandyConfigParser.""" |
|
34 |
|
35 # This class has the same interface as config.HandyConfigParser, no |
|
36 # need for docstrings. |
|
37 # pylint: disable=missing-function-docstring |
|
38 |
|
39 def __init__(self, our_file): |
|
40 self.our_file = our_file |
|
41 self.data = None |
|
42 |
|
43 def read(self, filenames): |
|
44 # RawConfigParser takes a filename or list of filenames, but we only |
|
45 # ever call this with a single filename. |
|
46 assert isinstance(filenames, (bytes, str, os.PathLike)) |
|
47 filename = os.fspath(filenames) |
|
48 |
|
49 try: |
|
50 with open(filename, encoding='utf-8') as fp: |
|
51 toml_text = fp.read() |
|
52 except OSError: |
|
53 return [] |
|
54 if tomllib is not None: |
|
55 toml_text = substitute_variables(toml_text, os.environ) |
|
56 try: |
|
57 self.data = tomllib.loads(toml_text) |
|
58 except tomllib.TOMLDecodeError as err: |
|
59 raise TomlDecodeError(str(err)) from err |
|
60 return [filename] |
|
61 else: |
|
62 has_toml = re.search(r"^\[tool\.coverage\.", toml_text, flags=re.MULTILINE) |
|
63 if self.our_file or has_toml: |
|
64 # Looks like they meant to read TOML, but we can't read it. |
|
65 msg = "Can't read {!r} without TOML support. Install with [toml] extra" |
|
66 raise ConfigError(msg.format(filename)) |
|
67 return [] |
|
68 |
|
69 def _get_section(self, section): |
|
70 """Get a section from the data. |
|
71 |
|
72 Arguments: |
|
73 section (str): A section name, which can be dotted. |
|
74 |
|
75 Returns: |
|
76 name (str): the actual name of the section that was found, if any, |
|
77 or None. |
|
78 data (str): the dict of data in the section, or None if not found. |
|
79 |
|
80 """ |
|
81 prefixes = ["tool.coverage."] |
|
82 if self.our_file: |
|
83 prefixes.append("") |
|
84 for prefix in prefixes: |
|
85 real_section = prefix + section |
|
86 parts = real_section.split(".") |
|
87 try: |
|
88 data = self.data[parts[0]] |
|
89 for part in parts[1:]: |
|
90 data = data[part] |
|
91 except KeyError: |
|
92 continue |
|
93 break |
|
94 else: |
|
95 return None, None |
|
96 return real_section, data |
|
97 |
|
98 def _get(self, section, option): |
|
99 """Like .get, but returns the real section name and the value.""" |
|
100 name, data = self._get_section(section) |
|
101 if data is None: |
|
102 raise configparser.NoSectionError(section) |
|
103 try: |
|
104 return name, data[option] |
|
105 except KeyError as exc: |
|
106 raise configparser.NoOptionError(option, name) from exc |
|
107 |
|
108 def has_option(self, section, option): |
|
109 _, data = self._get_section(section) |
|
110 if data is None: |
|
111 return False |
|
112 return option in data |
|
113 |
|
114 def has_section(self, section): |
|
115 name, _ = self._get_section(section) |
|
116 return name |
|
117 |
|
118 def options(self, section): |
|
119 _, data = self._get_section(section) |
|
120 if data is None: |
|
121 raise configparser.NoSectionError(section) |
|
122 return list(data.keys()) |
|
123 |
|
124 def get_section(self, section): |
|
125 _, data = self._get_section(section) |
|
126 return data |
|
127 |
|
128 def get(self, section, option): |
|
129 _, value = self._get(section, option) |
|
130 return value |
|
131 |
|
132 def _check_type(self, section, option, value, type_, type_desc): |
|
133 if not isinstance(value, type_): |
|
134 raise ValueError( |
|
135 'Option {!r} in section {!r} is not {}: {!r}' |
|
136 .format(option, section, type_desc, value) |
|
137 ) |
|
138 |
|
139 def getboolean(self, section, option): |
|
140 name, value = self._get(section, option) |
|
141 self._check_type(name, option, value, bool, "a boolean") |
|
142 return value |
|
143 |
|
144 def getlist(self, section, option): |
|
145 name, values = self._get(section, option) |
|
146 self._check_type(name, option, values, list, "a list") |
|
147 return values |
|
148 |
|
149 def getregexlist(self, section, option): |
|
150 name, values = self._get(section, option) |
|
151 self._check_type(name, option, values, list, "a list") |
|
152 for value in values: |
|
153 value = value.strip() |
|
154 try: |
|
155 re.compile(value) |
|
156 except re.error as e: |
|
157 raise ConfigError(f"Invalid [{name}].{option} value {value!r}: {e}") from e |
|
158 return values |
|
159 |
|
160 def getint(self, section, option): |
|
161 name, value = self._get(section, option) |
|
162 self._check_type(name, option, value, int, "an integer") |
|
163 return value |
|
164 |
|
165 def getfloat(self, section, option): |
|
166 name, value = self._get(section, option) |
|
167 if isinstance(value, int): |
|
168 value = float(value) |
|
169 self._check_type(name, option, value, float, "a float") |
|
170 return value |