Skip to content
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

Run in an un-activated virtual environment, fixes #1507 #1866

Closed
wants to merge 2 commits into from
Closed
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
10 changes: 2 additions & 8 deletions src/ansiblelint/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Store configuration options as a singleton."""
import os
import re
import subprocess
import sys
from argparse import Namespace
from functools import lru_cache
Expand All @@ -10,6 +9,7 @@
from packaging.version import Version

from ansiblelint.constants import ANSIBLE_MISSING_RC
from ansiblelint.venv_utils import run_ansible_version

DEFAULT_KINDS = [
# Do not sort this list, order matters.
Expand Down Expand Up @@ -143,13 +143,7 @@ def ansible_version(version: str = "") -> Version:
to Version object in order to make it usable in comparisons.
"""
if not version:
proc = subprocess.run(
["ansible", "--version"],
universal_newlines=True,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
proc = run_ansible_version()
if proc.returncode == 0:
version, error = parse_ansible_version(proc.stdout)
if error is not None:
Expand Down
16 changes: 6 additions & 10 deletions src/ansiblelint/prerun.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
INVALID_PREREQUISITES_RC,
)
from ansiblelint.loaders import yaml_from_file
from ansiblelint.venv_utils import add_venv_path, run_ansible_version

_logger = logging.getLogger(__name__)

