diff options
Diffstat (limited to 'app/backends/common.py')
| -rw-r--r-- | app/backends/common.py | 46 |
1 files changed, 36 insertions, 10 deletions
diff --git a/app/backends/common.py b/app/backends/common.py index 7974b58..20de432 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -9,6 +9,7 @@ TUI) so it can be reused without pulling curses into a non-interactive run. """ +import ast import os import re import shutil @@ -306,25 +307,50 @@ def update_config_value(key: str, value, """Set ``KEY`` to VALUE in app/converter/config.py and in memory. Only the value of the named assignment changes: indentation and any - trailing comment are preserved. Strings render double-quoted; other - literals (ints, booleans) render bare. After a successful write (or - when the file already holds VALUE) the new value is mirrored onto the - imported ``converter.config`` module, so a wizard's change takes - effect immediately instead of only after the next process start. - Returns True when the file now holds VALUE, False when it could not - be read or written (or KEY has no line in it). + trailing comment are preserved. Strings render as proper Python + literals via ``repr`` (quoting with bare double quotes would instead + produce invalid syntax — or silently change the value — whenever the + string itself contains a quote or a backslash, corrupting + config.py); other literals (ints, booleans) render bare. After a + successful write (or when the file already holds VALUE) the new + value is mirrored onto the imported ``converter.config`` module, so + a wizard's change takes effect immediately instead of only after + the next process start. Returns True when the file now holds VALUE, + False when it could not be read or written (or KEY has no line in + it, or the edit would not parse). """ path = Path(config_path) if config_path is not None else CONFIG_PATH - rendered = f'"{value}"' if isinstance(value, str) else str(value) + rendered = repr(value) if isinstance(value, str) else str(value) try: text = path.read_text(encoding="utf-8") match = re.search( - rf'(?m)^(\s*{re.escape(key)}\s*=\s*)("[^"]*"|\S+)(\s*(?:#.*)?)$', + rf'(?m)^(\s*{re.escape(key)}\s*=\s*)' + # Any valid Python string literal, single- or double-quoted + # (both spellings occur once repr() has written a value), + # else a bare literal token. + r'("[^"\\]*(?:\\.[^"\\]*)*"' + r"|'[^'\\]*(?:\\.[^'\\]*)*'" + r'|\S+)' + r'(\s*(?:#.*)?)$', text) if match is None: return False - if match.group(2) != rendered: + try: + # Semantic equality first: a file still holding the value in + # the old quoting style must not be rewritten (a no-op save + # stays a no-op), and the matched token may be any literal. + same = (match.group(2) == rendered + or ast.literal_eval(match.group(2)) == value) + except (ValueError, SyntaxError): + same = match.group(2) == rendered + if not same: text = text[:match.start(2)] + rendered + text[match.end(2):] + try: + # Never write a file that fails to import: a broken + # config.py breaks every later process start. + compile(text, str(path), "exec") + except (SyntaxError, ValueError): + return False path.write_text(text, encoding="utf-8") except OSError: return False |
