Skip to content

Commit 540b83b

Browse files
arnaudsjsinmantaci
authored andcommitted
Added the inmanta module download command, that allows downloading Inmanta modules in their source format from a Python package repository. (Issue #7820, PR #9397)
# Description Added the `inmanta module download` command, that allows downloading Inmanta modules in their source format from a Python package repository. closes #7820 # Self Check: - [x] Attached issue to pull request - [x] Changelog entry - [x] Type annotations are present - [x] Code is clear and sufficiently documented - [x] No (preventable) type errors (check using make mypy or make mypy-diff) - [x] Sufficient test cases (reproduces the bug/tests the requested feature) - [x] Correct, in line with design - [x] End user documentation is included or an issue is created for end-user documentation - [ ] ~~If this PR fixes a race condition in the test suite, also push the fix to the relevant stable branche(s) (see [test-fixes](https://internal.inmanta.com/development/core/tasks/build-master.html#test-fixes) for more info)~~
1 parent 3c96e0b commit 540b83b

File tree

5 files changed

+293
-1
lines changed

5 files changed

+293
-1
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
description: "Added the `inmanta module download` command, that allows downloading Inmanta modules in their source format from a Python package repository."
3+
issue-nr: 7820
4+
issue-repo: inmanta-core
5+
change-type: minor
6+
destination-branches: [master, iso8]
7+
sections:
8+
feature: "{{description}}"

src/inmanta/env.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,21 @@ def compose_uninstall_command(cls, python_path: str, pkg_names: Sequence[str]) -
359359
"""
360360
return [python_path, "-m", "pip", "uninstall", "-y", *pkg_names]
361361

362+
@classmethod
363+
def compose_download_command(
364+
cls, python_path: str, pkg_requirement: inmanta.util.CanonicalRequirement, no_deps: bool, no_binary: str | None
365+
) -> list[str]:
366+
"""
367+
Returns the pip command to download a python package.
368+
"""
369+
command = [python_path, "-m", "pip", "download"]
370+
if no_deps:
371+
command.append("--no-deps")
372+
if no_binary:
373+
command.extend(["--no-binary", no_binary])
374+
command.append(str(pkg_requirement))
375+
return command
376+
362377
@classmethod
363378
def compose_list_command(
364379
cls, python_path: str, format: Optional[PipListFormat] = None, only_editable: bool = False
@@ -828,6 +843,15 @@ def get_installed_packages(self, only_editable: bool = False) -> dict[Normalized
828843
output = CommandRunner(LOGGER_PIP).run_command_and_log_output(cmd, stderr=subprocess.DEVNULL, env=os.environ.copy())
829844
return {canonicalize_name(r["name"]): packaging.version.Version(r["version"]) for r in json.loads(output)}
830845

846+
def download_python_package(self, pkg_requirement: inmanta.util.CanonicalRequirement, output_directory: str) -> None:
847+
"""
848+
Download the python package that satisfies the constraint pkg_requirement as a source distributin package.
849+
"""
850+
cmd = PipCommandBuilder.compose_download_command(
851+
python_path=self.python_path, pkg_requirement=pkg_requirement, no_deps=True, no_binary=pkg_requirement.name
852+
)
853+
CommandRunner(LOGGER_PIP).run_command_and_log_output(cmd, env=os.environ.copy(), cwd=output_directory)
854+
831855
def install_for_config(
832856
self,
833857
requirements: Sequence[inmanta.util.CanonicalRequirement],

src/inmanta/moduletool.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import configparser
2121
import datetime
2222
import enum
23+
import gzip
2324
import inspect
2425
import itertools
2526
import logging
@@ -53,7 +54,7 @@
5354
import packaging.requirements
5455
import toml
5556
from build.env import DefaultIsolatedEnv
56-
from inmanta import const
57+
from inmanta import const, env
5758
from inmanta.command import CLIException, ShowUsageException
5859
from inmanta.const import CF_CACHE_DIR
5960
from inmanta.module import (
@@ -626,6 +627,71 @@ def modules_parser_config(cls, parser: ArgumentParser, parent_parsers: abc.Seque
626627
"this message will also be used as the commit message.",
627628
)
628629
release.add_argument("-a", "--all", dest="commit_all", help="Use commit -a", action="store_true")
630+
download = subparser.add_parser(
631+
"download",
632+
help="Download the source distribution of an Inmanta module from a Python package repository,"
633+
" extract it and convert it to its source format.",
634+
parents=parent_parsers,
635+
)
636+
download.add_argument(
637+
"module_req",
638+
help="The name of the module, optionally with a version constraint.",
639+
)
640+
download.add_argument(
641+
"--install",
642+
dest="install",
643+
help="Install the downloaded module in editable mode into the active Python environment.",
644+
action="store_true",
645+
)
646+
download.add_argument(
647+
"-d",
648+
"--directory",
649+
dest="directory",
650+
help="Download the module in this directory instead of the current working directory.",
651+
)
652+
653+
def download(self, module_req: str, install: bool, directory: str | None) -> None:
654+
if directory is None:
655+
directory = os.getcwd()
656+
module_requirement = InmantaModuleRequirement.parse(module_req)
657+
module_name = module_requirement.name
658+
with tempfile.TemporaryDirectory() as path_tmp_dir:
659+
# Download the python package
660+
download_dir = os.path.join(path_tmp_dir, "download")
661+
os.mkdir(download_dir)
662+
env.process_env.download_python_package(
663+
pkg_requirement=module_requirement.get_python_package_requirement(),
664+
output_directory=download_dir,
665+
)
666+
files_download_dir = os.listdir(download_dir)
667+
assert len(files_download_dir) == 1
668+
path_python_source_package = os.path.join(download_dir, files_download_dir[0])
669+
# Extract the package
670+
extract_dir = os.path.join(path_tmp_dir, "extract")
671+
os.mkdir(extract_dir)
672+
with gzip.open(filename=path_python_source_package, mode="rb") as tar_file_obj:
673+
with tarfile.TarFile(mode="r", fileobj=tar_file_obj) as tar:
674+
tar.extractall(path=extract_dir, filter="data")
675+
files_extract_dir = os.listdir(extract_dir)
676+
assert len(files_extract_dir) == 1
677+
path_extracted_pkg = os.path.join(extract_dir, files_extract_dir[0])
678+
# Convert to source format
679+
try:
680+
# Remove this file as it will be replace by the one present in the inmanta_plugins/<mod-name> directory.
681+
os.remove(os.path.join(path_extracted_pkg, "setup.cfg"))
682+
except FileNotFoundError:
683+
pass
684+
files_and_dirs_to_move = ["model", "templates", "files", "setup.cfg"]
685+
for file_or_dir in files_and_dirs_to_move:
686+
fq_path = os.path.join(path_extracted_pkg, "inmanta_plugins", module_name, file_or_dir)
687+
if os.path.exists(fq_path):
688+
shutil.move(src=fq_path, dst=path_extracted_pkg)
689+
# Move to desired output directory
690+
destination_dir = os.path.join(directory, module_name)
691+
shutil.copytree(src=path_extracted_pkg, dst=destination_dir)
692+
# Install in editable mode if requested
693+
if install:
694+
env.process_env.install_from_source(paths=[env.LocalPackagePath(path=destination_dir, editable=True)])
629695

630696
def add(self, module_req: str, v2: bool = True, override: bool = False) -> None:
631697
"""
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def test_test():
2+
pass

tests/moduletool/test_download.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
Copyright 2025 Inmanta
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
17+
"""
18+
19+
import os
20+
import shutil
21+
import subprocess
22+
import sys
23+
import tempfile
24+
25+
import pytest
26+
27+
import packaging.utils
28+
from inmanta import env, module, moduletool
29+
from libpip2pi.commands import dir2pi
30+
from packaging import version
31+
from utils import module_from_template
32+
33+
34+
@pytest.fixture(scope="session")
35+
def pip_index(modules_v2_dir: str) -> str:
36+
"""
37+
Returns the path to a pip index that contains several version for the same python package.
38+
"""
39+
with tempfile.TemporaryDirectory() as root_dir:
40+
source_dir = os.path.join(root_dir, "source")
41+
build_dir = os.path.join(root_dir, "build")
42+
index_dir = os.path.join(build_dir, "simple")
43+
44+
modules_to_build = [
45+
("elaboratev2module", version.Version("1.2.3"), False),
46+
("elaboratev2module", version.Version("2.3.4"), False),
47+
("elaboratev2module", version.Version("2.3.5"), True),
48+
("minimalv2module", version.Version("1.1.1"), False),
49+
]
50+
51+
for module_name, mod_version, is_prerelease in modules_to_build:
52+
template_dir = os.path.join(modules_v2_dir, module_name)
53+
module_dir = os.path.join(source_dir, module_name)
54+
module_from_template(
55+
source_dir=template_dir,
56+
dest_dir=module_dir,
57+
new_version=mod_version,
58+
)
59+
moduletool.ModuleTool().build(
60+
path=module_dir,
61+
output_dir=build_dir,
62+
dev_build=is_prerelease,
63+
wheel=True,
64+
sdist=True,
65+
)
66+
shutil.rmtree(module_dir)
67+
68+
# The setuptools and wheel packages are required by `pip download`
69+
subprocess.check_call([sys.executable, "-m", "pip", "download", "setuptools", "wheel"], cwd=build_dir)
70+
dir2pi(argv=["dir2pi", build_dir])
71+
yield index_dir
72+
73+
74+
def execute_inmanta_download(module_req: str, install: bool, download_dir: str | None) -> str:
75+
"""
76+
Executes the `inmanta module download` command and returns the ModuleV2 object for the
77+
downloaded module.
78+
"""
79+
if download_dir is None:
80+
download_dir = os.getcwd()
81+
module_name: str = module.InmantaModuleRequirement.parse(module_req).name
82+
assert not os.listdir(download_dir)
83+
m = moduletool.ModuleTool()
84+
m.download(module_req=module_req, install=install, directory=download_dir)
85+
files = os.listdir(download_dir)
86+
assert len(files) == 1
87+
assert files[0] == module_name
88+
return module.ModuleV2(project=None, path=os.path.join(download_dir, module_name))
89+
90+
91+
def assert_files_in_module(
92+
module_dir: str, module_name: str, has_files_dir: bool, has_templates_dir: bool, has_tests_dir: bool
93+
) -> None:
94+
"""
95+
Verify that the directory structure of the extracted python package is correct.
96+
97+
:param has_files_dir: True iff the given module has a files directory.
98+
:param has_templates_dir: True iff the given module has a templates directory.
99+
:param has_tests_dir: True iff the given module has a tests directory.
100+
"""
101+
assert os.path.exists(os.path.join(module_dir, "inmanta_plugins"))
102+
for file_or_dir, must_exist_in_root_dir in [
103+
("tests", has_tests_dir),
104+
("files", has_files_dir),
105+
("templates", has_templates_dir),
106+
("setup.cfg", True),
107+
]:
108+
assert not os.path.exists(os.path.join(module_dir, "inmanta_plugins", module_name, file_or_dir))
109+
assert os.path.exists(os.path.join(module_dir, file_or_dir)) == must_exist_in_root_dir
110+
111+
112+
def test_download_module(tmpdir, monkeypatch, tmpvenv_active, pip_index: str):
113+
"""
114+
Test the `inmanta module download` command with a version constraint.
115+
"""
116+
monkeypatch.setenv("PIP_INDEX_URL", pip_index)
117+
monkeypatch.setenv("PIP_PRE", "false")
118+
download_dir = os.path.join(tmpdir, "download")
119+
120+
# Test download package without constraint
121+
os.mkdir(download_dir)
122+
mod: module.ModuleV2 = execute_inmanta_download(module_req="elaboratev2module", install=False, download_dir=download_dir)
123+
assert_files_in_module(
124+
module_dir=mod.path, module_name="elaboratev2module", has_files_dir=True, has_templates_dir=True, has_tests_dir=True
125+
)
126+
assert mod.version == version.Version("2.3.4")
127+
shutil.rmtree(download_dir)
128+
129+
# Test download package with constraint
130+
os.mkdir(download_dir)
131+
mod: module.ModuleV2 = execute_inmanta_download(
132+
module_req="elaboratev2module~=1.2.0", install=False, download_dir=download_dir
133+
)
134+
assert_files_in_module(
135+
module_dir=mod.path, module_name="elaboratev2module", has_files_dir=True, has_templates_dir=True, has_tests_dir=True
136+
)
137+
assert mod.version == version.Version("1.2.3")
138+
shutil.rmtree(download_dir)
139+
140+
# Test download package with --pre
141+
os.mkdir(download_dir)
142+
monkeypatch.setenv("PIP_PRE", "true")
143+
mod: module.ModuleV2 = execute_inmanta_download(module_req="elaboratev2module", install=False, download_dir=download_dir)
144+
assert_files_in_module(
145+
module_dir=mod.path, module_name="elaboratev2module", has_files_dir=True, has_templates_dir=True, has_tests_dir=True
146+
)
147+
assert mod.version.base_version == "2.3.5"
148+
assert mod.version.is_prerelease
149+
shutil.rmtree(download_dir)
150+
151+
# Test downloading a package that doesn't have any of the optional directories
152+
# (e.g. files, templates, tests).
153+
monkeypatch.setenv("PIP_PRE", "false")
154+
os.mkdir(download_dir)
155+
mod: module.ModuleV2 = execute_inmanta_download(module_req="minimalv2module", install=False, download_dir=download_dir)
156+
assert_files_in_module(
157+
module_dir=mod.path, module_name="minimalv2module", has_files_dir=False, has_templates_dir=False, has_tests_dir=False
158+
)
159+
assert mod.version == version.Version("1.1.1")
160+
161+
162+
def test_download_cwd(tmpdir, monkeypatch, tmpvenv_active, pip_index: str):
163+
"""
164+
Test the `inmanta module download` command when downloading to the current working directory.
165+
"""
166+
monkeypatch.setenv("PIP_INDEX_URL", pip_index)
167+
monkeypatch.setenv("PIP_PRE", "false")
168+
download_dir = os.path.join(tmpdir, "download")
169+
os.mkdir(download_dir)
170+
monkeypatch.chdir(download_dir)
171+
mod: module.ModuleV2 = execute_inmanta_download(module_req="elaboratev2module", install=False, download_dir=None)
172+
assert_files_in_module(
173+
module_dir=mod.path, module_name="elaboratev2module", has_files_dir=True, has_templates_dir=True, has_tests_dir=True
174+
)
175+
176+
177+
def test_download_install(tmpdir, monkeypatch, tmpvenv_active, pip_index: str):
178+
"""
179+
Test the install option of the `inmanta module download` command.
180+
"""
181+
monkeypatch.setenv("PIP_INDEX_URL", pip_index)
182+
monkeypatch.setenv("PIP_PRE", "false")
183+
download_dir = os.path.join(tmpdir, "download")
184+
os.mkdir(download_dir)
185+
pkg_name = "inmanta-module-minimalv2module"
186+
pkgs_installed_in_editable_mode: dict[packaging.utils.NormalizedName, version.Version]
187+
pkgs_installed_in_editable_mode = env.process_env.get_installed_packages(only_editable=True)
188+
assert pkg_name not in pkgs_installed_in_editable_mode
189+
execute_inmanta_download(module_req="minimalv2module", install=True, download_dir=download_dir)
190+
pkgs_installed_in_editable_mode = env.process_env.get_installed_packages(only_editable=True)
191+
assert pkg_name in pkgs_installed_in_editable_mode
192+
assert pkgs_installed_in_editable_mode[pkg_name] == version.Version("1.1.1")

0 commit comments

Comments
 (0)