pythonfaster 1.8.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.
@@ -0,0 +1,425 @@
1
+ """Compilation pipeline: Python source -> Cython -> C -> shared object.
2
+
3
+ The original source is first checked for known CPython/Cython semantic
4
+ mismatches. At compilation levels 2 and 3, the AST transform pipeline is
5
+ attempted before Cython builds the extension. If transformation or the
6
+ transformed build fails, the pipeline retries with a verbatim ``.py`` copy;
7
+ all failures ultimately return control to the standard Python importer.
8
+
9
+ Intermediate files (.pyx/.c) live in the cache build area outside the
10
+ project and are deleted after a successful build, keeping only the .so.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from . import cache
20
+
21
+ _VERBOSE = os.environ.get("PYTHONFASTER_VERBOSE") == "1"
22
+ _KEEP_INTERMEDIATE = os.environ.get("PYTHONFASTER_KEEP_INTERMEDIATE") == "1"
23
+
24
+
25
+ class CompileError(RuntimeError):
26
+ """Raised when a module cannot be compiled. Caller falls back.
27
+
28
+ ``permanent`` marks failures that will reproduce for the same source
29
+ (cythonize / C build errors) -- only these belong on the skip list.
30
+ Transient failures (lock timeout, disk IO) must not be persisted.
31
+ """
32
+
33
+ def __init__(self, message: str, *, permanent: bool = True) -> None:
34
+ super().__init__(message)
35
+ self.permanent = permanent
36
+
37
+
38
+ def _directives(level: int) -> dict:
39
+ # Global directives deliberately remain conservative. Transform-specific
40
+ # directives are injected only when their local preconditions are proven.
41
+ d = {"language_level": "3"}
42
+ # Ensure source lines in tracebacks are str, not bytes
43
+ d["c_string_type"] = "unicode"
44
+ d["c_string_encoding"] = "utf-8"
45
+ return d
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Cython compatibility pre-check
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _cython_compat_check(source: str) -> str | None:
53
+ """Scan *source* for patterns known to cause runtime behaviour
54
+ differences between CPython and Cython-compiled code.
55
+
56
+ Returns a human-readable reason string when an incompatible pattern
57
+ is found, or ``None`` when the source appears safe to compile.
58
+
59
+ This check runs on the **original** user source, before any AST
60
+ transforms, and applies to *all* compilation levels (plain Cython
61
+ and transformed). When a pattern is detected the caller should
62
+ skip Cython compilation entirely and fall back to the ``.py``
63
+ import, because the issue is a Cython limitation — not a transform
64
+ artefact.
65
+ """
66
+ import ast as _ast
67
+
68
+ try:
69
+ tree = _ast.parse(source)
70
+ except SyntaxError:
71
+ return None # let Cython handle syntax issues
72
+
73
+ # Collect names of ALL class definitions (including nested/local classes)
74
+ # — only ``del ClassName.method`` where ClassName refers to a class is
75
+ # incompatible. ``del self.attr`` or ``del local_var.attr`` are fine.
76
+ class_names: set[str] = set()
77
+ for node in _ast.walk(tree):
78
+ if isinstance(node, _ast.ClassDef):
79
+ class_names.add(node.name)
80
+
81
+ # --- Pattern 1: ``del ClassName.method`` ---
82
+ # Cython does not support runtime deletion of class-level attributes
83
+ # / methods. In CPython this raises TypeError on the next access;
84
+ # in Cython it raises AttributeError instead.
85
+ for node in _ast.walk(tree):
86
+ if isinstance(node, _ast.Delete):
87
+ for target in node.targets:
88
+ if isinstance(target, _ast.Attribute) \
89
+ and isinstance(target.value, _ast.Name) \
90
+ and target.value.id in class_names \
91
+ and target.value.id != "self":
92
+ return (f"del {target.value.id}.{target.attr} — "
93
+ "Cython does not support runtime method deletion")
94
+
95
+ # --- Pattern 2: dict mutation during iteration ---
96
+ # CPython raises RuntimeError when a dict changes size during
97
+ # iteration; Cython does not detect this and silently continues.
98
+ for node in _ast.walk(tree):
99
+ if not isinstance(node, _ast.For):
100
+ continue
101
+ if not isinstance(node.iter, _ast.Name):
102
+ continue
103
+ iter_name = node.iter.id
104
+ for child in _ast.walk(node):
105
+ if isinstance(child, _ast.Delete):
106
+ for target in child.targets:
107
+ if isinstance(target, _ast.Subscript) \
108
+ and isinstance(target.value, _ast.Name) \
109
+ and target.value.id == iter_name:
110
+ return (f"dict mutation during iteration — "
111
+ "Cython does not detect dict size changes "
112
+ "in for-loops")
113
+
114
+ # --- Pattern 3: load_tests + DocFileSuite ---
115
+ # DocFileSuite resolves file paths relative to the module's __file__,
116
+ # which changes from .py to .so/.pyx after compilation.
117
+ has_load_tests = False
118
+ has_docfilesuite = False
119
+ for node in _ast.walk(tree):
120
+ if isinstance(node, _ast.FunctionDef) and node.name == "load_tests":
121
+ has_load_tests = True
122
+ if isinstance(node, _ast.Call):
123
+ fname = None
124
+ if isinstance(node.func, _ast.Attribute):
125
+ fname = node.func.attr
126
+ elif isinstance(node.func, _ast.Name):
127
+ fname = node.func.id
128
+ if fname == "DocFileSuite":
129
+ has_docfilesuite = True
130
+ if has_load_tests and has_docfilesuite:
131
+ return ("load_tests + DocFileSuite — file path resolution "
132
+ "differs between .py and .so")
133
+
134
+ # ============================================================
135
+ # Patterns 4-10: silent-wrong-result regressions measured on
136
+ # the CPython official test suite (3.2.4 AND 3.2.9 — Cython
137
+ # does not fix these). Each rule below maps to a concrete
138
+ # badcase; blocking here trades acceleration for correctness:
139
+ # the module falls back to the plain .py import.
140
+ #
141
+ # All 10 patterns are enabled by default (strict mode is the
142
+ # default since v1.8.1: compatibility wins over coverage).
143
+ # Set PYTHONFASTER_STRICT=0 to fall back to the lenient profile
144
+ # (patterns 4/7/9/10 disabled) if maximum acceleration is needed.
145
+ # ============================================================
146
+ strict = os.environ.get("PYTHONFASTER_STRICT", "1") != "0"
147
+
148
+ # --- Pattern 4: ``yield from`` (PEP 380 generator delegation) ---
149
+ # Cython-compiled generators propagate close()/throw() through
150
+ # the delegation chain differently (test_yield_from: the
151
+ # delegated exception never reaches the catcher).
152
+ if strict:
153
+ for node in _ast.walk(tree):
154
+ if isinstance(node, _ast.YieldFrom):
155
+ return ("yield from — Cython generator delegation changes "
156
+ "close()/throw() semantics")
157
+
158
+ # --- Pattern 5: ``import cmath`` ---
159
+ # cmath.phase() loses the sign of negative-zero arguments under
160
+ # Cython numeric conversion (test_cmath: phase(complex(0,-0.0))
161
+ # returns 0.0 instead of pi).
162
+ imported = set()
163
+ for node in _ast.walk(tree):
164
+ if isinstance(node, _ast.Import):
165
+ imported.update(a.name for a in node.names)
166
+ elif isinstance(node, _ast.ImportFrom) and node.module:
167
+ imported.add(node.module)
168
+ if "cmath" in imported:
169
+ return ("import cmath — negative-zero sign lost in Cython "
170
+ "complex conversion (cmath.phase wrong)")
171
+
172
+ # --- Pattern 6: operator.countOf / operator.indexOf ---
173
+ # Cython replaces these with C implementations whose semantics
174
+ # differ (test_operator: countOf returns 0 instead of 2).
175
+ for node in _ast.walk(tree):
176
+ if isinstance(node, _ast.Attribute) and node.attr in (
177
+ "countOf", "indexOf"):
178
+ value = node.value
179
+ if isinstance(value, _ast.Name) and value.id == "operator":
180
+ return (f"operator.{node.attr} — Cython C implementation "
181
+ "returns wrong results")
182
+ if isinstance(node, _ast.ImportFrom):
183
+ if node.module == "operator" and any(
184
+ a.name in ("countOf", "indexOf") for a in node.names):
185
+ return ("operator.countOf/indexOf — Cython C "
186
+ "implementation returns wrong results")
187
+
188
+ # --- Pattern 7: Fraction subclass / fractions import ---
189
+ # Mixed-arithmetic type dispatch is reordered under Cython
190
+ # (test_fractions: 10.0 != 9.0, DummyFloat isinstance fails,
191
+ # format() raises SystemError).
192
+ if strict:
193
+ for node in _ast.walk(tree):
194
+ if isinstance(node, _ast.ClassDef):
195
+ for base in node.bases:
196
+ if isinstance(base, _ast.Name) and base.id == "Fraction":
197
+ return ("class Fraction subclass — Cython numeric "
198
+ "protocol dispatch differs from CPython")
199
+ if isinstance(base, _ast.Attribute) and base.attr == "Fraction":
200
+ return ("class Fraction subclass — Cython numeric "
201
+ "protocol dispatch differs from CPython")
202
+ if "fractions" in imported:
203
+ return ("import fractions — Cython numeric protocol dispatch "
204
+ "differs from CPython")
205
+
206
+ # --- Pattern 8: ``__class_getitem__`` definition ---
207
+ # Metaclass __class_getitem__ dispatch breaks under Cython class
208
+ # restructuring (test_genericclass: TypeError 'NoneType' object
209
+ # is not callable).
210
+ for node in _ast.walk(tree):
211
+ if isinstance(node, _ast.FunctionDef) \
212
+ and node.name == "__class_getitem__":
213
+ return ("__class_getitem__ — Cython class restructuring "
214
+ "breaks metaclass dispatch")
215
+
216
+ # --- Pattern 9: ``__del__`` finalizer methods ---
217
+ # Cython cdef-style classes cannot accept runtime attribute
218
+ # assignment during finalization (test_finalization: cannot set
219
+ # the '_cleaning' attribute of immutable type).
220
+ if strict:
221
+ for node in _ast.walk(tree):
222
+ if isinstance(node, _ast.FunctionDef) and node.name == "__del__":
223
+ return ("__del__ — Cython immutable classes reject runtime "
224
+ "attribute assignment in finalizers")
225
+
226
+ # --- Pattern 10: ``__file__``-relative file access ---
227
+ # Compiled modules get a .so __file__; relative data files
228
+ # (certs, fixtures, source lookups) resolve differently
229
+ # (test_baseexception/test_urllib2_localnet/test_zipimport_support:
230
+ # FileNotFoundError).
231
+ if strict:
232
+ uses_dunder_file = any(
233
+ isinstance(n, _ast.Name) and n.id == "__file__"
234
+ for n in _ast.walk(tree)
235
+ )
236
+ opens_files = any(
237
+ isinstance(n, _ast.Call) and isinstance(n.func, _ast.Name)
238
+ and n.func.id in ("open", "load")
239
+ for n in _ast.walk(tree)
240
+ )
241
+ if uses_dunder_file and opens_files:
242
+ return ("__file__ + open() — data-file resolution differs "
243
+ "between .py and .so")
244
+
245
+ return None
246
+
247
+
248
+ def _read_source(path: Path) -> str:
249
+ """Read a Python source file with PEP 263 encoding detection.
250
+
251
+ Uses :func:`tokenize.open` which honours ``# -*- coding: xxx -*-``
252
+ declarations and BOM markers. Falls back to UTF-8 on any error.
253
+ """
254
+ try:
255
+ import tokenize as _tokenize
256
+ with _tokenize.open(str(path)) as fh:
257
+ return fh.read()
258
+ except Exception:
259
+ return path.read_text(encoding="utf-8", errors="replace")
260
+
261
+
262
+ def compile_module(
263
+ *,
264
+ module: str,
265
+ source_path: Path,
266
+ key: str,
267
+ level: int,
268
+ aggressive: bool = False,
269
+ ) -> Path:
270
+ """Compile *module* and return the cached .so path.
271
+
272
+ Raises :class:`CompileError` on any failure (caller records the skip
273
+ and falls back to the normal .py import).
274
+ """
275
+ target = cache.lib_dir(key) / cache.so_rel_path(module)
276
+ if target.exists():
277
+ return target
278
+
279
+ # --- Cython compatibility pre-check ---
280
+ # Scan the original source for patterns known to cause runtime
281
+ # behaviour differences between CPython and Cython. If found,
282
+ # skip compilation entirely and fall back to the .py import.
283
+ # This protects against silent semantic mismatches that the
284
+ # compile-time degradation ladder cannot detect.
285
+ source_text = _read_source(source_path)
286
+ compat_reason = _cython_compat_check(source_text)
287
+ if compat_reason is not None:
288
+ if _VERBOSE:
289
+ print(f"[pythonfaster] skipping {module}: {compat_reason}")
290
+ raise CompileError(
291
+ f"cython compatibility check failed: {compat_reason}",
292
+ permanent=True,
293
+ )
294
+
295
+ bdir = cache.build_dir(key)
296
+ lock = cache.FileLock(bdir)
297
+ try:
298
+ with lock:
299
+ if target.exists(): # another process won the race
300
+ return target
301
+ started = time.monotonic()
302
+ bdir.mkdir(parents=True, exist_ok=True)
303
+ applied: list[str] = []
304
+ try:
305
+ pyx_path, used_transform, applied = _prepare_pyx(
306
+ module, source_path, bdir, level, aggressive
307
+ )
308
+ try:
309
+ _cython_build(
310
+ module=module,
311
+ pyx_path=pyx_path,
312
+ out_dir=cache.lib_dir(key),
313
+ build_tmp=bdir / "tmp",
314
+ level=level,
315
+ )
316
+ except CompileError:
317
+ if not used_transform:
318
+ raise
319
+ # degradation ladder ②: strip injections, compile plain
320
+ applied = []
321
+ pyx_path = bdir / (module.rsplit(".", 1)[-1] + ".pyx")
322
+ shutil.copyfile(source_path, pyx_path)
323
+ _cython_build(
324
+ module=module,
325
+ pyx_path=pyx_path,
326
+ out_dir=cache.lib_dir(key),
327
+ build_tmp=bdir / "tmp",
328
+ level=level,
329
+ )
330
+ # keep the exact compiled source for traceback line fidelity
331
+ final_pyx = cache.lib_dir(key) / cache.pyx_rel_path(module)
332
+ final_pyx.parent.mkdir(parents=True, exist_ok=True)
333
+ shutil.copyfile(pyx_path, final_pyx)
334
+ finally:
335
+ if not _KEEP_INTERMEDIATE:
336
+ shutil.rmtree(bdir, ignore_errors=True)
337
+ if not target.exists():
338
+ raise CompileError(f"build finished but artifact missing: {target}")
339
+ cache.CacheIndex().record_success(
340
+ key, module, time.monotonic() - started, strategies=applied
341
+ )
342
+ return target
343
+ except CompileError:
344
+ raise
345
+ except TimeoutError as exc:
346
+ raise CompileError(str(exc), permanent=False) from exc
347
+ except Exception as exc: # cython/distutils raise many types
348
+ raise CompileError(f"{type(exc).__name__}: {exc}") from exc
349
+
350
+
351
+ def _prepare_pyx(module: str, source_path: Path, bdir: Path,
352
+ level: int, aggressive: bool = False):
353
+ """Produce the .pyx for *module* inside the build dir.
354
+
355
+ Runs the AST transform engine in memory (level >= 2) and writes the
356
+ transformed source; falls back to a verbatim copy when the transform
357
+ declines or fails. Returns (pyx_path, used_transform, applied_strategies).
358
+ The user project is never touched either way.
359
+ """
360
+ pyx_path = bdir / (module.rsplit(".", 1)[-1] + ".pyx")
361
+ if level >= 2:
362
+ try:
363
+ from .transform import transform_source
364
+
365
+ result = transform_source(
366
+ _read_source(source_path), level=level,
367
+ aggressive=aggressive,
368
+ )
369
+ if result.changed:
370
+ pyx_path.write_text(result.source, encoding="utf-8")
371
+ if _VERBOSE:
372
+ print(f"[pythonfaster] transform {module}: "
373
+ f"{', '.join(result.applied)}")
374
+ return pyx_path, True, list(result.applied)
375
+ except Exception as exc:
376
+ if _VERBOSE:
377
+ print(f"[pythonfaster] transform failed for {module}, "
378
+ f"plain compile: {exc!r}")
379
+ shutil.copyfile(source_path, pyx_path)
380
+ return pyx_path, False, []
381
+
382
+
383
+ def _cython_build(
384
+ *,
385
+ module: str,
386
+ pyx_path: Path,
387
+ out_dir: Path,
388
+ build_tmp: Path,
389
+ level: int,
390
+ ) -> None:
391
+ from Cython.Build import cythonize
392
+ from setuptools import Distribution, Extension
393
+ from setuptools.command.build_ext import build_ext
394
+
395
+ out_dir.mkdir(parents=True, exist_ok=True)
396
+ build_tmp.mkdir(parents=True, exist_ok=True)
397
+
398
+ if not _VERBOSE:
399
+ from distutils import log as distutils_log
400
+
401
+ distutils_log.set_threshold(distutils_log.ERROR)
402
+
403
+ extension = Extension(
404
+ module,
405
+ sources=[str(pyx_path)],
406
+ extra_compile_args=list(cache.COMPILE_FLAGS),
407
+ )
408
+ try:
409
+ extensions = cythonize(
410
+ [extension],
411
+ compiler_directives=_directives(level),
412
+ quiet=not _VERBOSE,
413
+ )
414
+ except Exception as exc:
415
+ raise CompileError(f"cythonize failed: {exc}") from exc
416
+
417
+ distribution = Distribution({"ext_modules": extensions})
418
+ command = build_ext(distribution)
419
+ command.ensure_finalized()
420
+ command.build_lib = str(out_dir)
421
+ command.build_temp = str(build_tmp)
422
+ try:
423
+ command.run()
424
+ except Exception as exc:
425
+ raise CompileError(f"C build failed: {exc}") from exc
pythonfaster/config.py ADDED
@@ -0,0 +1,165 @@
1
+ """Project root detection and pythonfaster configuration loading.
2
+
3
+ Configuration sources (later overrides earlier):
4
+ 1. ``pythonfaster.toml`` (``[pythonfaster]`` table) or ``pyproject.toml`` (``[tool.pythonfaster]``)
5
+ at the project root.
6
+ 2. Environment variables: ``PYTHONFASTER_DISABLE``, ``PYTHONFASTER_LEVEL``,
7
+ ``PYTHONFASTER_VERBOSE``, ``PYTHONFASTER_CACHE_DIR``.
8
+
9
+ Global activation state lives in ``~/.config/pythonfaster/config.json``.
10
+ When the file is absent (fresh install), pythonfaster defaults to **enabled**.
11
+ Use ``pythonfaster enable`` to also install the ``.pth`` file that triggers
12
+ auto-activation on every Python startup.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import fnmatch
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ ROOT_MARKERS = ("pythonfaster.toml", "pyproject.toml", ".git")
23
+
24
+ #: Modules matching any of these (relative to project root) are never compiled.
25
+ DEFAULT_EXCLUDES = (
26
+ "tests/*", "test/*", "*/tests/*", "*/test/*",
27
+ "conftest.py", "setup.py",
28
+ ".venv/*", "venv/*", "*/.venv/*", "*/venv/*",
29
+ "*/site-packages/*", "*/node_modules/*",
30
+ )
31
+
32
+ #: pythonfaster must never compile itself (it may live inside the project root
33
+ #: when running from a source checkout).
34
+ SELF_PACKAGE = "pythonfaster"
35
+
36
+
37
+ @dataclass
38
+ class Config:
39
+ """Effective configuration for one project."""
40
+
41
+ enabled: bool = True
42
+ level: int = 2
43
+ exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDES))
44
+ root: Path | None = None
45
+ #: High-risk template rewrites (S2 fannkuch, C2 nbody flat-buffer)
46
+ #: are disabled by default. Enable via env PYTHONFASTER_AGGRESSIVE=1
47
+ #: or [pythonfaster] aggressive = true in config.
48
+ aggressive: bool = False
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # project root detection
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def find_project_root(start: Path) -> Path | None:
56
+ """Walk upwards from *start* looking for a project marker."""
57
+ p = start.resolve()
58
+ if p.is_file():
59
+ p = p.parent
60
+ for directory in (p, *p.parents):
61
+ for marker in ROOT_MARKERS:
62
+ if (directory / marker).exists():
63
+ return directory
64
+ return None
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # per-project config
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def _read_toml(path: Path) -> dict:
72
+ try:
73
+ import tomllib # Python 3.11+
74
+ except ImportError: # pragma: no cover - Python 3.9/3.10
75
+ try:
76
+ import tomli as tomllib
77
+ except ImportError:
78
+ return {}
79
+ try:
80
+ with open(path, "rb") as fh:
81
+ return tomllib.load(fh)
82
+ except Exception:
83
+ return {}
84
+
85
+
86
+ def load_config(root: Path) -> Config:
87
+ """Load the effective config for the project at *root*."""
88
+ cfg = Config(root=root)
89
+
90
+ data = _read_toml(root / "pythonfaster.toml").get("pythonfaster", {})
91
+ if not data:
92
+ data = (
93
+ _read_toml(root / "pyproject.toml")
94
+ .get("tool", {})
95
+ .get("pythonfaster", {})
96
+ )
97
+
98
+ if isinstance(data.get("enable"), bool):
99
+ cfg.enabled = data["enable"]
100
+ if isinstance(data.get("level"), int) and data["level"] in (1, 2, 3):
101
+ cfg.level = data["level"]
102
+ if isinstance(data.get("exclude"), list):
103
+ cfg.exclude = list(DEFAULT_EXCLUDES) + [
104
+ str(p) for p in data["exclude"] if isinstance(p, str)
105
+ ]
106
+ if isinstance(data.get("aggressive"), bool):
107
+ cfg.aggressive = data["aggressive"]
108
+
109
+ # environment overrides
110
+ if os.environ.get("PYTHONFASTER_DISABLE") == "1":
111
+ cfg.enabled = False
112
+ level_env = os.environ.get("PYTHONFASTER_LEVEL")
113
+ if level_env in ("1", "2", "3"):
114
+ cfg.level = int(level_env)
115
+ if os.environ.get("PYTHONFASTER_AGGRESSIVE") == "1":
116
+ cfg.aggressive = True
117
+
118
+ return cfg
119
+
120
+
121
+ def is_excluded(rel_posix_path: str, patterns: list[str] | tuple[str, ...]) -> bool:
122
+ """Return True if *rel_posix_path* matches any exclude pattern."""
123
+ for pattern in patterns:
124
+ if fnmatch.fnmatch(rel_posix_path, pattern):
125
+ return True
126
+ return False
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # global activation state (written by `pythonfaster enable`)
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def state_dir() -> Path:
134
+ xdg = os.environ.get("XDG_CONFIG_HOME")
135
+ base = Path(xdg) if xdg else Path.home() / ".config"
136
+ return base / "pythonfaster"
137
+
138
+
139
+ def state_path() -> Path:
140
+ return state_dir() / "config.json"
141
+
142
+
143
+ def load_state() -> dict:
144
+ """Load global activation state.
145
+
146
+ Defaults to **enabled** when no state file exists yet (fresh install).
147
+ ``pythonfaster disable`` writes ``{"enabled": false}`` to explicitly turn off.
148
+ """
149
+ try:
150
+ with open(state_path(), "r", encoding="utf-8") as fh:
151
+ state = json.load(fh)
152
+ if isinstance(state, dict):
153
+ return state
154
+ except Exception:
155
+ pass
156
+ return {"enabled": True, "mode": "all"}
157
+
158
+
159
+ def save_state(state: dict) -> None:
160
+ d = state_dir()
161
+ d.mkdir(parents=True, exist_ok=True)
162
+ tmp = d / "config.json.tmp"
163
+ with open(tmp, "w", encoding="utf-8") as fh:
164
+ json.dump(state, fh, indent=2)
165
+ os.replace(tmp, state_path())