Skip to content

Add environment variable support for select parameters #315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 132 additions & 21 deletions src/ansible_dev_environment/arg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import contextlib
import logging
import os
import sys
Expand All @@ -12,12 +13,24 @@
from pathlib import Path
from typing import TYPE_CHECKING

from .utils import str_to_bool


logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from typing import Any


ENVVAR_MAPPING: dict[str, str] = {
"ansi": "NO_COLOR",
"isolation_mode": "ADE_ISOLATION_MODE",
"seed": "ADE_SEED",
"uv": "ADE_UV",
"venv": "VIRTUAL_ENV",
"verbose": "ADE_VERBOSE",
}

try:
from ._version import version as __version__ # type: ignore[unused-ignore,import-not-found]
except ImportError: # pragma: no cover
Expand All @@ -39,6 +52,14 @@ def common_args(parser: ArgumentParser) -> None:
Args:
parser: The parser to add the arguments to
"""
parser.add_argument(
"--ansi",
action=argparse.BooleanOptionalAction,
default=True,
dest="ansi",
help="Enable or disable the use of ANSI codes for terminal hyperlink generation and color.",
)

parser.add_argument(
"--lf",
"--log-file <file>",
Expand All @@ -62,6 +83,13 @@ def common_args(parser: ArgumentParser) -> None:
default="true",
help="Append to log file.",
)
parser.add_argument(
"--uv",
dest="uv",
action=argparse.BooleanOptionalAction,
default=True,
help="Enable or disable the use of uv as the python package manager if found.",
)
parser.add_argument(
"-v",
"--verbose",
Expand Down Expand Up @@ -101,12 +129,9 @@ def parse() -> argparse.Namespace:

level1 = ArgumentParser(add_help=False)

venv_path = os.environ.get("VIRTUAL_ENV", None)
if not venv_path:
warnings.warn("No virtualenv found active, we will assume .venv", stacklevel=1)
level1.add_argument(
"--venv <directory>",
help="Target virtual environment.",
help="Target virtual environment. Created with 'ade install' if not found.",
default=".venv",
dest="venv",
)
Expand All @@ -119,15 +144,6 @@ def parse() -> argparse.Namespace:
action="store_true",
)

level1.add_argument(
"--na",
"--no-ansi",
action="store_true",
default=False,
dest="no_ansi",
help="Disable the use of ANSI codes for terminal hyperlink generation and color.",
)

level1.add_argument(
"--ssp",
"--system-site-packages",
Expand Down Expand Up @@ -195,7 +211,6 @@ def parse() -> argparse.Namespace:
)

install.add_argument(
# "-adt",
"--seed",
action=argparse.BooleanOptionalAction,
default=True,
Expand Down Expand Up @@ -229,13 +244,106 @@ def parse() -> argparse.Namespace:
_group_titles(subparser)

args = sys.argv[1:]
for i, v in enumerate(args):
for old in ("-adt", "--ansible-dev-tools"):
if v == old:
msg = f"Replace the deprecated {old} argument with --seed to avoid future execution failure."
logger.warning(msg)
args[i] = "--seed"
return parser.parse_args(args)
for param in ("--adt", "--ansible-dev-tools"):
with contextlib.suppress(ValueError):
args[args.index(param)] = "--seed"
err = f"The parameter '{param}' is deprecated, use 'ADE_SEED or '--seed' instead."
warnings.warn(err, DeprecationWarning, stacklevel=2)

if skip_uv := os.environ.get("SKIP_UV"):
os.environ["ADE_UV"] = skip_uv
err = "The environment variable 'SKIP_UV' is deprecated, use 'ADE_UV' or '--no-uv' instead."
warnings.warn(err, DeprecationWarning, stacklevel=2)

return apply_envvars(args=args, parser=parser)


def _get_action(actions: list[argparse.Action], dest: str) -> argparse.Action | None:
"""Get the action for a given destination.

Args:
actions: The list of actions
dest: The destination to get the action for

Returns:
The action for the given destination

"""
for action in actions:
if action.dest == dest:
return action
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for subparser in action.choices.values():
sub_action = _get_action(subparser._actions, dest) # noqa: SLF001
if sub_action is not None:
return sub_action

return None


def apply_envvars(args: list[str], parser: ArgumentParser) -> argparse.Namespace:
"""Apply the environment variables to the arguments.

Special handling exists NO_COLOR since it's value shouldn't matter.
Also account for a space in some option strings when checking for presence in sys.argv.

Args:
args: The cli arguments
parser: The argument parser

Returns:
The resulting namespace

