"""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). Variables already in the environment win over the file. 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_") 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``.""" 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: return pairs, [f"{path}: cannot read ({type(e).__name__})"] for n, raw in enumerate(lines, 1): line = raw.strip() if not line or line.startswith("#"): 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 applied, warnings = apply(path, dict(os.environ)) 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:]))