jittest 0.2.1__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.
jittest/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """jittest - just-in-time catching tests for pull requests.
2
+
3
+ The open-source reference implementation of Just-in-Time catching test
4
+ generation, the method published by Meta in 2026 (arXiv 2601.22832).
5
+
6
+ A catching test is not a test that passes. It is a test that PASSES on the
7
+ base commit and FAILS on the head commit, proving the change broke something.
8
+ That distinction is the whole product.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.2.1"
13
+ __all__ = ["__version__"]
jittest/_minirunner.py ADDED
@@ -0,0 +1,121 @@
1
+ """A ~100 line test runner, used only when pytest is not importable.
2
+
3
+ Why this exists: jittest must be able to answer the question "does this test
4
+ pass here?" in environments where we cannot install anything - hardened CI
5
+ images, air-gapped runners, or a first-time user who has not created a virtual
6
+ environment yet. Refusing to run is a worse answer than running a smaller
7
+ runner.
8
+
9
+ Exit codes deliberately mirror pytest so the oracle needs no special cases:
10
+
11
+ 0 all tests passed
12
+ 1 at least one test failed
13
+ 2 the file could not be imported or collected
14
+ 5 no tests were collected
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import importlib.util
20
+ import inspect
21
+ import sys
22
+ import traceback
23
+ from pathlib import Path
24
+
25
+ EXIT_OK = 0
26
+ EXIT_FAILED = 1
27
+ EXIT_COLLECTION_ERROR = 2
28
+ EXIT_NO_TESTS = 5
29
+
30
+
31
+ def _load(path: Path):
32
+ spec = importlib.util.spec_from_file_location(path.stem, path)
33
+ if spec is None or spec.loader is None:
34
+ raise ImportError(f"cannot load {path}")
35
+ module = importlib.util.module_from_spec(spec)
36
+ sys.modules[path.stem] = module
37
+ spec.loader.exec_module(module)
38
+ return module
39
+
40
+
41
+ def _call(fn) -> None:
42
+ if inspect.iscoroutinefunction(fn):
43
+ asyncio.run(fn())
44
+ else:
45
+ fn()
46
+
47
+
48
+ def _collect(module) -> list[tuple[str, object]]:
49
+ tests: list[tuple[str, object]] = []
50
+ for name in dir(module):
51
+ obj = getattr(module, name)
52
+ if name.startswith("test_") and callable(obj):
53
+ tests.append((name, obj))
54
+ elif name.startswith("Test") and inspect.isclass(obj):
55
+ for method_name in dir(obj):
56
+ if method_name.startswith("test_"):
57
+ tests.append((f"{name}.{method_name}", (obj, method_name)))
58
+ return tests
59
+
60
+
61
+ def run_file(path: Path) -> int:
62
+ try:
63
+ module = _load(path)
64
+ except BaseException:
65
+ print(f"COLLECTION ERROR in {path.name}", file=sys.stderr)
66
+ traceback.print_exc()
67
+ return EXIT_COLLECTION_ERROR
68
+
69
+ tests = _collect(module)
70
+ if not tests:
71
+ print("no tests collected", file=sys.stderr)
72
+ return EXIT_NO_TESTS
73
+
74
+ failures = 0
75
+ for label, target in tests:
76
+ try:
77
+ if isinstance(target, tuple):
78
+ cls, method_name = target
79
+ instance = cls()
80
+ if hasattr(instance, "setup_method"):
81
+ instance.setup_method(getattr(instance, method_name))
82
+ _call(getattr(instance, method_name))
83
+ if hasattr(instance, "teardown_method"):
84
+ instance.teardown_method(getattr(instance, method_name))
85
+ else:
86
+ sig = inspect.signature(target)
87
+ if sig.parameters:
88
+ print(f"SKIP {label}: fixtures are not supported by the "
89
+ "mini-runner; install pytest", file=sys.stderr)
90
+ continue
91
+ _call(target)
92
+ print(f"PASS {label}")
93
+ except BaseException as exc:
94
+ failures += 1
95
+ print(f"FAIL {label}: {type(exc).__name__}: {exc}", file=sys.stderr)
96
+ traceback.print_exc()
97
+
98
+ return EXIT_FAILED if failures else EXIT_OK
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ args = list(sys.argv[1:] if argv is None else argv)
103
+ paths = [Path(a) for a in args if not a.startswith("-")]
104
+ if not paths:
105
+ print("usage: python -m jittest._minirunner <test_file.py>", file=sys.stderr)
106
+ return EXIT_COLLECTION_ERROR
107
+
108
+ worst = EXIT_NO_TESTS
109
+ for path in paths:
110
+ code = run_file(path)
111
+ if code == EXIT_COLLECTION_ERROR:
112
+ return code
113
+ if code == EXIT_FAILED:
114
+ worst = EXIT_FAILED
115
+ elif code == EXIT_OK and worst != EXIT_FAILED:
116
+ worst = EXIT_OK
117
+ return worst
118
+
119
+
120
+ if __name__ == "__main__":
121
+ raise SystemExit(main())
jittest/assess.py ADDED
@@ -0,0 +1,89 @@
1
+ """The assessor layer.
2
+
3
+ Meta reports that assessor agents cut reviewer load by roughly 70% (arXiv
4
+ 2601.22832). That number is the difference between a tool people keep switched
5
+ on and one they mute in week two: independent 2026 measurements put 15-30% of
6
+ AI review comments in the low-value or incorrect bucket.
7
+
8
+ The oracle decides whether a test is *true*. The assessor decides whether it is
9
+ *worth saying*. Only a confident real_regression is reported by default.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ __all__ = ["Assessment", "parse_assessment", "MIN_CONFIDENCE", "VALID_VERDICTS"]
16
+
17
+ MIN_CONFIDENCE = 0.70
18
+ VALID_VERDICTS = ("real_regression", "intended_change", "unclear")
19
+ VALID_SEVERITY = ("low", "medium", "high")
20
+
21
+
22
+ def _coerce_confidence(raw: object) -> float:
23
+ try:
24
+ value = float(raw) # type: ignore[arg-type]
25
+ except (TypeError, ValueError):
26
+ return 0.0
27
+ if value > 1.0: # models like answering "85"
28
+ value = value / 100.0
29
+ return round(min(1.0, max(0.0, value)), 3)
30
+
31
+
32
+ @dataclass
33
+ class Assessment:
34
+ verdict: str = "unclear"
35
+ confidence: float = 0.0
36
+ severity: str = "medium"
37
+ summary: str = ""
38
+ reviewer_question: str = ""
39
+ raw: str = ""
40
+
41
+ @property
42
+ def should_report(self) -> bool:
43
+ return self.verdict == "real_regression" and self.confidence >= MIN_CONFIDENCE
44
+
45
+ @property
46
+ def badge(self) -> str:
47
+ return {
48
+ "real_regression": "likely regression",
49
+ "intended_change": "looks intended",
50
+ "unclear": "needs a human",
51
+ }.get(self.verdict, "needs a human")
52
+
53
+ def as_dict(self) -> dict:
54
+ return {
55
+ "verdict": self.verdict,
56
+ "confidence": self.confidence,
57
+ "severity": self.severity,
58
+ "summary": self.summary,
59
+ "reviewer_question": self.reviewer_question,
60
+ "should_report": self.should_report,
61
+ }
62
+
63
+
64
+ def parse_assessment(payload: dict | None, raw: str = "") -> Assessment:
65
+ """Never raise. A malformed assessor reply degrades to `unclear`, which is
66
+ not reported - failing closed is the whole point of this layer."""
67
+ if not isinstance(payload, dict):
68
+ return Assessment(summary="assessor returned unparseable output", raw=raw)
69
+
70
+ verdict = str(payload.get("verdict", "")).strip().lower()
71
+ if verdict not in VALID_VERDICTS:
72
+ verdict = "unclear"
73
+
74
+ severity = str(payload.get("severity", "medium")).strip().lower()
75
+ if severity not in VALID_SEVERITY:
76
+ severity = "medium"
77
+
78
+ confidence = _coerce_confidence(payload.get("confidence", 0.0))
79
+ if verdict == "unclear":
80
+ confidence = min(confidence, MIN_CONFIDENCE - 0.01)
81
+
82
+ return Assessment(
83
+ verdict=verdict,
84
+ confidence=confidence,
85
+ severity=severity,
86
+ summary=str(payload.get("summary", "")).strip()[:300],
87
+ reviewer_question=str(payload.get("reviewer_question", "")).strip()[:300],
88
+ raw=raw,
89
+ )
jittest/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """Command line interface. argparse, because a CI tool has no business dragging
2
+ Typer, Click and Rich into somebody else's dependency graph.
3
+
4
+ jittest run --base main --head HEAD
5
+ jittest run --dry-run # whole pipeline, no model, no key, no cost
6
+ jittest doctor # can this environment run jittest?
7
+ jittest stats # what the ledger has learned
8
+ jittest outcome <hash> fixed_code
9
+ jittest export corpus.jsonl
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+ from .config import load_config
22
+ from .execute import detect_runner
23
+ from .ledger import HUMAN_OUTCOMES, Ledger
24
+ from .llm import LLMError, build_llm
25
+ from .pipeline import run as run_pipeline
26
+ from .report import to_markdown, to_terminal
27
+
28
+
29
+ def _add_run_args(p: argparse.ArgumentParser) -> None:
30
+ p.add_argument("--repo", default=".", help="path to the git repository")
31
+ p.add_argument("--base", default=os.getenv("JITTEST_BASE", "origin/main"))
32
+ p.add_argument("--head", default=os.getenv("JITTEST_HEAD", "HEAD"))
33
+ p.add_argument("--model", default=None)
34
+ p.add_argument("--candidates", type=int, default=None,
35
+ help="candidate tests generated per target")
36
+ p.add_argument("--max-targets", type=int, default=None)
37
+ p.add_argument("--risk-threshold", type=float, default=None)
38
+ p.add_argument("--budget", type=float, default=None, help="hard USD cap")
39
+ p.add_argument("--timeout", type=int, default=None, help="per test run, seconds")
40
+ p.add_argument("--reruns", type=int, default=None,
41
+ help="flakiness reruns on head (default 2)")
42
+ p.add_argument("--latent", action="store_true",
43
+ help="also report faults that fail on base too")
44
+ p.add_argument("--dry-run", action="store_true",
45
+ help="run everything with a stub model: no API key, no cost")
46
+ p.add_argument("--comment", action="store_true", help="upsert a PR comment")
47
+ p.add_argument("--fail-on-regression", action="store_true",
48
+ help="exit 1 when a confident regression is found")
49
+ p.add_argument("--json", dest="as_json", action="store_true")
50
+ p.add_argument("--markdown", metavar="PATH", default=None,
51
+ help="also write the markdown report to this file")
52
+ p.add_argument("--quiet", action="store_true")
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ parser = argparse.ArgumentParser(
57
+ prog="jittest",
58
+ description="Generate tests that FAIL on your pull request and PASS on main.",
59
+ )
60
+ parser.add_argument("--version", action="version", version=f"jittest {__version__}")
61
+ sub = parser.add_subparsers(dest="command", required=True)
62
+
63
+ _add_run_args(sub.add_parser("run", help="analyse a diff"))
64
+
65
+ st = sub.add_parser("stats", help="summarise the local ledger")
66
+ st.add_argument("--repo", default=".")
67
+ st.add_argument("--json", dest="as_json", action="store_true")
68
+
69
+ ex = sub.add_parser("export", help="export the corpus as JSONL")
70
+ ex.add_argument("out")
71
+ ex.add_argument("--repo", default=".")
72
+ ex.add_argument("--with-source", action="store_true",
73
+ help="include repo names and test code (off by default)")
74
+
75
+ oc = sub.add_parser("outcome", help="label what a human did with a finding")
76
+ oc.add_argument("test_hash")
77
+ oc.add_argument("outcome", choices=list(HUMAN_OUTCOMES))
78
+ oc.add_argument("--note", default="")
79
+ oc.add_argument("--repo", default=".")
80
+
81
+ dr = sub.add_parser("doctor", help="check that this environment can run jittest")
82
+ dr.add_argument("--repo", default=".")
83
+
84
+ sub.add_parser("version", help="print the version")
85
+ return parser
86
+
87
+
88
+ def _cmd_run(args: argparse.Namespace) -> int:
89
+ repo = Path(args.repo).resolve()
90
+ cfg = load_config(repo, overrides={
91
+ "model": args.model,
92
+ "candidates_per_target": args.candidates,
93
+ "max_targets": args.max_targets,
94
+ "risk_threshold": args.risk_threshold,
95
+ "budget_usd": args.budget,
96
+ "timeout_s": args.timeout,
97
+ "reruns": args.reruns,
98
+ "latent_mode": True if args.latent else None,
99
+ "fail_on_regression": True if args.fail_on_regression else None,
100
+ })
101
+
102
+ from .github import pr_context, upsert_pr_comment
103
+ pr_title, pr_body = pr_context()
104
+
105
+ try:
106
+ llm = build_llm(cfg.model, dry_run=args.dry_run, budget_usd=cfg.budget_usd,
107
+ temperature=cfg.temperature, cache_path=repo / cfg.cache_path)
108
+ except LLMError as exc:
109
+ print(f"jittest: {exc}", file=sys.stderr)
110
+ return 2
111
+
112
+ emit = (lambda m: None) if args.quiet else (lambda m: print(f" {m}", file=sys.stderr))
113
+ report = run_pipeline(repo, args.base, args.head, cfg, llm,
114
+ pr_title=pr_title, pr_body=pr_body,
115
+ pr_ref=os.getenv("JITTEST_PR_NUMBER", ""),
116
+ on_event=emit)
117
+
118
+ markdown = to_markdown(report)
119
+ if args.as_json:
120
+ print(json.dumps(report.as_dict(), indent=2))
121
+ else:
122
+ print(to_terminal(report))
123
+
124
+ if args.markdown:
125
+ Path(args.markdown).write_text(markdown or "", encoding="utf-8")
126
+
127
+ if args.comment:
128
+ print(f" github: {upsert_pr_comment(markdown)}", file=sys.stderr)
129
+
130
+ if os.getenv("GITHUB_OUTPUT"):
131
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
132
+ fh.write(f"regressions={'true' if report.has_regression else 'false'}\n")
133
+ fh.write(f"findings={len(report.findings)}\n")
134
+ fh.write(f"cost_usd={report.cost_usd:.4f}\n")
135
+
136
+ if (args.fail_on_regression or cfg.fail_on_regression) and report.has_regression:
137
+ return 1
138
+ return 0
139
+
140
+
141
+ def _cmd_stats(args: argparse.Namespace) -> int:
142
+ cfg = load_config(Path(args.repo))
143
+ with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
144
+ stats = ledger.stats()
145
+ if args.as_json:
146
+ print(json.dumps(stats, indent=2))
147
+ return 0
148
+ print("jittest ledger")
149
+ for key, value in stats.items():
150
+ print(f" {key:22} {value}")
151
+ if not stats["labelled"]:
152
+ print("\n No human outcomes recorded yet, so the precision numbers above")
153
+ print(" are unknown rather than good. Label findings with `jittest outcome`.")
154
+ return 0
155
+
156
+
157
+ def _cmd_export(args: argparse.Namespace) -> int:
158
+ cfg = load_config(Path(args.repo))
159
+ with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
160
+ n = ledger.export_jsonl(args.out, anonymise=not args.with_source)
161
+ print(f"wrote {n} record(s) to {args.out} "
162
+ f"({'with source' if args.with_source else 'anonymised'})")
163
+ return 0
164
+
165
+
166
+ def _cmd_outcome(args: argparse.Namespace) -> int:
167
+ cfg = load_config(Path(args.repo))
168
+ with Ledger(Path(args.repo) / cfg.ledger_path) as ledger:
169
+ n = ledger.mark_outcome_by_hash(args.test_hash, args.outcome, args.note)
170
+ print(f"labelled {n} record(s) as {args.outcome}")
171
+ return 0 if n else 1
172
+
173
+
174
+ def _cmd_doctor(args: argparse.Namespace) -> int:
175
+ repo = Path(args.repo).resolve()
176
+ cfg = load_config(repo)
177
+ ok = True
178
+
179
+ def check(label: str, passed: bool, detail: str = "") -> None:
180
+ nonlocal ok
181
+ ok = ok and passed
182
+ print(f" [{'ok ' if passed else 'FAIL'}] {label}"
183
+ f"{(' - ' + detail) if detail else ''}")
184
+
185
+ print(f"jittest {__version__} doctor")
186
+ check("python >= 3.11", sys.version_info >= (3, 11), sys.version.split()[0])
187
+
188
+ git = subprocess.run(["git", "--version"], capture_output=True, text=True)
189
+ check("git available", git.returncode == 0, git.stdout.strip())
190
+
191
+ inside = subprocess.run(
192
+ ["git", "-C", str(repo), "rev-parse", "--is-inside-work-tree"],
193
+ capture_output=True, text=True)
194
+ check("inside a git repository", inside.stdout.strip() == "true", str(repo))
195
+
196
+ runner = detect_runner()
197
+ using_pytest = "pytest" in " ".join(runner)
198
+ print(f" [ok ] test runner: "
199
+ f"{'pytest' if using_pytest else 'built-in mini-runner'}")
200
+ if not using_pytest:
201
+ print(" pytest is not importable here, so the oracle will use the")
202
+ print(" stdlib fallback, which does not support fixtures.")
203
+
204
+ has_key = any(os.getenv(k) for k in
205
+ ("JITTEST_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"))
206
+ print(f" [{'ok ' if has_key else 'warn'}] model API key present"
207
+ f"{'' if has_key else ' - only --dry-run will work'}")
208
+ print(f" [ok ] model {cfg.model}, budget ${cfg.budget_usd:.2f}, "
209
+ f"max targets {cfg.max_targets}")
210
+ print(f" [ok ] ledger {repo / cfg.ledger_path}")
211
+ print(f" [ok ] {len(cfg.ignore)} ignore pattern(s)")
212
+ return 0 if ok else 1
213
+
214
+
215
+ def main(argv: list[str] | None = None) -> int:
216
+ args = build_parser().parse_args(argv)
217
+ if args.command == "run":
218
+ return _cmd_run(args)
219
+ if args.command == "stats":
220
+ return _cmd_stats(args)
221
+ if args.command == "export":
222
+ return _cmd_export(args)
223
+ if args.command == "outcome":
224
+ return _cmd_outcome(args)
225
+ if args.command == "doctor":
226
+ return _cmd_doctor(args)
227
+ print(__version__)
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
jittest/config.py ADDED
@@ -0,0 +1,127 @@
1
+ """Configuration with a boring, predictable precedence chain:
2
+
3
+ dataclass defaults -> .jittest.toml -> [tool.jittest] in pyproject.toml
4
+ -> JITTEST_* environment variables -> command line flags
5
+
6
+ Read with stdlib `tomllib`. No PyYAML, no pydantic.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from dataclasses import asdict, dataclass, field
13
+ from fnmatch import fnmatch
14
+ from pathlib import Path
15
+
16
+ __all__ = ["Config", "load_config", "DEFAULT_IGNORES"]
17
+
18
+ # Generated, vendored or throwaway code. Testing it wastes money and reviewer
19
+ # patience in equal measure.
20
+ DEFAULT_IGNORES = [
21
+ "*/migrations/*", "*/vendor/*", "*/vendored/*", "*/node_modules/*",
22
+ "*/.venv/*", "*/venv/*", "*/build/*", "*/dist/*", "*/__pycache__/*",
23
+ "*_pb2.py", "*_pb2_grpc.py", "*.pyi", "setup.py", "conftest.py",
24
+ "*/docs/*", "*/examples/*",
25
+ ]
26
+
27
+ _ENV = {
28
+ "model": ("JITTEST_MODEL", str),
29
+ "budget_usd": ("JITTEST_BUDGET_USD", float),
30
+ "max_targets": ("JITTEST_MAX_TARGETS", int),
31
+ "candidates_per_target": ("JITTEST_CANDIDATES", int),
32
+ "risk_threshold": ("JITTEST_RISK_THRESHOLD", float),
33
+ "timeout_s": ("JITTEST_TIMEOUT", int),
34
+ "reruns": ("JITTEST_RERUNS", int),
35
+ "min_confidence": ("JITTEST_MIN_CONFIDENCE", float),
36
+ "ledger_path": ("JITTEST_LEDGER", str),
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class Config:
42
+ model: str = "anthropic/claude-sonnet-4-5"
43
+ budget_usd: float = 1.00
44
+ max_targets: int = 5
45
+ candidates_per_target: int = 4
46
+ risk_threshold: float = 0.35
47
+ timeout_s: int = 120
48
+ reruns: int = 2
49
+ temperature: float = 0.8
50
+ min_confidence: float = 0.70
51
+ repair_attempts: int = 1
52
+ latent_mode: bool = False
53
+ fail_on_regression: bool = False
54
+ ledger_path: str = ".jittest/ledger.db"
55
+ cache_path: str = ".jittest/cache.db"
56
+ ignore: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORES))
57
+
58
+ def is_ignored(self, path: str) -> bool:
59
+ p = path.replace("\\", "/")
60
+ name = p.split("/")[-1]
61
+ for pattern in self.ignore:
62
+ if fnmatch(p, pattern) or fnmatch(name, pattern) or fnmatch("/" + p, pattern):
63
+ return True
64
+ return False
65
+
66
+ def as_dict(self) -> dict:
67
+ return asdict(self)
68
+
69
+
70
+ def _from_toml(repo: Path) -> dict:
71
+ for filename, section in ((".jittest.toml", None), ("pyproject.toml", "jittest")):
72
+ path = repo / filename
73
+ if not path.exists():
74
+ continue
75
+ try:
76
+ with path.open("rb") as fh:
77
+ data = tomllib.load(fh)
78
+ except (OSError, tomllib.TOMLDecodeError):
79
+ continue
80
+ if section:
81
+ data = data.get("tool", {}).get(section, {})
82
+ elif "jittest" in data and isinstance(data["jittest"], dict):
83
+ data = data["jittest"]
84
+ if data:
85
+ return dict(data)
86
+ return {}
87
+
88
+
89
+ def _from_env() -> dict:
90
+ out: dict = {}
91
+ for key, (env_name, caster) in _ENV.items():
92
+ raw = os.getenv(env_name)
93
+ if raw is None or raw == "":
94
+ continue
95
+ try:
96
+ out[key] = caster(raw)
97
+ except ValueError:
98
+ continue
99
+ return out
100
+
101
+
102
+ def load_config(repo: Path | str = ".", overrides: dict | None = None) -> Config:
103
+ repo = Path(repo)
104
+ values: dict = {}
105
+ values.update(_from_toml(repo))
106
+ values.update(_from_env())
107
+ for key, value in (overrides or {}).items():
108
+ if value is not None:
109
+ values[key] = value
110
+
111
+ valid = {f for f in Config.__dataclass_fields__}
112
+ extra_ignores = values.pop("ignore", None)
113
+ cfg = Config(**{k: v for k, v in values.items() if k in valid})
114
+
115
+ if extra_ignores:
116
+ cfg.ignore = list(DEFAULT_IGNORES) + list(extra_ignores)
117
+
118
+ ignore_file = repo / ".jittestignore"
119
+ if ignore_file.exists():
120
+ try:
121
+ lines = ignore_file.read_text(encoding="utf-8").splitlines()
122
+ except OSError:
123
+ lines = []
124
+ cfg.ignore += [ln.strip() for ln in lines
125
+ if ln.strip() and not ln.strip().startswith("#")]
126
+
127
+ return cfg