Skip to content

Commit b72a1ee

Browse files
committed
allow plugins to be installed and run in virtual environments
1 parent c9ebb12 commit b72a1ee

File tree

4 files changed

+212
-46
lines changed

4 files changed

+212
-46
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .base_class import AnalysisScript
2-
from .plugins import available_plugins, find_plugins, plugin_requirements, \
2+
from .env_tool import VirtualEnvManager
3+
from .plugins import available_plugins, plugin_requirements, \
34
run_plugin, UnknownPluginError
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from pathlib import Path
2+
from subprocess import CalledProcessError, PIPE, run, STDOUT
3+
from tempfile import TemporaryDirectory
4+
import venv
5+
6+
7+
def _process_output(output):
8+
"""Converts bytes string to list of String lines.
9+
10+
Args:
11+
output: Bytes string.
12+
13+
Returns:
14+
List of strings.
15+
"""
16+
return [x for x in output.decode("utf-8").split("\n") if x]
17+
18+
19+
class VirtualEnvManager(object):
20+
"""Helper class for creating/running simple command in a virtual environment."""
21+
def __init__(self, path):
22+
self.path = Path(path)
23+
self.activate = f"source {self.path / 'bin' / 'activate'}"
24+
25+
@staticmethod
26+
def _execute(commands):
27+
"""Runs input commands through bash in a child process.
28+
29+
Args:
30+
commands: List of string commands.
31+
32+
Returns:
33+
List of string output.
34+
"""
35+
with TemporaryDirectory() as tmp:
36+
script_path = Path(tmp) / "script"
37+
with open(script_path, "w") as script:
38+
script.write("\n".join(commands))
39+
try:
40+
process = run(["bash", str(script_path)], stdout=PIPE, stderr=STDOUT,
41+
check=True)
42+
except CalledProcessError as err:
43+
for line in _process_output(err.output):
44+
print(line)
45+
raise
46+
return _process_output(process.stdout)
47+
48+
def _execute_python_script(self, commands):
49+
"""Runs input python code in bash in a child process.
50+
51+
Args:
52+
commands: List of string python code lines.
53+
54+
Returns:
55+
List of string output.
56+
"""
57+
with TemporaryDirectory() as tmp:
58+
script_path = Path(tmp) / "python_script"
59+
with open(script_path, "w") as script:
60+
script.write("\n".join(commands))
61+
commands = [self.activate, f"python3 {str(script_path)}"]
62+
return self._execute(commands)
63+
64+
def create_env(self):
65+
"""Creates the virtual environment."""
66+
venv.create(self.path, with_pip=True)
67+
68+
def destroy_env(self):
69+
"""Destroys the virtual environment."""
70+
raise NotImplementedError("this feature is not implemented yet.")
71+
72+
def install_package(self, name):
73+
"""Installs a package in the virtual environment.
74+
75+
Args:
76+
name: String name of the package.
77+
78+
Returns:
79+
List of string output.
80+
"""
81+
commands = [self.activate, "python3 -m pip --upgrade pip",
82+
f"python3 -m pip install {name}"]
83+
return self._execute(commands)
84+
85+
def list_plugins(self):
86+
"""Returns a list of plugins that are available in the virtual environment.
87+
88+
Returns:
89+
List of plugins.
90+
"""
91+
python_script = [
92+
"from analysis_scripts import available_plugins",
93+
"for plugin in available_plugins():",
94+
" print(plugin)"
95+
]
96+
return self._execute_python_script(python_script)
97+
98+
def run_analysis_plugin(self, name, catalog, output_directory, config=None):
99+
"""Returns a list of paths to figures created by the plugin from the virtual
100+
environment.
101+
102+
Args:
103+
name: String name of the analysis package.
104+
catalog: Path to the data catalog.
105+
output_directory: Path to the output directory.
106+
107+
Returns:
108+
List of figure paths.
109+
"""
110+
if config:
111+
python_script = [f"config = {str(config)}",]
112+
else:
113+
python_script = ["config = None",]
114+
python_script += [
115+
"from analysis_scripts import run_plugin",
116+
f"paths = run_plugin('{name}', '{catalog}', '{output_directory}', config=config)",
117+
"for path in paths:",
118+
" print(path)"
119+
]
120+
return self._execute_python_script(python_script)
121+
122+
def uninstall_package(self, name):
123+
"""Uninstalls a package from the virtual environment.
124+
125+
Args:
126+
name: String name of the package.
127+
128+
Returns:
129+
List of string output.
130+
"""
131+
commands = [self.activate, f"pip uninstall {name}"]
132+
return self._execute(commands)
133+
134+
135+
def analysis_script_test():
136+
env = VirtualEnvManager("foo")
137+
name = "freanalysis_clouds"
138+
url = "https://github.com/noaa-gfdl/analysis-scripts.git"
139+
with TemporaryDirectory() as tmp:
140+
tmp_path = Path(tmp)
141+
run(["git", "clone", url, str(tmp_path / "scripts")])
142+
output = env.install_package(str(tmp_path / "scripts" / "core" / "figure_tools"))
143+
output = env.install_package(str(tmp_path / "scripts" / "core" / "analysis_scripts"))
144+
output = env.install_package(str(tmp_path / "scripts" / "user-analysis-scripts" / name))
145+
env.run_analysis_plugin(name, "fake-catalog", ".", config={"a": "b"})
146+
147+
148+
if __name__ == "__main__":
149+
analysis_script_test()

