Skip to content

Commit bb4e3dd

Browse files
Chris Blantonilaflott
authored andcommitted
Add AnalysisScript base class and esnb derived class to define run_analysis for esnb analysis scripts
1 parent 2c5fa9f commit bb4e3dd

File tree

5 files changed

+375
-0
lines changed

5 files changed

+375
-0
lines changed

fre/analysis/base_class.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import json
2+
3+
4+
class AnalysisScript(object):
5+
"""Abstract base class for analysis scripts. User-defined analysis scripts
6+
should inhert from this class and override the requires and run_analysis methods.
7+
8+
Attributes:
9+
description: Longer form description for the analysis.
10+
title: Title that describes the analysis.
11+
"""
12+
def __init__(self):
13+
"""Instantiates an object. The user should provide a description and title."""
14+
raise NotImplementedError("you must override this function.")
15+
self.description = None
16+
self.title = None
17+
18+
def requires(self):
19+
"""Provides metadata describing what is needed for this analysis to run.
20+
21+
Returns:
22+
A json string describing the metadata.
23+
"""
24+
raise NotImplementedError("you must override this function.")
25+
return json.dumps("{json of metadata MDTF format.}")
26+
27+
def run_analysis(self, yaml, name, date_range, scripts_dir, output_dir, output_yaml):
28+
"""Runs the analysis and generates all plots and associated datasets.
29+
30+
Args:
31+
yaml: Path to a model yaml
32+
name: Name of the analysis as specified in the yaml
33+
date_range: Time span to use for analysis (YYYY-MM-DD,YYYY-MM-DD)
34+
scripts_dir: Path to a directory to save intermediate scripts
35+
output_dir: Path to a directory to save figures
36+
output_yaml: Path to use as an structured output yaml file
37+
38+
Returns:
39+
A list of png figures.
40+
"""
41+
raise NotImplementedError("you must override this function.")
42+
return ["figure1.png", "figure2.png",]

fre/analysis/env_tool.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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)

fre/analysis/plugins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .esnb import freanalysis_esnb

fre/analysis/plugins/esnb.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import logging
2+
from pathlib import Path, PurePosixPath
3+
import requests
4+
from ..base_class import AnalysisScript
5+
import esnb.engine
6+
7+
fre_logger = logging.getLogger(__name__)
8+
9+
class freanalysis_esnb(AnalysisScript):
10+
"""Defines run and report-requirements methods for ESNB flavor usage
11+
"""
12+
13+
def __init__(self):
14+
self.description = "Wrapper to access analysis framework for ESNB scripts"
15+
self.title = "ESNB"
16+
17+
def run_analysis(self, config, name, date_range, scripts_dir, output_dir, output_yaml):
18+
"""Runs the ESNB analysis specified in the yaml and the runtime options
19+
20+
Args:
21+
config: Dictionary of specific configuration for the script
22+
name: Name of the analysis as specified in the yaml
23+
date_range: Time span to use for analysis (YYYY-MM-DD,YYYY-MM-DD)
24+
scripts_dir: Path to a directory to save intermediate scripts
25+
output_dir: Path to a directory to save figures
26+
output_yaml: Path to use as an structured output yaml file
27+
"""
28+
29+
# save notebook to scripts_dir
30+
url = config["notebook_path"]
31+
# convert to the "Raw" URL
32+
# replace 'github.com' with 'raw.githubusercontent.com' and remove '/blob'
33+
raw_url = url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
34+
local_filename = Path(scripts_dir) / PurePosixPath(url).name
35+
with requests.get(raw_url) as r:
36+
r.raise_for_status() # Check for HTTP errors (404, 500, etc.)
37+
with open(local_filename, 'wb') as f:
38+
for chunk in r.iter_content(chunk_size=8192):
39+
f.write(chunk)
40+
fre_logger.debug(f"ESNB notebook saved to '{local_filename}'")
41+
42+
# create run_settings dictionary
43+
run_settings = {
44+
'conda_env_root': config["conda_env_root"],
45+
'notebook_path': local_filename,
46+
'outdir': output_dir,
47+
'scripts_dir': scripts_dir
48+
}
49+
50+
# create case_settings dictionary
51+
52+
# write the python script that runs the notebook
53+
python_script = esnb.engine.canopy_launcher(run_settings, verbose=True)
54+
fre_logger.debug(f"ESNB python wrapper saved to '{python_script}'")
55+
56+
# run the python script
57+

