mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
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>
59 lines
1.9 KiB
Python
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
|