core/analysis_scripts/analysis_scripts/plugins.py

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,15 @@
11
import importlib
22
import inspect
33
import pkgutil
4-
import sys
54

65
from .base_class import AnalysisScript
76

87

9-
class _PathAdjuster(object):
10-
"""Helper class to adjust where python tries to import modules from."""
11-
def __init__(self, path):
12-
"""Initialize the object.
13-
14-
Args:
15-
path: Path to look in for python modules and packages.
16-
"""
17-
self.path = path
18-
self.old_sys_path = sys.path
19-
20-
def __enter__(self):
21-
"""Adjusts the sys path so the modules and packages can be imported."""
22-
if self.path not in sys.path:
23-
sys.path.insert(0, self.path)
24-
return self
25-
26-
def __exit__(self, exception_type, exception_value, traceback):
27-
"""Undoes the sys path adjustment."""
28-
if sys.path != self.old_sys_path:
29-
sys.path = self.old_sys_path
30-
31-
328
# Dictionary of found plugins.
339
discovered_plugins = {}
34-
35-
36-
def find_plugins(path=None):
37-
"""Find all installed python modules with names that start with 'freanalysis_'.
38-
39-
Args:
40-
path: Custom directory where modules and packages are installed.
41-
"""
42-
if path:
43-
path = [path,]
44-
for finder, name, ispkg in pkgutil.iter_modules(path):
45-
if name.startswith("freanalysis_"):
46-
if path:
47-
with _PathAdjuster(path[0]):
48-
discovered_plugins[name] = importlib.import_module(name)
49-
else:
50-
discovered_plugins[name] = importlib.import_module(name)
51-
52-
53-
# Update plugin dictionary.
54-
find_plugins()
10+
for finder, name, ispkg in pkgutil.iter_modules():
11+
if name.startswith("freanalysis_"):
12+
discovered_plugins[name] = importlib.import_module(name)
5513

5614

5715
class UnknownPluginError(BaseException):
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pathlib import Path
2+
from platform import python_version_tuple
3+
from subprocess import CalledProcessError, run
4+
from tempfile import TemporaryDirectory
5+
6+
from analysis_scripts import VirtualEnvManager
7+
import pytest
8+
9+
10+
def install_helper(tmp):
11+
tmp_path = Path(tmp)
12+
env_path = tmp_path / "env"
13+
env = VirtualEnvManager(env_path)
14+
env.create_env()
15+
name = "freanalysis_clouds"
16+
url = "https://github.com/noaa-gfdl/analysis-scripts.git"
17+
run(["git", "clone", url, str(tmp_path / "scripts")])
18+
output = env.install_package(str(tmp_path / "scripts" / "core" / "analysis_scripts"))
19+
output = env.install_package(str(tmp_path / "scripts" / "core" / "figure_tools"))
20+
output = env.install_package(str(tmp_path / "scripts" / "user-analysis-scripts" / name))
21+
return tmp_path, env_path, env, name
22+
23+
24+
def test_create_env():
25+
with TemporaryDirectory() as tmp:
26+
env_path = Path(tmp) / "env"
27+
env = VirtualEnvManager(env_path)
28+
env.create_env()
29+
assert env_path.is_dir()
30+
test_string = "hello, world"
31+
assert env._execute([f'echo "{test_string}"',])[0] == test_string
32+
33+
34+
def test_install_plugin():
35+
with TemporaryDirectory() as tmp:
36+
tmp_path, env_path, env, name = install_helper(tmp)
37+
version = ".".join(python_version_tuple()[:2])
38+
plugin_path = env_path / "lib" / f"python{version}" / "site-packages" / name
39+
assert plugin_path.is_dir()
40+
41+
42+
def test_list_plugins():
43+
with TemporaryDirectory() as tmp:
44+
tmp_path, env_path, env, name = install_helper(tmp)
45+
plugins = env.list_plugins()
46+
assert plugins[0] == name
47+
48+
49+
def test_run_plugin():
50+
with TemporaryDirectory() as tmp:
51+
tmp_path, env_path, env, name = install_helper(tmp)
52+
catalog = tmp_path / "fake-catalog"
53+
with pytest.raises(CalledProcessError) as err:
54+
env.run_analysis_plugin(name, str(catalog), ".", config={"a": "b"})
55+
for line in err._excinfo[1].output.decode("utf-8").split("\n"):
56+
if f"No such file or directory: '{str(catalog)}'" in line:
57+
return
58+
assert False

0 commit comments

Comments
 (0)