Expand All @@ -45,12 +46,7 @@ def _get_ver_err() -> Tuple[str, str]:
err = ""
failed = False
ver = ""
result = subprocess.run(
args=["ansible", "--version"],
stdout=subprocess.PIPE,
universal_newlines=True,
check=False,
)
result = run_ansible_version()
if result.returncode != 0:
return (
ver,
Expand Down Expand Up @@ -103,7 +99,7 @@ def install_collection(collection: str, destination: Optional[str] = None) -> No
Can accept version constraints like 'foo.bar:>=1.2.3'
"""
cmd = [
"ansible-galaxy",
add_venv_path("ansible-galaxy"),
"collection",
"install",
"--force", # required for ansible 2.9
Expand Down Expand Up @@ -138,7 +134,7 @@ def install_requirements(requirement: str) -> None:
return

cmd = [
"ansible-galaxy",
add_venv_path("ansible-galaxy"),
"role",
"install",
"--force", # required for ansible 2.9
Expand All @@ -164,7 +160,7 @@ def install_requirements(requirement: str) -> None:
if "collections" in yaml_from_file(requirement):

cmd = [
"ansible-galaxy",
add_venv_path("ansible-galaxy"),
"collection",
"install",
"--force", # required for ansible 2.9
Expand Down Expand Up @@ -432,7 +428,7 @@ def ansible_config_get(key: str, kind: Type[Any] = str) -> Union[str, List[str],
env.pop(colpathvar)

config = subprocess.check_output(
["ansible-config", "dump"], universal_newlines=True, env=env
[add_venv_path("ansible-config"), "dump"], universal_newlines=True, env=env
)

if kind == str:
Expand Down
3 changes: 2 additions & 1 deletion src/ansiblelint/rules/AnsibleSyntaxCheckRule.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ansiblelint.logger import timed_info
from ansiblelint.rules import AnsibleLintRule
from ansiblelint.text import strip_ansi_escape
from ansiblelint.venv_utils import add_venv_path

DESCRIPTION = """\
Running ``ansible-playbook --syntax-check ...`` failed.
Expand Down Expand Up @@ -57,7 +58,7 @@ def _get_ansible_syntax_check_matches(lintable: Lintable) -> List[MatchError]:
if options.extra_vars:
extra_vars_cmd = ["--extra-vars", json.dumps(options.extra_vars)]
cmd = [
"ansible-playbook",
add_venv_path("ansible-playbook"),
"--syntax-check",
*extra_vars_cmd,
str(lintable.path),
Expand Down
54 changes: 54 additions & 0 deletions src/ansiblelint/venv_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Utilities supporting virtual environments."""

# We run various ansible command line tools (and detect the ansible version
# by executing `ansible --version`).
# The executable we want to run may not be in $PATH, especially if we
# are running in a virtual environment that's not been activated.

import os
import pathlib
import subprocess
import sys
from functools import lru_cache
from typing import TYPE_CHECKING, Any, List

if TYPE_CHECKING:
# https://github.com/PyCQA/pylint/issues/3240
# pylint: disable=unsubscriptable-object
CompletedProcess = subprocess.CompletedProcess[Any]
else:
CompletedProcess = subprocess.CompletedProcess


def _normalize(path: str) -> pathlib.Path:
# pathlib.Path takes care of MS Windows case-insensitivity
return pathlib.Path(os.path.realpath(os.path.normpath(os.path.expanduser(path))))


def _get_search_path() -> List[pathlib.Path]:
return [_normalize(path) for path in os.get_exec_path()]


@lru_cache()
def _get_venv_path() -> str:
venv_dir = _normalize(os.path.dirname(sys.executable))
search_path = _get_search_path()
if venv_dir in search_path or search_path and search_path[0] == pathlib.Path("."):
return ""
return str(venv_dir)


def add_venv_path(cmd: str) -> str:
"""Prepend the path to the venv, if any, to the supplied command."""
return os.path.join(_get_venv_path(), cmd)


def run_ansible_version() -> CompletedProcess:
"""Run `ansible --version` and return the result."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will fix the failing docs build:

Suggested change
"""Run `ansible --version` and return the result."""
"""Run ``ansible --version`` and return the result."""

return subprocess.run(
args=[add_venv_path("ansible"), "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
check=False,
)
96 changes: 88 additions & 8 deletions test/test_prerun.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Tests related to prerun part of the linter."""
import os
import re
import subprocess
from typing import List
import sys
from typing import Callable, List

import pytest
from _pytest.monkeypatch import MonkeyPatch
Expand All @@ -11,35 +13,113 @@
from ansiblelint.constants import INVALID_PREREQUISITES_RC
from ansiblelint.testing import run_ansible_lint

EXECDIR = os.path.dirname(sys.executable)

Deactivator = Callable[[bool], str]
DeactivateVenv = Callable[[], Deactivator]


@pytest.fixture
def deactivate_venv(monkeypatch: MonkeyPatch) -> Deactivator:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the use of monkeypatch but returning a callable seems like an overengineering: since all the tests call it at the beginning of their execution, this fixture could do this when injected.

Assuming you're worried/don't know how this works with parametrize — it's quite simple: just inject a request fixture here and access the parameter via request.param. And on the test function side, add indirect=('deactivate_venv', ) argument into the parametrize() decorator. That's it!

"""Deliver a function to deactivate the current venv, if any, if requested, by removing it's bin dir from $PATH."""

def deactiveator(deactivate: bool) -> str:
if deactivate:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pro tip: this clause can be turned into a "guard expression" by flipping the check. Use if not request.param: return "" to exit early and this will allow dedenting a whole bunch of code making the control flow look more linear.

search_path = os.get_exec_path()
try:
pos = search_path.index(EXECDIR)
except ValueError:
return ""
del search_path[pos]
monkeypatch.setenv("PATH", ";".join(search_path))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this meant to be

Suggested change
monkeypatch.setenv("PATH", ";".join(search_path))
monkeypatch.setenv("PATH", os.path.sep.join(search_path))

*nix separator is : normally while windows uses ;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@webknjaz And you know very well that ansible-lint does not run on Windows.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know but it's still good practice not to hardcode things regardless.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And your suggestion is misleading as os.path.sep is / while os.pathsep is :.-- not sure if just using the : not not just be much easier to read.

A simple dot... totally different outcome.

return EXECDIR
return ""

return deactiveator


# https://github.com/box/flaky/issues/170
@flaky(max_runs=3) # type: ignore
def test_prerun_reqs_v1() -> None:
@pytest.mark.parametrize(
("deactivate"),
(
(False),
(True),
),
ids=("venv activated", "venv deactivated"),
)
def test_prerun_reqs_v1(deactivate: bool, deactivate_venv: Deactivator) -> None:
"""Checks that the linter can auto-install requirements v1 when found."""
cwd = os.path.realpath(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "..", "examples", "reqs_v1"
)
)
execdir = deactivate_venv(deactivate)
result = run_ansible_lint("-v", ".", cwd=cwd)
assert "Running ansible-galaxy role install" in result.stderr, result.stderr

ansible_galaxy = os.path.join(execdir, "ansible-galaxy")
assert f"Running {ansible_galaxy} role install" in result.stderr, result.stderr
assert (
"Running ansible-galaxy collection install" not in result.stderr
f"Running {ansible_galaxy} collection install" not in result.stderr
), result.stderr
assert result.returncode == 0, result


@flaky(max_runs=3) # type: ignore
def test_prerun_reqs_v2() -> None:
@pytest.mark.parametrize(
("executable"), # Run with `python -m` or the venv command: issue #1507
(
(None),
(os.path.join(os.path.dirname(sys.executable), "ansible-lint")),
),
ids=(
"run as ansiblelint python module",
"run via path to the virtualenv's ansible-lint command",
),
)
@pytest.mark.parametrize(
("deactivate"),
(
(False),
(True),
),
ids=("venv activated", "venv deactivated"),
)
def test_prerun_reqs_v2(
executable: str, deactivate: bool, deactivate_venv: Deactivator
) -> None:
"""Checks that the linter can auto-install requirements v2 when found."""
cwd = os.path.realpath(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "..", "examples", "reqs_v2"
)
)
result = run_ansible_lint("-v", ".", cwd=cwd)
assert "Running ansible-galaxy role install" in result.stderr, result.stderr
assert "Running ansible-galaxy collection install" in result.stderr, result.stderr
execdir = deactivate_venv(deactivate)
result = run_ansible_lint("-v", ".", cwd=cwd, executable=executable)

# These first 2 assertations are written to pass both with the test
# as-it-is before the fix of issue #1507 and after the fix.
# (See: https://github.com/ansible-community/ansible-lint/issues/1507)
# But these assertations are written as much like the "before" version
# of this test as possible, to demonstrate that the assertion
# itself is not substantively changed by the fix.
# When these asserations fail, without the #1507 patch,
# it demonstrates that ansible-lint does not run in a virtual environment
# unless that environment is activated.
assert re.search(
"Running [^ ]*ansible-galaxy role install", result.stderr
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not do a more precise comparison with

Suggested change
"Running [^ ]*ansible-galaxy role install", result.stderr
f"Running {execdir}/ansible-galaxy role install", result.stderr

?

), result.stderr
assert re.search(
"Running [^ ]*ansible-galaxy collection install", result.stderr
), result.stderr

# Commands are given with or without paths, as expected.
ansible_galaxy = os.path.join(execdir, "ansible-galaxy")
assert f"Running {ansible_galaxy} role install" in result.stderr, result.stderr
assert (
f"Running {ansible_galaxy} collection install" in result.stderr
), result.stderr
assert result.returncode == 0, result


Expand Down