Files
computer-playbook/utils/env/writer.py
kevinveenbirkenbachandClaude Opus 4.8 a097bf36c5 fix(env): write .env atomically via a temp file and rename
write_dotenv truncated .env in place, so a concurrent make dotenv (parallel
make test regenerates the shared file once per target) could expose a torn
half-written file to a sibling reader. Write a per-pid sibling temp with the
normal umask-respecting mode, then atomically rename it over .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 03:49:25 +02:00

59 lines
1.9 KiB
Python

"""Serialise an EnvBuilder to .env on disk.
The output format is the docker-compose `.env`-subset that is also
sourceable from bash (via `set -a; source .env; set +a`). Values that
contain only "safe" characters are emitted bare; everything else is
double-quoted with backslash-escaping of `\\` and `"`.
Each key is preceded by its documentation comment when the builder
carries one, mirroring the convention used in `default.env`.
"""
from __future__ import annotations
import os
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from utils.env.builder import EnvBuilder
_SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9_/.@:%+=-]+$")
def quote_for_dotenv(value: str) -> str:
if value != "" and _SAFE_VALUE_RE.match(value):
return value
if "\n" in value or "\r" in value:
raise ValueError(
"Multi-line env values cannot be written to .env "
"(bash `source` and docker-compose `--env-file` both fail "
"to round-trip them). Collapse to a single line at the "
f"producer. Offending value: {value!r}"
)
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def write_dotenv(eb: EnvBuilder, dest: Path) -> None:
lines: list[str] = [
"# Generated by `make dotenv` (python -m cli.meta.env) -- do NOT edit.",
"# Re-run after changes to default.env or after the runtime context",
"# shifts (distro, CI vs local, fresh checkout).",
]
for key in sorted(eb.values):
lines.append("")
comment = eb.comments.get(key, "").strip()
if comment:
lines.append(f"# {comment}")
lines.append(f"{key}={quote_for_dotenv(eb.values[key])}")
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
try:
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
tmp.replace(dest)
except BaseException:
tmp.unlink(missing_ok=True)
raise