Raises:
NotImplementedError: If the action type is not implemented
"""
cli_result = parser.parse_args(args)

for dest, envvar in ENVVAR_MAPPING.items():
if (action := _get_action(parser._actions, dest)) is None: # noqa: SLF001
err = f"Action for {dest} not found in parser"
raise NotImplementedError(err)

present = any(
option
for option in action.option_strings
if any(arg for arg in args if arg.startswith(option.split()[0]))
)
envvar_value = os.environ.get(envvar)

if present or envvar_value is None:
continue

final_value: bool | int | str | None = None
named_type = "not set"

with contextlib.suppress(ValueError):
if envvar == "NO_COLOR" and envvar_value != "":
final_value = False
elif isinstance(action, (argparse.BooleanOptionalAction, argparse._StoreTrueAction)): # noqa: SLF001
named_type = "boolean"
final_value = str_to_bool(envvar_value)
elif isinstance(action, argparse._CountAction) or action.type is int: # noqa: SLF001
named_type = "int"
final_value = int(envvar_value)
elif action.type is str or action.type is None:
named_type = "str"
final_value = envvar_value
else:
err = f"Action type {action.type} not implemented for envvar {envvar}"
raise NotImplementedError(err)

err = f"ade: error: environment variable {envvar}: invalid value: '{envvar_value}' "
if final_value is None:
err += f"could not convert to {named_type}\n"
parser.exit(1, err)
if action.choices and final_value not in action.choices:
err += f"(choose from {', '.join(map(repr, action.choices))})\n"
parser.exit(1, err)

setattr(cli_result, dest, final_value)

return cli_result


def _group_titles(parser: ArgumentParser) -> None:
Expand Down Expand Up @@ -266,6 +374,9 @@ def add_argument( # type: ignore[override]
"""
if "choices" in kwargs:
kwargs["help"] += f" (choices: {', '.join(kwargs['choices'])})"
if kwargs.get("dest") in ENVVAR_MAPPING:
envvar = ENVVAR_MAPPING[kwargs["dest"]]
kwargs["help"] += f" (env: {envvar})"
if "default" in kwargs and kwargs["default"] != "==SUPPRESS==":
kwargs["help"] += f" (default: {kwargs['default']})"
kwargs["help"] = kwargs["help"][0].upper() + kwargs["help"][1:]
Expand Down
8 changes: 6 additions & 2 deletions src/ansible_dev_environment/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ def init_output(self) -> None:
self.term_features = TermFeatures(color=False, links=False)
else:
self.term_features = TermFeatures(
color=False if os.environ.get("NO_COLOR") else not self.args.no_ansi,
links=not self.args.no_ansi,
color=self.args.ansi,
links=self.args.ansi,
)

self.output = Output(
Expand Down Expand Up @@ -98,6 +98,10 @@ def args_sanity(self) -> None:
err = "Editable can not be used with a requirements file."
self.output.critical(err)

self.output.debug("Arguments sanity check passed.")
for arg in vars(self.args):
self.output.debug(f"{arg}: {getattr(self.args, arg)}")

def isolation_check(self) -> bool:
"""Check the environment for isolation.

Expand Down
29 changes: 8 additions & 21 deletions src/ansible_dev_environment/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -49,7 +48,7 @@ def __init__(

def init(self) -> None:
"""Initialize the configuration."""
if self.args.venv:
if self.args.subcommand == "install":
self._create_venv = True

self._set_interpreter()
Expand All @@ -65,19 +64,8 @@ def cache_dir(self) -> Path:

@property
def venv(self) -> Path:
"""Return the virtual environment path.

Raises:
SystemExit: If the virtual environment cannot be found.
"""
if self.args.venv:
return Path(self.args.venv).expanduser().resolve()
venv_str = os.environ.get("VIRTUAL_ENV")
if venv_str:
return Path(venv_str).expanduser().resolve()
err = "Failed to find a virtual environment."
self._output.critical(err)
raise SystemExit(1) # pragma: no cover # critical exits
"""Return the virtual environment path."""
return Path(self.args.venv).expanduser().resolve()

@property
def venv_cache_dir(self) -> Path:
Expand All @@ -102,8 +90,8 @@ def uv_available(self) -> bool:
Returns:
True if uv is to be used.
"""
if int(os.environ.get("SKIP_UV", "0")):
self._output.debug("uv is disabled by SKIP_UV=1 in the environment.")
if self.args.uv is False:
self._output.debug("uv is disabled.")
return False

if not (uv_path := shutil.which("uv")):
Expand All @@ -112,7 +100,7 @@ def uv_available(self) -> bool:

self._output.debug(f"uv is available at {uv_path}")
self._output.info(
"uv is available and will be used instead of venv/pip. To disable that define SKIP_UV=1 in your environment.",
"uv is available and will be used instead of venv/pip. Disable with 'ADE_UV=0' or '--uv false'.",
)
return True

Expand Down Expand Up @@ -184,12 +172,11 @@ def _set_interpreter(
if not self.venv.exists():
if self._create_venv:
msg = f"Creating virtual environment: {self.venv}"
self._output.debug(msg)
command = f"{venv_cmd} {self.venv}"
msg = f"Creating virtual environment: {self.venv}"
if self.args.system_site_packages:
command = f"{command} --system-site-packages"
msg = f"Creating virtual environment with system site packages: {self.venv}"
msg += " with system site packages"
self._output.debug(msg)
try:
subprocess_run(
command=command,
Expand Down
21 changes: 21 additions & 0 deletions src/ansible_dev_environment/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,24 @@ def __exit__(
sys.stdout.write("\r")
# show the cursor
sys.stdout.write("\033[?25h")


def str_to_bool(value: str) -> bool | None:
"""Converts a string to a boolean based on common truthy and falsy values.

Args:
value: The string to convert.

Returns:
True for truthy values, False for falsy values, None for invalid values.
"""
truthy_values = {"true", "1", "yes", "y", "on"}
falsy_values = {"false", "0", "no", "n", "off"}

value_lower = value.strip().lower()

if value_lower in truthy_values:
return True
if value_lower in falsy_values:
return False
return None
31 changes: 31 additions & 0 deletions tests/integration/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,34 @@ def test_requirements(
assert "ansible.netcommon" not in captured.out
assert "ansible.scm" not in captured.out
assert "ansible.utils" in captured.out


def test_system_site_packages(
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Install non-local collection.

Args:
capsys: Capture stdout and stderr
tmp_path: Temporary directory
monkeypatch: Pytest monkeypatch
"""
monkeypatch.setattr(
"sys.argv",
[
"ade",
"install",
"ansible.utils",
f"--venv={tmp_path / 'venv'}",
"--system-site-packages",
"--no-ansi",
"-vvv",
],
)
with pytest.raises(SystemExit):
main()
captured = capsys.readouterr()
assert "with system site packages" in captured.out
assert "Installed collections include: ansible.utils" in captured.out
Loading