"""Load Azure DevOps settings from an env file into ``os.environ``.
``AzureDevOpsClient.from_env`` reads only process environment variables. On
Windows that means ``setx``/``$env:`` juggling, and an MCP server launched by
Claude Desktop or Claude Code never sees variables set in a PowerShell session.
An env file next to the user's profile works the same on every OS and every
launcher.
Only the **first existing** file of these is used:
1. ``$DEVOPS_UTILS_ENV_FILE`` — an explicit path.
2. ``./.env.devops-utils`` — project scope (keep it out of git).
3. ``~/.devops-utils.env`` — user scope (``%USERPROFILE%`` on Windows).
Files are never merged, and a variable already in the process environment
always wins. Both rules exist so a cloned repo's project file cannot pair its
own ``AZURE_DEVOPS_ORG_URL`` with the token from your user file or session and
ship that token to a server of its choosing. For the same reason an org URL
from a file is ignored unless the token in effect comes from that same file,
and only ``AZURE_DEVOPS_*`` keys are ever loaded.
The format is the usual ``KEY=VALUE`` subset: ``#`` comments, blank lines, an
optional leading ``export``, and optional matching single/double quotes. A UTF-8
BOM and CRLF line endings — what Notepad writes — are tolerated. Nothing is
expanded or executed.
"""
from __future__ import annotations
import os
from pathlib import Path
#: Env var naming an explicit env file to load first.
ENV_FILE_VAR = "DEVOPS_UTILS_ENV_FILE"
#: Project-scope env file name (resolved against the current directory).
PROJECT_ENV_FILE = ".env.devops-utils"
#: User-scope env file name (resolved against the home directory).
USER_ENV_FILE = ".devops-utils.env"
#: Only keys with this prefix are loaded from a file.
KEY_PREFIX = "AZURE_DEVOPS_"
_ORG_URL = "AZURE_DEVOPS_ORG_URL"
_TOKEN = "AZURE_DEVOPS_TOKEN" # nosec B105 - env var name, not a secret
[docs]
def env_file_candidates() -> list[Path]:
"""Return the env files to try, highest precedence first."""
candidates: list[Path] = []
explicit = os.environ.get(ENV_FILE_VAR, "").strip()
if explicit:
candidates.append(Path(explicit).expanduser())
candidates.append(Path.cwd() / PROJECT_ENV_FILE)
candidates.append(Path.home() / USER_ENV_FILE)
return candidates
[docs]
def parse_env_text(text: str) -> dict[str, str]:
"""Parse ``KEY=VALUE`` lines; malformed lines are skipped, not fatal."""
values: dict[str, str] = {}
for raw in text.lstrip("").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :].lstrip()
key, sep, value = line.partition("=")
key = key.strip()
if not sep or not key.isidentifier():
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
values[key] = value
return values
[docs]
def load_env_files(candidates: list[Path] | None = None) -> Path | None:
"""Fill unset ``AZURE_DEVOPS_*`` variables from the first existing env file.
Never overrides a variable that is already set (even to an empty string —
an explicit empty value is the user's choice). Unreadable files are skipped
so a stray permission problem can't stop the CLI from starting.
Returns:
The file that was used, or ``None``.
"""
for path in env_file_candidates() if candidates is None else candidates:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
values = {
key: value
for key, value in parse_env_text(text).items()
if key.startswith(KEY_PREFIX)
}
# The org URL only travels with the token that will actually be used.
if _TOKEN not in values or _TOKEN in os.environ:
values.pop(_ORG_URL, None)
for key, value in values.items():
os.environ.setdefault(key, value)
return path
return None