Skip to content

Commit 7ebd45b

Browse files
authored
Fix optional dependency installation (#322)
* Fix optional dependency installation * Additional test * Type fix * Update join
1 parent d84f7d8 commit 7ebd45b

File tree

7 files changed

+109
-51
lines changed

7 files changed

+109
-51
lines changed

.config/constraints.txt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
# pip-compile --all-extras --no-annotate --output-file=.config/constraints.txt --strip-extras .config/requirements.in pyproject.toml
66
#
77
ansible-builder==3.1.0
8+
argcomplete==3.6.2
89
astroid==3.3.9
910
attrs==25.3.0
1011
babel==2.17.0
1112
backrefs==5.8
12-
beautifulsoup4==4.13.3
13+
beautifulsoup4==4.13.4
1314
bindep==2.13.0
1415
build==1.2.2.post1
1516
cachetools==5.5.2
@@ -26,7 +27,7 @@ coverage==7.8.0
2627
csscompressor==0.9.5
2728
cssselect2==0.8.0
2829
defusedxml==0.7.1
29-
dill==0.3.9
30+
dill==0.4.0
3031
distlib==0.3.9
3132
distro==1.9.0
3233
dnspython==2.7.0
@@ -60,7 +61,7 @@ mkdocs-gen-files==0.5.0
6061
mkdocs-get-deps==0.2.0
6162
mkdocs-htmlproofer-plugin==1.3.0
6263
mkdocs-macros-plugin==1.3.7
63-
mkdocs-material==9.6.11
64+
mkdocs-material==9.6.12
6465
mkdocs-material-extensions==1.3.1
6566
mkdocs-minify-plugin==0.8.0
6667
mkdocs-monorepo-plugin==1.1.0
@@ -80,7 +81,7 @@ platformdirs==4.3.7
8081
pluggy==1.5.0
8182
pre-commit==4.2.0
8283
pycparser==2.22
83-
pydoclint==0.6.5
84+
pydoclint==0.6.6
8485
pygments==2.19.1
8586
pylint==3.3.6
8687
pymdown-extensions==10.14.3

.config/pydoclint-baseline.txt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,3 @@ tests/unit/test_treemaker.py
55
DOC101: Method `SafeEnvBuilder.__init__`: Docstring contains fewer arguments than in function signature.
66
DOC103: Method `SafeEnvBuilder.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [clear: bool, prompt: str | None, symlinks: bool, system_site_packages: bool, upgrade: bool, upgrade_deps: bool, with_pip: bool].
77
--------------------
8-
tests/unit/test_utils.py
9-
DOC502: Function `test_builder_found` has a "Raises" section in the docstring, but there are not "raise" statements in the body
10-
--------------------

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ repos:
5353
- id: tox-ini-fmt
5454

5555
- repo: https://github.com/astral-sh/ruff-pre-commit
56-
rev: v0.11.4
56+
rev: v0.11.5
5757
hooks:
5858
- id: ruff
5959
entry: sh -c 'ruff check --fix --force-exclude && ruff format --force-exclude'
@@ -66,7 +66,7 @@ repos:
6666
name: Spell check with cspell
6767

6868
- repo: https://github.com/jsh9/pydoclint
69-
rev: "0.6.5"
69+
rev: "0.6.6"
7070
hooks:
7171
- id: pydoclint
7272
# This allows automatic reduction of the baseline file when needed.

src/ansible_dev_environment/subcommands/installer.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ansible_dev_environment.utils import (
1717
builder_introspect,
1818
collections_from_requirements,
19+
opt_deps_to_files,
1920
oxford_join,
2021
subprocess_run,
2122
)
@@ -77,6 +78,8 @@ def run(self) -> None:
7778

7879
if self._config.args.requirement or self._config.args.cpi:
7980
self._install_galaxy_requirements()
81+
82+
opt_dep_paths = None
8083
if self._config.args.collection_specifier:
8184
collections = [
8285
parse_collection_request(
@@ -98,7 +101,15 @@ def run(self) -> None:
98101
self._output.critical(msg)
99102
self._install_galaxy_collections(collections=distant_collections)
100103

101-
builder_introspect(config=self._config, output=self._output)
104+
opt_dep_paths = [
105+
path
106+
for collection in collections
107+
for path in opt_deps_to_files(collection, self._output)
108+
]
109+
msg = f"Optional dependencies found: {oxford_join(opt_dep_paths)}"
110+
self._output.info(msg)
111+
112+
builder_introspect(config=self._config, opt_dep_paths=opt_dep_paths, output=self._output)
102113
self._pip_install()
103114
Checker(config=self._config, output=self._output).system_deps()
104115

src/ansible_dev_environment/utils.py

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@
1818

1919

2020
if TYPE_CHECKING:
21+
from collections.abc import Sequence
2122
from pathlib import Path
2223
from types import TracebackType
2324

25+
from .collection import Collection
2426
from .config import Config
2527
from .output import Output
2628

29+
2730
from typing import Any
2831

2932

@@ -156,50 +159,60 @@ def subprocess_run( # pylint: disable=too-many-positional-arguments
156159
)
157160

158161

159-
def oxford_join(words: list[str]) -> str:
162+
def oxford_join(words: Sequence[str | Path]) -> str:
160163
"""Join a list of words with commas and an oxford comma.
161164
162165
Args:
163166
words: A list of words to join
164167
Returns:
165168
A string of words joined with commas and an oxford comma
166169
"""
167-
words.sort()
168-
if not words:
169-
return ""
170-
if len(words) == 1:
171-
return words[0]
172-
if len(words) == 2: # noqa: PLR2004
173-
return " and ".join(words)
174-
return ", ".join(words[:-1]) + ", and " + words[-1]
175-
176-
177-
def opt_deps_to_files(collection_path: Path, dep_str: str) -> list[Path]:
170+
_words = sorted([str(word) for word in words])
171+
match _words:
172+
case [word]:
173+
return word
174+
case [word_one, word_two]:
175+
return f"{word_one} and {word_two}"
176+
case [*first_words, last_word]:
177+
return f"{', '.join(first_words)}, and {last_word}"
178+
case _:
179+
return ""
180+
181+
182+
def opt_deps_to_files(collection: Collection, output: Output) -> list[Path]:
178183
"""Convert a string of optional dependencies to a list of files.
179184
180185
Args:
181-
collection_path: The path to the collection
182-
dep_str: A string of optional dependencies
186+
collection: The collection object
187+
output: The output object
183188
Returns:
184-
A list of files
189+
A list of paths
185190
"""
186-
deps = dep_str.split(",")
191+
if not collection.opt_deps:
192+
msg = "No optional dependencies specified."
193+
output.debug(msg)
194+
return []
195+
196+
deps = collection.opt_deps.split(",")
187197
files = []
188198
for dep in deps:
189199
_dep = dep.strip()
190-
variant1 = collection_path / f"{_dep}-requirements.txt"
200+
variant1 = collection.path / f"{_dep}-requirements.txt"
191201
if variant1.exists():
192202
files.append(variant1)
193203
continue
194-
variant2 = collection_path / f"requirements-{_dep}.txt"
204+
variant2 = collection.path / f"requirements-{_dep}.txt"
195205
if variant2.exists():
196206
files.append(variant2)
197207
continue
198208
msg = (
199209
f"Failed to find optional dependency file for '{_dep}'."
200210
f" Checked for '{variant1.name}' and '{variant2.name}'. Skipping."
201211
)
202-
logger.error(msg)
212+
output.error(msg)
213+
count = len(files)
214+
msg = f"Found {count} optional dependency file{'s' * (count > 1)}. {oxford_join(files)}"
215+
output.debug(msg)
203216
return files
204217

205218

@@ -277,7 +290,11 @@ def collect_manifests( # noqa: C901
277290
return sort_dict(collections)
278291

279292

280-
def builder_introspect(config: Config, output: Output) -> None:
293+
def builder_introspect(
294+
config: Config,
295+
output: Output,
296+
opt_dep_paths: list[Path] | None = None,
297+
) -> None:
281298
"""Introspect a collection.
282299
283300
Use the sys executable to run builder, since it is a direct dependency
@@ -286,29 +303,20 @@ def builder_introspect(config: Config, output: Output) -> None:
286303
Args:
287304
config: The configuration object.
288305
output: The output object.
306+
opt_dep_paths: A list of optional dependency paths.
289307
"""
290308
command = (
291309
f"{sys.executable} -m ansible_builder introspect {config.site_pkg_path}"
292310
f" --write-pip {config.discovered_python_reqs}"
293311
f" --write-bindep {config.discovered_bindep_reqs}"
294312
" --sanitize"
295313
)
296-
if (
297-
hasattr(config.args, "collection_specifier")
298-
and hasattr(config, "collection")
299-
and config.collection.opt_deps
300-
and config.collection.path
301-
):
302-
dep_paths = opt_deps_to_files(
303-
collection_path=config.collection.path,
304-
dep_str=config.collection.opt_deps,
305-
)
306-
for dep_path in dep_paths:
307-
command += f" --user-pip {dep_path}"
314+
for opt_dep_path in opt_dep_paths or []:
315+
command += f" --user-pip {opt_dep_path}"
308316
msg = f"Writing discovered python requirements to: {config.discovered_python_reqs}"
309-
logger.debug(msg)
317+
output.debug(msg)
310318
msg = f"Writing discovered system requirements to: {config.discovered_bindep_reqs}"
311-
logger.debug(msg)
319+
output.debug(msg)
312320
work = "Persisting requirements to file system"
313321
try:
314322
subprocess_run(

tests/integration/test_basic.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def test_venv(
2626
) -> None:
2727
"""Basic smoke test.
2828
29+
Test for a local collection install with optional dependencies
30+
2931
Args:
3032
capsys: Capture stdout and stderr
3133
tmp_path: Temporary directory
@@ -56,17 +58,19 @@ def test_venv(
5658
[
5759
"ade",
5860
"install",
59-
str(tmp_path / "cisco.nxos"),
61+
str(tmp_path / "cisco.nxos[test]"),
6062
"--venv=venv",
6163
"--no-ansi",
64+
"-vvv",
6265
],
6366
)
6467
with pytest.raises(SystemExit):
6568
main()
66-
string = "Installed collections include: ansible.netcommon, ansible.utils,"
67-
captured = capsys.readouterr()
6869

69-
assert string in captured.out
70+
captured = capsys.readouterr()
71+
assert "Installed collections include: ansible.netcommon, ansible.utils," in captured.out
72+
assert "Optional dependencies found" in captured.out
73+
assert "'pytest-xdist # from collection user'" in captured.out
7074

7175
monkeypatch.setattr(
7276
"sys.argv",

tests/unit/test_utils.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
)
1414
from ansible_dev_environment.config import Config
1515
from ansible_dev_environment.output import Output
16-
from ansible_dev_environment.utils import TermFeatures, builder_introspect, str_to_bool
16+
from ansible_dev_environment.utils import (
17+
TermFeatures,
18+
builder_introspect,
19+
opt_deps_to_files,
20+
str_to_bool,
21+
)
1722

1823

1924
term_features = TermFeatures(color=False, links=False)
@@ -137,8 +142,6 @@ def test_builder_found(
137142
monkeypatch: The pytest Monkeypatch fixture
138143
session_venv: The session venv
139144
140-
Raises:
141-
AssertionError: if either file is not found
142145
"""
143146

144147
@property # type: ignore[misc]
@@ -196,3 +199,37 @@ def test_str_to_bool() -> None:
196199
assert str_to_bool("off") is False
197200

198201
assert str_to_bool("anything else") is None
202+
203+
204+
def test_opt_deps_to_files(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
205+
"""Test the opt_deps_to_files function.
206+
207+
Args:
208+
tmp_path: A temporary path
209+
capsys: The pytest LogCaptureFixture
210+
"""
211+
# Create a temporary file with some content
212+
f1 = tmp_path / "test-requirements.txt"
213+
f1.touch()
214+
f2 = tmp_path / "requirements-dev.txt"
215+
f2.touch()
216+
217+
collection = Collection(
218+
config=config,
219+
cname="cname",
220+
cnamespace="cnamespace",
221+
local=True,
222+
path=tmp_path,
223+
specifier="",
224+
original=str(tmp_path),
225+
opt_deps="test,dev,foo",
226+
csource=[],
227+
)
228+
229+
result = opt_deps_to_files(collection, output)
230+
231+
captured = capsys.readouterr()
232+
233+
assert result[0] == f1
234+
assert result[1] == f2
235+
assert "Error: Failed to find optional dependency file for 'foo'." in captured.err

0 commit comments

Comments
 (0)