Merge-workspace review of PR #43 (codex R1-R4, claude R1/R2).
- R1: envfile.parse raises EnvFileError when the file exists but cannot be
read or decoded as a whole; `--shell` prints the cause and exits 1, so the
wrapper stops (rc 1, as before #26) instead of running on defaults, and
mqtt_common's entry points stop on the same error. A bad single line is
still a warning. The wrapper's message no longer claims a read failure
when the cause is elsewhere ("cannot load ... see the line above").
- R2: lib.sh mam_int spells out the ASCII blanks and digits instead of
[[:space:]]/[0-9], which follow the locale: U+2003 30 U+2003 was 30 in the
wrapper and invalid in the runner under en_US.UTF-8. The test oracle
_expected uses the same ASCII rule.
- R3: an integer flag given explicitly with an empty value is refused like
any other invalid value; only an absent flag keeps its default.
- R4: a line holding a NUL byte is skipped with a warning by both loaders;
before, the wrapper read "leftright" and the Python loader died.
- .mam.env.example states the format: no expansion, `export` and bash's own
variables skipped, a trailing `# ...` is part of the value, the environment
wins, an unreadable file is an error.
Tests: unreadable (0000) and invalid-UTF-8 files stop the wrapper and the
Python loader; a NUL line is skipped by both; Unicode blanks/digits are
invalid for mam_int, _positive_int, the wrapper and the runner under C and
en_US.UTF-8; explicit empty values for the five integer flags are refused.
All of them fail on b58edac.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
"""The one `.mam.env` parser (Issue #26).
|
|
|
|
Rules: blank lines and `#` comments are skipped; `KEY=VALUE` with the key and
|
|
the value stripped; one matching pair of surrounding quotes is removed; no
|
|
shell interpretation (`$(...)`, `${...}` and backticks stay literal). A key
|
|
must match ``^[A-Za-z_][A-Za-z0-9_]*$`` and not be one of bash's own variables
|
|
(RESERVED); a line that breaks either, or that has no `=`, is skipped with a
|
|
warning naming the line (never the value — the file holds secrets). A line
|
|
holding a NUL byte is skipped the same way (no environment can carry it).
|
|
Variables already in the environment win over the file. A file that exists
|
|
but cannot be read or decoded as a whole is an error (EnvFileError; the
|
|
``--shell`` command exits 1), not an empty configuration.
|
|
|
|
Stdlib only: the delegate-job wrapper runs this file by path
|
|
(``envfile.py --shell <path>``) and evals the ``export`` lines it prints, so
|
|
values travel over a pipe, never on a command line.
|
|
"""
|
|
import os
|
|
import re
|
|
import shlex
|
|
import sys
|
|
from typing import List, MutableMapping, Tuple
|
|
|
|
_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
|
|
# Bash's own variables (bash(1) "Shell Variables"). Assigning some runs code
|
|
# even from a quoted value (RANDOM/SECONDS/... are evaluated arithmetically,
|
|
# so RANDOM='a[$(cmd)]' executes cmd), others steer the shell (IFS, PS4,
|
|
# BASH_ENV, SHELLOPTS) or are readonly. A file key never assigns them - in
|
|
# either loader. tests/test_issue26_envfile.py checks this covers every
|
|
# variable a clean bash defines.
|
|
RESERVED = frozenset("""
|
|
BASH BASHOPTS BASHPID CDPATH CHILD_MAX COLUMNS COMPREPLY COPROC DIRSTACK EMACS ENV
|
|
EPOCHREALTIME EPOCHSECONDS EUID EXECIGNORE FCEDIT FIGNORE FUNCNAME FUNCNEST GLOBIGNORE
|
|
GLOBSORT GROUPS HISTCMD HISTCONTROL HISTFILE HISTFILESIZE HISTIGNORE HISTSIZE
|
|
HISTTIMEFORMAT HOSTFILE HOSTNAME HOSTTYPE IFS IGNOREEOF INPUTRC INSIDE_EMACS LINENO LINES
|
|
MACHTYPE MAIL MAILCHECK MAILPATH MAPFILE OLDPWD OPTARG OPTERR OPTIND OSTYPE PIPESTATUS
|
|
POSIXLY_CORRECT PPID PROMPT_COMMAND PROMPT_DIRTRIM PS0 PS1 PS2 PS3 PS4 PWD RANDOM REPLY
|
|
SECONDS SHELL SHELLOPTS SHLVL SRANDOM TIMEFORMAT TMOUT UID auto_resume histchars _
|
|
""".split())
|
|
RESERVED_PREFIXES = ("BASH_", "COMP_", "READLINE_")
|
|
|
|
|
|
class EnvFileError(RuntimeError):
|
|
"""The env file exists but cannot be read or decoded as a whole."""
|
|
|
|
|
|
def is_reserved(key: str) -> bool:
|
|
return key in RESERVED or key.startswith(RESERVED_PREFIXES)
|
|
|
|
|
|
def parse(path: str) -> Tuple[List[Tuple[str, str]], List[str]]:
|
|
"""Return ([(key, value)], [warning]) for the file at ``path``; raise
|
|
EnvFileError when the file cannot be read or decoded as a whole."""
|
|
pairs: List[Tuple[str, str]] = []
|
|
warnings: List[str] = []
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
lines = f.read().splitlines()
|
|
except (OSError, UnicodeDecodeError) as e:
|
|
raise EnvFileError(f"{path}: cannot read ({type(e).__name__})") from None
|
|
for n, raw in enumerate(lines, 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "\0" in line:
|
|
warnings.append(f"{path}:{n}: skipped (NUL byte)")
|
|
continue
|
|
key, sep, val = line.partition("=")
|
|
key = key.strip()
|
|
if not sep:
|
|
warnings.append(f"{path}:{n}: skipped (no '=')")
|
|
continue
|
|
if not _KEY.fullmatch(key):
|
|
warnings.append(f"{path}:{n}: skipped (invalid key)")
|
|
continue
|
|
if is_reserved(key):
|
|
warnings.append(f"{path}:{n}: skipped (reserved shell variable {key})")
|
|
continue
|
|
val = val.strip()
|
|
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
|
val = val[1:-1]
|
|
pairs.append((key, val))
|
|
return pairs, warnings
|
|
|
|
|
|
def apply(path: str, environ: MutableMapping[str, str]) -> Tuple[List[Tuple[str, str]], List[str]]:
|
|
"""Set each pair not already in ``environ``; return (the pairs set, warnings)."""
|
|
pairs, warnings = parse(path)
|
|
applied = []
|
|
for key, val in pairs:
|
|
if key not in environ:
|
|
environ[key] = val
|
|
applied.append((key, val))
|
|
return applied, warnings
|
|
|
|
|
|
def main(argv: List[str]) -> int:
|
|
if len(argv) != 2 or argv[0] != "--shell":
|
|
print("usage: envfile.py --shell <path>", file=sys.stderr)
|
|
return 2
|
|
path = argv[1]
|
|
if not os.path.isfile(path):
|
|
return 0
|
|
try:
|
|
applied, warnings = apply(path, dict(os.environ))
|
|
except EnvFileError as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
return 1
|
|
for w in warnings:
|
|
print(f"WARNING: {w}", file=sys.stderr)
|
|
for key, val in applied:
|
|
print(f"export {key}={shlex.quote(val)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|