"""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). A UTF-8 byte-order mark at the start of the file is dropped. Variables already in the environment win over the file, and a key given twice keeps its first value. 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 ``) 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: # utf-8-sig: a byte-order mark at the start is not part of the first key with open(path, "r", encoding="utf-8-sig") 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 ", 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:]))