fre/analysis/plugins/subtools.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import importlib
2+
import inspect
3+
from pathlib import Path
4+
import pkgutil
5+
6+
from ..base_class import AnalysisScript
7+
from .esnb import freanalysis_esnb
8+
9+
10+
class UnknownPluginError(BaseException):
11+
"""Custom exception for when an invalid plugin name is used."""
12+
pass
13+
14+
15+
def _find_plugin_class(module):
16+
"""Looks for a class that inherits from AnalysisScript.
17+
18+
Args:
19+
module: Module object.
20+
21+
Returns:
22+
Class that inherits from AnalysisScript.
23+
24+
Raises:
25+
UnknownPluginError if no class is found.
26+
"""
27+
for attribute in vars(module).values():
28+
# Try to find a class that inherits from the AnalysisScript class.
29+
if inspect.isclass(attribute) and AnalysisScript in attribute.__bases__:
30+
# Return the class so an object can be instantiated from it later.
31+
return attribute
32+
raise UnknownPluginError("could not find class that inherts from AnalysisScripts")
33+
34+
35+
_sanity_counter = 0 # How much recursion is happening.
36+
_maximum_craziness = 100 # This is too much recursion.
37+
38+
39+
def _recursive_search(name, ispkg):
40+
"""Recursively search for a module that has a class that inherits from AnalysisScript.
41+
42+
Args:
43+
name: String name of the module.
44+
ispkg: Flag telling whether or not the module is a package.
45+
46+
Returns:
47+
Class that inherits from AnalysisScript.
48+
49+
Raises:
50+
UnknownPluginError if no class is found.
51+
ValueError if there is too much recursion.
52+
"""
53+
global _sanity_counter
54+
_sanity_counter += 1
55+
if _sanity_counter > _maximum_craziness:
56+
raise ValueError(f"recursion level {_sanity_counter} too high.")
57+
58+
module = importlib.import_module(name)
59+
try:
60+
return _find_plugin_class(module)
61+
except UnknownPluginError:
62+
if not ispkg:
63+
# Do not recurse further.
64+
raise
65+
paths = module.__spec__.submodule_search_locations
66+
for finder, subname, ispkg in pkgutil.iter_modules(paths):
67+
subname = f"{name}.{subname}"
68+
try:
69+
return _recursive_search(subname, ispkg)
70+
except UnknownPluginError:
71+
# Didn't find it, so continue to iterate.
72+
pass
73+
74+
75+
# Dictionary of found plugins.
76+
_discovered_plugins = {}
77+
for finder, name, ispkg in pkgutil.iter_modules():
78+
if name.startswith("freanalysis_") and ispkg:
79+
_sanity_counter = 0
80+
_discovered_plugins[name] = _recursive_search(name, True)
81+
82+
83+
def _plugin_object(name):
84+
"""Attempts to create an object from a class that inherits from AnalysisScript in
85+
the plugin module.
86+
87+
Args:
88+
name: Name of the plugin.
89+
90+
Returns:
91+
The object that inherits from AnalysisScript.
92+
93+
Raises:
94+
UnknownPluginError if the input name is not in the disovered_plugins dictionary.
95+
"""
96+
return freanalysis_esnb()
97+
# try:
98+
#return _discovered_plugins[name]()
99+
# return freanalysis_esnb()
100+
# except KeyError:
101+
# raise UnknownPluginError(f"could not find analysis script plugin '{name}'.")
102+
103+
104+
def available_plugins():
105+
"""Returns a list of plugin names."""
106+
return sorted(list(_discovered_plugins.keys()))
107+
108+
109+
def list_plugins():
110+
"""Prints a list of plugin names."""
111+
names = available_plugins()
112+
if names:
113+
print("\n".join(["Available plugins:", "-"*32] + names))
114+
else:
115+
print("Warning: no plugins found.")
116+
117+
118+
def plugin_requirements(name):
119+
"""Returns a JSON string detailing the plugin's requirement metadata.
120+
121+
Args:
122+
name: Name of the plugin.
123+
124+
Returns:
125+
JSON string of metadata.
126+
"""
127+
return _plugin_object(name).requires()
128+
129+
130+
def run_plugin(script_type, name, config, date_range, scripts_dir, output_dir, output_yaml):
131+
"""Runs the plugin's analysis.
132+
133+
Args:
134+
name: Name of the plugin.
135+
catalog: Path to the data catalog.
136+
png_dir: Directory where the output figures will be stored.
137+
config: Dictionary of configuration values.
138+
catalog: Path to the catalog of reference data.
139+
140+
Returns:
141+
A list of png figure files that were created by the analysis.
142+
"""
143+
return _plugin_object(script_type).run_analysis(config, name, date_range, scripts_dir, output_dir, output_yaml)

0 commit comments

Comments
 (0)