dotless 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dotless/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dotless: find dotted/dotless I bugs caused by locale-dependent case mapping."""
2
+
3
+ __version__ = "1.0.0"
dotless/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
dotless/analysis.py ADDED
@@ -0,0 +1,350 @@
1
+ """Decide how serious a locale-dependent case conversion is.
2
+
3
+ Only one character pair behaves differently in Turkic locales: I/i.
4
+ That makes equality checks against ASCII constants decidable:
5
+
6
+ * lowering, then comparing with a constant that contains 'i' -> broken
7
+ * lowering, then comparing with a constant without any 'i' -> unaffected
8
+ * upper-casing works the same way with 'I'
9
+
10
+ A site is reported as ``high`` when its result is compared with an
11
+ affected ASCII constant or is used as a lookup key (hash table, key file,
12
+ dictionary, MIME type, file extension ...). A site whose only uses are
13
+ comparisons with unaffected constants is not reported at all. Everything
14
+ else is ``low``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+
22
+ from .lexer import Source, literal_value
23
+
24
+ LOWER, UPPER, BOTH = "lower", "upper", "both"
25
+
26
+ _CMP_OP = re.compile(r"\s*(===|!==|==|!=)\s*(?:(?:Ok|Some)\s*\(\s*)?")
27
+ _CMP_OP_BEFORE = re.compile(r"(===|!==|==|!=)\s*$")
28
+ _PY_IN = re.compile(r"\s*(?:not\s+)?in\s*([(\[{])")
29
+ _METHOD_AFTER = re.compile(r"\s*\.\s*(\w+)\s*\(")
30
+ _IDENT_BEFORE = re.compile(r"([A-Za-z_$][\w$]*(?:\s*(?:\.|->|::)\s*[A-Za-z_$][\w$]*)*)\s*$")
31
+ _ASSIGN_BEFORE = re.compile(
32
+ r"([A-Za-z_$][\w$]*)\s*(?:\[[^\]]*\]\s*)?=\s*(?:\(\s*(?:const\s+)?\w+\s*\**\s*\)\s*)?$"
33
+ )
34
+ _CASE_LABEL = re.compile(r"\bcase\s+")
35
+
36
+ COMPARE_FUNCS = {
37
+ "strcmp", "strncmp", "g_strcmp0", "g_str_equal", "g_str_has_prefix",
38
+ "g_str_has_suffix", "strstr", "g_strrstr", "strcasecmp", "strncasecmp",
39
+ "g_ascii_strcasecmp", "g_ascii_strncasecmp", "g_strv_contains", "memcmp",
40
+ "streq", "g_strcasecmp", "g_strncasecmp", "strcasestr", "g_regex_match_simple",
41
+ "g_pattern_match_simple",
42
+ }
43
+ COMPARE_METHODS = {
44
+ "has_prefix", "has_suffix", "contains", "startsWith", "endsWith",
45
+ "includes", "indexOf", "startswith", "endswith", "find", "index",
46
+ "equals", "equal", "match", "test", "search", "replace", "split",
47
+ }
48
+ KEY_FUNC_PREFIXES = (
49
+ "g_hash_table_", "g_tree_", "g_key_file_", "g_settings_", "g_quark_",
50
+ "g_intern_", "g_datalist_", "g_dataset_", "g_content_type_", "g_mime_",
51
+ "g_variant_lookup", "g_variant_dict_", "g_object_get_data", "g_object_set_data",
52
+ "g_file_info_get_attribute", "g_file_info_set_attribute", "bsearch",
53
+ "g_app_info_get_default_for_type", "g_application_get_", "g_type_from_name",
54
+ "g_enum_get_value_by_", "g_flags_get_value_by_",
55
+ )
56
+ KEY_METHODS = {
57
+ "lookup", "lookup_extended", "get", "has", "has_key", "contains_key",
58
+ "get_string", "get_boolean", "get_int", "get_value", "get_data",
59
+ "setdefault", "pop",
60
+ }
61
+ # Storing a converted key is only a problem if something later looks it up
62
+ # with a differently converted string; that is not visible locally.
63
+ INSERT_FUNCS = {
64
+ "g_hash_table_add", "g_hash_table_insert", "g_hash_table_replace",
65
+ "g_tree_insert", "g_tree_replace", "insert", "set", "add", "push", "append",
66
+ }
67
+ # Calls and methods that pass a string through without changing its case.
68
+ TRANSPARENT_FUNCS = {
69
+ "from_ptr", "from", "String", "str", "CStr", "Some", "Ok", "g_strstrip",
70
+ "g_strchomp", "g_strchug", "strip", "trim",
71
+ }
72
+ TRANSPARENT_METHODS = {
73
+ "to_str", "to_bytes", "to_string_lossy", "to_string", "to_owned", "unwrap",
74
+ "as_str", "as_ref", "strip", "trim", "toString", "get_string",
75
+ }
76
+ IGNORED_USES = {"g_free", "free", "g_clear_pointer", "g_strfreev", "g_steal_pointer", "g_strdup", "strlen"}
77
+ # Variable names that mark machine-readable identifiers. "key" and
78
+ # "keyword" are deliberately absent: in GNOME code they usually hold
79
+ # keyboard keys or user search terms.
80
+ KEYISH_WORDS = {
81
+ "ext", "exts", "extension", "extensions", "suffix", "mime", "mimetype",
82
+ "scheme", "protocol", "proto", "charset", "encoding", "codeset", "header",
83
+ }
84
+ _DEFINE = re.compile(r'^[ ]*#[ ]*define[ ]+(\w+)[ ]+("(?:[^"\
85
+ ]|\.)*")[ ]*$', re.M)
86
+
87
+
88
+ @dataclass
89
+ class Verdict:
90
+ severity: str | None # "high", "low" or None (provably unaffected)
91
+ reason: str
92
+
93
+
94
+ def _affected(value: str, kind: str) -> bool:
95
+ if not value.isascii():
96
+ return False
97
+ if kind == LOWER:
98
+ return "i" in value
99
+ if kind == UPPER:
100
+ return "I" in value
101
+ return "i" in value or "I" in value
102
+
103
+
104
+ def _ident_words(name: str) -> set[str]:
105
+ spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
106
+ return {w for w in re.split(r"[_\W]+", spaced) if w}
107
+
108
+
109
+ def is_keyish(name: str) -> bool:
110
+ words = _ident_words(name)
111
+ if words & KEYISH_WORDS:
112
+ return True
113
+ return "content_type" in "_".join(words) or "mime_type" in "_".join(words)
114
+
115
+
116
+ _BRACKETS = {"(": ")", "[": "]", "{": "}"}
117
+ _match_cache: tuple[str, dict[int, int]] = ("", {})
118
+
119
+
120
+ def _bracket_map(bare: str) -> dict[int, int]:
121
+ """Offset of every opening bracket -> offset just past its closer.
122
+
123
+ Computed once per file in a single pass. A mismatched closer (macro
124
+ tricks, broken code) closes the innermost open bracket; brackets left
125
+ open at the end map to the end of the text.
126
+ """
127
+ global _match_cache
128
+ if _match_cache[0] is bare:
129
+ return _match_cache[1]
130
+ result: dict[int, int] = {}
131
+ stack: list[int] = []
132
+ for i, c in enumerate(bare):
133
+ if c in _BRACKETS:
134
+ stack.append(i)
135
+ elif c in ")]}" and stack:
136
+ result[stack.pop()] = i + 1
137
+ n = len(bare)
138
+ for i in stack:
139
+ result[i] = n
140
+ _match_cache = (bare, result)
141
+ return result
142
+
143
+
144
+ def matching_close(bare: str, open_idx: int) -> int:
145
+ """Index just past the bracket matching ``bare[open_idx]``."""
146
+ return _bracket_map(bare).get(open_idx, len(bare))
147
+
148
+
149
+ def enclosing(bare: str, pos: int, limit: int = 2000) -> tuple[int, str, int] | None:
150
+ """Innermost unclosed ( or [ before ``pos`` and the name in front of it.
151
+
152
+ Returns (offset of the bracket, name, offset where the possibly
153
+ qualified name starts). ``name`` is "[" for subscripts. Stops at
154
+ statement boundaries.
155
+ """
156
+ depth = 0
157
+ i = pos - 1
158
+ stop = max(0, pos - limit)
159
+ while i >= stop:
160
+ c = bare[i]
161
+ if c in ")]":
162
+ depth += 1
163
+ elif c in "([":
164
+ if depth == 0:
165
+ if c == "[":
166
+ return i, "[", i
167
+ m = _IDENT_BEFORE.search(bare, max(0, i - 200), i)
168
+ if not m:
169
+ return i, "", i
170
+ return i, re.split(r"\s*(?:\.|->|::)\s*", m.group(1))[-1], m.start(1)
171
+ depth -= 1
172
+ elif c in ";{}" and depth == 0:
173
+ return None
174
+ i -= 1
175
+ return None
176
+
177
+
178
+ def defines(src: Source) -> dict[str, str]:
179
+ """``#define NAME "literal"`` constants of a file (cached on ``src``)."""
180
+ cached = getattr(src, "_dotless_defines", None)
181
+ if cached is None:
182
+ cached = {m.group(1): literal_value(m.group(2)) for m in _DEFINE.finditer(src.text)} if "#" in src.text else {}
183
+ src._dotless_defines = cached
184
+ return cached
185
+
186
+
187
+ def literals_in(src: Source, start: int, end: int) -> list[str]:
188
+ """Literal values in text[start:end], including #define'd constants."""
189
+ out = []
190
+ consts = defines(src)
191
+ if consts:
192
+ for m in re.finditer(r"[A-Za-z_]\w*", src.bare[start:end]):
193
+ v = consts.get(m.group(0))
194
+ if v is not None:
195
+ out.append(v)
196
+ for s, e in src.strings:
197
+ if s >= end:
198
+ break
199
+ if s >= start:
200
+ v = literal_value(src.text[s:e])
201
+ if v is not None:
202
+ out.append(v)
203
+ return out
204
+
205
+
206
+ def judge_literals(values: list[str], kind: str, what: str) -> Verdict | None:
207
+ if not values:
208
+ return None
209
+ hit = next((v for v in values if _affected(v, kind)), None)
210
+ if hit is not None:
211
+ return Verdict("high", f"{what} {_show(hit)}")
212
+ return Verdict(None, f"{what} only constants without i/I")
213
+
214
+
215
+ def _show(v: str) -> str:
216
+ v = v if len(v) <= 40 else v[:37] + "..."
217
+ return '"' + v.replace("\n", "\\n") + '"'
218
+
219
+
220
+ def _literal_ending_at(src: Source, end: int) -> str | None:
221
+ for s, e in reversed(src.strings):
222
+ if e == end:
223
+ return literal_value(src.text[s:e])
224
+ if e < end:
225
+ return None
226
+ return None
227
+
228
+
229
+ def judge_expression(src: Source, start: int, end: int, kind: str, lang: str, depth: int = 0) -> Verdict | None:
230
+ """Look at how the value of ``text[start:end]`` is used.
231
+
232
+ Returns a Verdict when the use is decidable, None otherwise.
233
+ """
234
+ bare = src.bare
235
+
236
+ # Skip case-preserving method chains: expr.to_str().unwrap() == "lit"
237
+ while True:
238
+ m = _METHOD_AFTER.match(bare, end)
239
+ if not m or m.group(1) not in TRANSPARENT_METHODS:
240
+ break
241
+ end = matching_close(bare, m.end() - 1)
242
+
243
+ # expr == "lit"
244
+ m = _CMP_OP.match(bare, end)
245
+ if m:
246
+ span = src.string_at(m.end())
247
+ if span:
248
+ v = literal_value(src.text[span[0] : span[1]])
249
+ if v is not None:
250
+ return judge_literals([v], kind, "compared with")
251
+ # "lit" == expr
252
+ m = _CMP_OP_BEFORE.search(bare, max(0, start - 50), start)
253
+ if m:
254
+ k = m.start()
255
+ while k > 0 and bare[k - 1].isspace():
256
+ k -= 1
257
+ v = _literal_ending_at(src, k)
258
+ if v is not None:
259
+ return judge_literals([v], kind, "compared with")
260
+
261
+ # expr in ("a", "b")
262
+ if lang == "python":
263
+ m = _PY_IN.match(bare, end)
264
+ if m:
265
+ close = matching_close(bare, m.end() - 1)
266
+ return judge_literals(literals_in(src, m.end(), close), kind, "checked for membership in")
267
+
268
+ # expr.endsWith(".ini") / expr.has_suffix (".ini")
269
+ m = _METHOD_AFTER.match(bare, end)
270
+ if m:
271
+ close = matching_close(bare, m.end() - 1)
272
+ if m.group(1) in COMPARE_METHODS:
273
+ v = judge_literals(literals_in(src, m.end(), close), kind, f"passed to .{m.group(1)}() with")
274
+ if v:
275
+ return v
276
+
277
+ enc = enclosing(bare, start)
278
+ if enc is None:
279
+ return None
280
+ open_idx, name, name_start = enc
281
+ if name == "[":
282
+ return Verdict("high", "used as a subscript key")
283
+ if name == "switch":
284
+ close = matching_close(bare, open_idx)
285
+ brace = bare.find("{", close)
286
+ if brace >= 0 and not bare[close:brace].strip():
287
+ body_end = matching_close(bare, brace)
288
+ labels = []
289
+ for cm in _CASE_LABEL.finditer(bare, brace, body_end):
290
+ span = src.string_at(cm.end())
291
+ if span:
292
+ v = literal_value(src.text[span[0] : span[1]])
293
+ if v is not None:
294
+ labels.append(v)
295
+ return judge_literals(labels, kind, "switch case")
296
+ return None
297
+ close = matching_close(bare, open_idx)
298
+ if name in TRANSPARENT_FUNCS and depth < 3:
299
+ return judge_expression(src, name_start, close, kind, lang, depth + 1)
300
+ if name in COMPARE_FUNCS or name in COMPARE_METHODS:
301
+ return judge_literals(literals_in(src, open_idx, close), kind, f"passed to {name}() with")
302
+ if name in INSERT_FUNCS:
303
+ return Verdict("low", f"stored as a key with {name}()")
304
+ if name.startswith(KEY_FUNC_PREFIXES) or name in KEY_METHODS:
305
+ return Verdict("high", f"used as a lookup key in {name}()")
306
+ if name in IGNORED_USES:
307
+ return Verdict(None, f"only passed to {name}()")
308
+ return None
309
+
310
+
311
+ def judge_call(src: Source, start: int, end: int, kind: str, lang: str) -> Verdict:
312
+ """Severity for a locale-dependent call spanning text[start:end]."""
313
+ direct = judge_expression(src, start, end, kind, lang)
314
+ if direct is not None:
315
+ return direct
316
+
317
+ # Result stored in a variable: follow it for a while.
318
+ bare = src.bare
319
+ # Assignments sit right before the call; a bounded window keeps very
320
+ # long lines (minified or generated code) linear.
321
+ line_start = max(bare.rfind("\n", 0, start) + 1, start - 300)
322
+ m = _ASSIGN_BEFORE.search(bare, line_start, start)
323
+ if m:
324
+ var = m.group(1)
325
+ if is_keyish(var):
326
+ return Verdict("high", f"stored in `{var}` (looks like a key, extension, MIME type or protocol name)")
327
+ return _follow_variable(src, var, end, kind, lang)
328
+ return Verdict("low", "result not compared with a constant nearby")
329
+
330
+
331
+ def _follow_variable(src: Source, var: str, from_off: int, kind: str, lang: str) -> Verdict:
332
+ bare = src.bare
333
+ end_line = src.line_of(from_off) + 60
334
+ limit = src.line_offset(end_line)
335
+ use = re.compile(r"(?<![\w$.>])" + re.escape(var) + r"(?![\w$])")
336
+ verdicts: list[Verdict] = []
337
+ undecided = False
338
+ for m in use.finditer(bare, from_off, limit):
339
+ if re.match(r"\s*=(?!=)", bare[m.end() : m.end() + 3]):
340
+ break # reassigned
341
+ v = judge_expression(src, m.start(), m.end(), kind, lang)
342
+ if v is None:
343
+ undecided = True
344
+ continue
345
+ if v.severity == "high":
346
+ return Verdict("high", f"stored in `{var}`, which is {v.reason} (line {src.line_of(m.start())})")
347
+ verdicts.append(v)
348
+ if verdicts and not undecided:
349
+ return Verdict(None, f"`{var}` is {verdicts[0].reason}")
350
+ return Verdict("low", f"stored in `{var}`; no comparison with an affected constant found")
dotless/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """Command line interface.
2
+
3
+ Exit status: 0 nothing found (or ``--exit-zero``), 1 findings reported,
4
+ 2 usage or configuration error. ``dotless run`` uses 0 for identical
5
+ output, 1 for differences and 2 when it cannot run the comparison.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+
14
+ from . import __version__
15
+ from .rules import RULES, SEVERITIES
16
+
17
+ EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
18
+
19
+
20
+ def _err(msg: str) -> None:
21
+ print(f"dotless: {msg}", file=sys.stderr)
22
+
23
+
24
+ def use_color(mode: str, stream) -> bool:
25
+ """Colour for --color=auto follows NO_COLOR and whether ``stream`` is a terminal."""
26
+ if mode == "always":
27
+ return True
28
+ if mode == "never":
29
+ return False
30
+ return "NO_COLOR" not in os.environ and hasattr(stream, "isatty") and stream.isatty()
31
+
32
+
33
+ def _write(text: str) -> None:
34
+ try:
35
+ sys.stdout.write(text)
36
+ sys.stdout.flush()
37
+ except BrokenPipeError:
38
+ # The reader went away (e.g. `dotless scan . | head`); stop quietly.
39
+ devnull = os.open(os.devnull, os.O_WRONLY)
40
+ os.dup2(devnull, sys.stdout.fileno())
41
+
42
+
43
+ def _scan(args: argparse.Namespace) -> int:
44
+ from . import config, formats
45
+ from .scanner import scan_paths
46
+
47
+ for p in args.paths:
48
+ if not os.path.exists(p):
49
+ _err(f"{p}: no such file or directory")
50
+ return EXIT_ERROR
51
+ bad = [r for r in args.disable if r not in RULES]
52
+ if bad:
53
+ _err(f"unknown rule id(s): {', '.join(bad)} (see `dotless rules`)")
54
+ return EXIT_ERROR
55
+
56
+ cfg = config.Config()
57
+ path = args.config if args.config else (None if args.no_config else config.find_pyproject(os.getcwd()))
58
+ if args.config and not os.path.isfile(args.config):
59
+ _err(f"{args.config}: no such file")
60
+ return EXIT_ERROR
61
+ if path:
62
+ try:
63
+ cfg, warning = config.load(path)
64
+ except (config.ConfigError, OSError) as e:
65
+ _err(str(e))
66
+ return EXIT_ERROR
67
+ if warning:
68
+ _err(f"warning: {warning}")
69
+
70
+ min_severity = args.min_severity or cfg.min_severity or "low"
71
+ errors: list[str] = []
72
+ findings, files = scan_paths(
73
+ args.paths,
74
+ min_severity,
75
+ excludes=cfg.exclude + args.exclude,
76
+ disable=cfg.disable + args.disable,
77
+ force_exclude=args.force_exclude,
78
+ errors=errors,
79
+ )
80
+ for e in errors:
81
+ _err(f"warning: cannot read {e}")
82
+
83
+ if args.format == "text":
84
+ stream = sys.stdout if args.output is None else None
85
+ out = formats.text(findings, files, color=stream is not None and use_color(args.color, stream))
86
+ else:
87
+ out = formats.FORMATS[args.format](findings, files)
88
+ print(formats.summary(findings, files), file=sys.stderr)
89
+ if args.output:
90
+ try:
91
+ with open(args.output, "w", encoding="utf-8", newline="\n") as fh:
92
+ fh.write(out)
93
+ except OSError as e:
94
+ _err(f"{args.output}: {e.strerror or e}")
95
+ return EXIT_ERROR
96
+ else:
97
+ _write(out)
98
+ return EXIT_FINDINGS if findings and not args.exit_zero else EXIT_OK
99
+
100
+
101
+ def _rules(args: argparse.Namespace) -> int:
102
+ lines = []
103
+ for r in RULES.values():
104
+ lines.append(f"{r.id} {r.name} [{', '.join(r.languages)}]")
105
+ lines.append(f" {r.summary}")
106
+ lines.append(f" fix: {r.fix}")
107
+ _write("\n".join(lines) + "\n")
108
+ return EXIT_OK
109
+
110
+
111
+ def _run(args: argparse.Namespace) -> int:
112
+ from .runtime import run_compare
113
+
114
+ cmd = args.command
115
+ if cmd and cmd[0] == "--":
116
+ cmd = cmd[1:]
117
+ if not cmd:
118
+ _err("run: missing command (usage: dotless run -- COMMAND [ARGS...])")
119
+ return EXIT_ERROR
120
+ return run_compare(cmd, args.locales.split(","), args.baseline, args.timeout, args.stdin)
121
+
122
+
123
+ def build_parser() -> argparse.ArgumentParser:
124
+ p = argparse.ArgumentParser(
125
+ prog="dotless",
126
+ description="Find dotted/dotless I bugs caused by locale-dependent case mapping.",
127
+ epilog="Exit status: 0 nothing found, 1 findings reported, 2 error.",
128
+ )
129
+ p.add_argument("--version", action="version", version=f"dotless {__version__}")
130
+ sub = p.add_subparsers(dest="cmd", required=True)
131
+
132
+ s = sub.add_parser(
133
+ "scan",
134
+ help="scan source files",
135
+ description="Scan C, Vala, Python, JavaScript and Rust sources. Settings in "
136
+ "[tool.dotless] of the nearest pyproject.toml are used unless --no-config is given.",
137
+ )
138
+ s.add_argument("paths", nargs="+", metavar="PATH")
139
+ s.add_argument("-f", "--format", choices=["text", "json", "sarif", "gitlab"], default="text")
140
+ s.add_argument("--min-severity", choices=SEVERITIES, default=None, help="default: low")
141
+ s.add_argument("-o", "--output", metavar="FILE", help="write the report to FILE")
142
+ s.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="skip matching paths (repeatable)")
143
+ s.add_argument("--force-exclude", action="store_true", help="apply excludes to files named on the command line too")
144
+ s.add_argument("--disable", action="append", default=[], metavar="RULE", help="turn off a rule, e.g. DL005 (repeatable)")
145
+ s.add_argument("--color", choices=["auto", "always", "never"], default="auto", help="colour text output (default: auto)")
146
+ s.add_argument("--config", metavar="FILE", help="read [tool.dotless] from FILE")
147
+ s.add_argument("--no-config", action="store_true", help="ignore pyproject.toml")
148
+ s.add_argument("--exit-zero", action="store_true", help="exit with 0 even if there are findings")
149
+ s.set_defaults(func=_scan)
150
+
151
+ r = sub.add_parser("rules", help="list rules")
152
+ r.set_defaults(func=_rules)
153
+
154
+ run = sub.add_parser(
155
+ "run",
156
+ help="run a command under Turkic locales and compare its output",
157
+ usage="dotless run [--locales L1,L2] [--baseline LOCALE] -- COMMAND [ARGS...]",
158
+ )
159
+ run.add_argument("--locales", default="tr_TR.UTF-8,az_AZ.UTF-8")
160
+ run.add_argument("--baseline", default="C.UTF-8")
161
+ run.add_argument("--timeout", type=float, default=60.0)
162
+ run.add_argument("--stdin", metavar="FILE", help="feed FILE to the command's standard input")
163
+ run.add_argument("command", nargs=argparse.REMAINDER)
164
+ run.set_defaults(func=_run)
165
+ return p
166
+
167
+
168
+ def main(argv: list[str] | None = None) -> int:
169
+ for stream in (sys.stdout, sys.stderr):
170
+ if hasattr(stream, "reconfigure"):
171
+ stream.reconfigure(errors="backslashreplace")
172
+ args = build_parser().parse_args(argv)
173
+ return args.func(args)
dotless/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """``[tool.dotless]`` settings from pyproject.toml.
2
+
3
+ Reading TOML needs ``tomllib`` (Python 3.11+). On older Pythons a
4
+ pyproject.toml that has a ``[tool.dotless]`` table is reported and
5
+ ignored; command line options keep working.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from .rules import RULES, SEVERITIES
15
+
16
+ try:
17
+ import tomllib
18
+ except ImportError: # Python < 3.11
19
+ tomllib = None
20
+
21
+ KEYS = {"exclude", "min-severity", "disable"}
22
+
23
+
24
+ class ConfigError(Exception):
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ path: str | None = None
31
+ exclude: list[str] = field(default_factory=list)
32
+ min_severity: str | None = None
33
+ disable: list[str] = field(default_factory=list)
34
+
35
+
36
+ def find_pyproject(start: str) -> str | None:
37
+ """The nearest pyproject.toml in ``start`` or one of its parents."""
38
+ d = os.path.abspath(start)
39
+ while True:
40
+ candidate = os.path.join(d, "pyproject.toml")
41
+ if os.path.isfile(candidate):
42
+ return candidate
43
+ parent = os.path.dirname(d)
44
+ if parent == d:
45
+ return None
46
+ d = parent
47
+
48
+
49
+ def load(path: str) -> tuple[Config, str | None]:
50
+ """Read ``[tool.dotless]`` from ``path``.
51
+
52
+ Returns (config, warning). Raises ConfigError for invalid settings.
53
+ """
54
+ with open(path, "rb") as fh:
55
+ raw = fh.read()
56
+ if tomllib is None:
57
+ if re.search(rb"^\s*\[tool\.dotless\]", raw, re.M):
58
+ return Config(), (f"{path}: [tool.dotless] ignored, reading it needs Python 3.11 or newer")
59
+ return Config(), None
60
+ try:
61
+ data = tomllib.loads(raw.decode("utf-8"))
62
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
63
+ raise ConfigError(f"{path}: {e}") from None
64
+ table = data.get("tool", {}).get("dotless")
65
+ if table is None:
66
+ return Config(), None
67
+ if not isinstance(table, dict):
68
+ raise ConfigError(f"{path}: [tool.dotless] must be a table")
69
+ unknown = set(table) - KEYS
70
+ if unknown:
71
+ raise ConfigError(f"{path}: unknown key(s) in [tool.dotless]: {', '.join(sorted(unknown))}")
72
+
73
+ cfg = Config(path=path)
74
+ exclude = table.get("exclude", [])
75
+ if not (isinstance(exclude, list) and all(isinstance(x, str) for x in exclude)):
76
+ raise ConfigError(f"{path}: 'exclude' must be a list of strings")
77
+ cfg.exclude = exclude
78
+
79
+ sev = table.get("min-severity")
80
+ if sev is not None and sev not in SEVERITIES:
81
+ raise ConfigError(f"{path}: 'min-severity' must be one of {', '.join(SEVERITIES)}")
82
+ cfg.min_severity = sev
83
+
84
+ disable = table.get("disable", [])
85
+ if not (isinstance(disable, list) and all(isinstance(x, str) for x in disable)):
86
+ raise ConfigError(f"{path}: 'disable' must be a list of rule ids")
87
+ bad = [r for r in disable if r not in RULES]
88
+ if bad:
89
+ raise ConfigError(f"{path}: unknown rule id(s) in 'disable': {', '.join(bad)}")
90
+ cfg.disable = disable
91
+ return cfg, None