Skip to content

Commit 89bbab4

Browse files
authored
Merge pull request #26 from openpathsampling/release-0.1
Release 0.1
2 parents 9d11c16 + 0277e6e commit 89bbab4

15 files changed

+456
-91
lines changed

.travis.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ script:
4545
- py.test -vv --cov --cov-report xml:cov.xml
4646

4747
after_success:
48-
- COVERALLS_PARALLEL=true coveralls
48+
- bash <(curl -s https://codecov.io/bash)
4949

5050
import:
51-
- dwhswenson/autorelease:[email protected].0
51+
- dwhswenson/autorelease:[email protected].1

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[![Build Status](https://travis-ci.com/openpathsampling/openpathsampling-cli.svg?branch=master)](https://travis-ci.com/openpathsampling/openpathsampling-cli)
22
[![Documentation Status](https://readthedocs.org/projects/openpathsampling-cli/badge/?version=latest)](https://openpathsampling-cli.readthedocs.io/en/latest/?badge=latest)
3-
[![Coverage Status](https://coveralls.io/repos/github/openpathsampling/openpathsampling-cli/badge.svg?branch=master)](https://coveralls.io/github/openpathsampling/openpathsampling-cli?branch=master)
3+
[![Coverage Status](https://codecov.io/gh/openpathsampling/openpathsampling-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/openpathsampling/openpathsampling-cli)
44
[![Maintainability](https://api.codeclimate.com/v1/badges/0d1ee29e1a05cfcdc01a/maintainability)](https://codeclimate.com/github/openpathsampling/openpathsampling-cli/maintainability)
55

66
# OpenPathSampling CLI

paths_cli/cli.py

Lines changed: 16 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,15 @@
77
import logging
88
import logging.config
99
import os
10+
import pathlib
1011

1112
import click
1213
# import click_completion
1314
# click_completion.init()
1415

15-
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
16-
17-
_POSSIBLE_PLUGIN_FOLDERS = [
18-
os.path.join(os.path.dirname(__file__), 'commands'),
19-
os.path.join(click.get_app_dir("OpenPathSampling"), 'cli-plugins'),
20-
os.path.join(click.get_app_dir("OpenPathSampling", force_posix=True),
21-
'cli-plugins'),
22-
]
23-
24-
OPSPlugin = collections.namedtuple("OPSPlugin",
25-
['name', 'filename', 'func', 'section'])
16+
from .plugin_management import FilePluginLoader, NamespacePluginLoader
2617

18+
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
2719

2820
class OpenPathSamplingCLI(click.MultiCommand):
2921
"""Main class for the command line interface
@@ -32,13 +24,20 @@ class OpenPathSamplingCLI(click.MultiCommand):
3224
"""
3325
def __init__(self, *args, **kwargs):
3426
# the logic here is all about loading the plugins
35-
self.plugin_folders = []
36-
for folder in _POSSIBLE_PLUGIN_FOLDERS:
37-
if folder not in self.plugin_folders and os.path.exists(folder):
38-
self.plugin_folders.append(folder)
27+
commands = str(pathlib.Path(__file__).parent.resolve() / 'commands')
28+
def app_dir_plugins(posix):
29+
return str(pathlib.Path(
30+
click.get_app_dir("OpenPathSampling", force_posix=posix)
31+
).resolve() / 'cli-plugins')
32+
33+
self.plugin_loaders = [
34+
FilePluginLoader(commands),
35+
FilePluginLoader(app_dir_plugins(posix=False)),
36+
FilePluginLoader(app_dir_plugins(posix=True)),
37+
NamespacePluginLoader('paths_cli.plugins')
38+
]
3939

40-
plugin_files = self._list_plugin_files(self.plugin_folders)
41-
plugins = self._load_plugin_files(plugin_files)
40+
plugins = sum([loader() for loader in self.plugin_loaders], [])
4241

4342
self._get_command = {}
4443
self._sections = collections.defaultdict(list)
@@ -62,45 +61,6 @@ def _deregister_plugin(self, plugin):
6261
def plugin_for_command(self, command_name):
6362
return {p.name: p for p in self.plugins}[command_name]
6463

65-
@staticmethod
66-
def _list_plugin_files(plugin_folders):
67-
def is_plugin(filename):
68-
return (
69-
filename.endswith(".py") and not filename.startswith("_")
70-
and not filename.startswith(".")
71-
)
72-
73-
plugin_files = []
74-
for folder in plugin_folders:
75-
files = [os.path.join(folder, f) for f in os.listdir(folder)
76-
if is_plugin(f)]
77-
plugin_files += files
78-
return plugin_files
79-
80-
@staticmethod
81-
def _filename_to_command_name(filename):
82-
command_name = filename[:-3] # get rid of .py
83-
command_name = command_name.replace('_', '-') # commands use -
84-
return command_name
85-
86-
@staticmethod
87-
def _load_plugin(name):
88-
ns = {}
89-
with open(name) as f:
90-
code = compile(f.read(), name, 'exec')
91-
eval(code, ns, ns)
92-
return ns['CLI'], ns['SECTION']
93-
94-
def _load_plugin_files(self, plugin_files):
95-
plugins = []
96-
for full_name in plugin_files:
97-
_, filename = os.path.split(full_name)
98-
command_name = self._filename_to_command_name(filename)
99-
func, section = self._load_plugin(full_name)
100-
plugins.append(OPSPlugin(name=command_name, filename=full_name,
101-
func=func, section=section))
102-
return plugins
103-
10464
def list_commands(self, ctx):
10565
return list(self._get_command.keys())
10666

paths_cli/commands/pathsampling.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
@SCHEME.clicked(required=False)
1616
@INIT_CONDS.clicked(required=False)
1717
@N_STEPS_MC
18-
def path_sampling(input_file, output_file, scheme, init_conds, nsteps):
18+
def pathsampling(input_file, output_file, scheme, init_conds, nsteps):
1919
"""General path sampling, using setup in INPUT_FILE"""
2020
storage = INPUT_FILE.get(input_file)
21-
path_sampling_main(output_storage=OUTPUT_FILE.get(output_file),
22-
scheme=SCHEME.get(storage, scheme),
23-
init_conds=INIT_CONDS.get(storage, init_conds),
24-
n_steps=nsteps)
21+
pathsampling_main(output_storage=OUTPUT_FILE.get(output_file),
22+
scheme=SCHEME.get(storage, scheme),
23+
init_conds=INIT_CONDS.get(storage, init_conds),
24+
n_steps=nsteps)
2525

26-
def path_sampling_main(output_storage, scheme, init_conds, n_steps):
26+
def pathsampling_main(output_storage, scheme, init_conds, n_steps):
2727
import openpathsampling as paths
2828
init_conds = scheme.initial_conditions_from_trajectories(init_conds)
2929
simulation = paths.PathSampling(
@@ -37,6 +37,6 @@ def path_sampling_main(output_storage, scheme, init_conds, n_steps):
3737
return simulation.sample_set, simulation
3838

3939

40-
CLI = path_sampling
40+
CLI = pathsampling
4141
SECTION = "Simulation"
4242
REQUIRES_OPS = (1, 0)

paths_cli/plugin_management.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import collections
2+
import pkgutil
3+
import importlib
4+
import os
5+
6+
OPSPlugin = collections.namedtuple(
7+
"OPSPlugin", ['name', 'location', 'func', 'section', 'plugin_type']
8+
)
9+
10+
class CLIPluginLoader(object):
11+
"""Abstract object for CLI plugins
12+
13+
The overall approach involves 5 steps, each of which can be overridden:
14+
15+
1. Find candidate plugins (which must be Python modules)
16+
2. Load the namespaces associated into a dict (nsdict)
17+
3. Based on those namespaces, validate that the module *is* a plugin
18+
4. Get the associated command name
19+
5. Return an OPSPlugin object for each plugin
20+
21+
Details on steps 1, 2, and 4 differ based on whether this is a
22+
filesystem-based plugin or a namespace-based plugin.
23+
"""
24+
def __init__(self, plugin_type, search_path):
25+
self.plugin_type = plugin_type
26+
self.search_path = search_path
27+
28+
def _find_candidates(self):
29+
raise NotImplementedError()
30+
31+
@staticmethod
32+
def _make_nsdict(candidate):
33+
raise NotImplementedError()
34+
35+
@staticmethod
36+
def _validate(nsdict):
37+
for attr in ['CLI', 'SECTION']:
38+
if attr not in nsdict:
39+
return False
40+
return True
41+
42+
def _get_command_name(self, candidate):
43+
raise NotImplementedError()
44+
45+
def _find_valid(self):
46+
candidates = self._find_candidates()
47+
namespaces = {cand: self._make_nsdict(cand) for cand in candidates}
48+
valid = {cand: ns for cand, ns in namespaces.items()
49+
if self._validate(ns)}
50+
return valid
51+
52+
def __call__(self):
53+
valid = self._find_valid()
54+
plugins = [
55+
OPSPlugin(name=self._get_command_name(cand),
56+
location=cand,
57+
func=ns['CLI'],
58+
section=ns['SECTION'],
59+
plugin_type=self.plugin_type)
60+
for cand, ns in valid.items()
61+
]
62+
return plugins
63+
64+
65+
class FilePluginLoader(CLIPluginLoader):
66+
"""File-based plugins (quick and dirty)
67+
68+
Parameters
69+
----------
70+
search_path : str
71+
path to the directory that contains plugins (OS-dependent format)
72+
"""
73+
def __init__(self, search_path):
74+
super().__init__(plugin_type="file", search_path=search_path)
75+
76+
def _find_candidates(self):
77+
def is_plugin(filename):
78+
return (
79+
filename.endswith(".py") and not filename.startswith("_")
80+
and not filename.startswith(".")
81+
)
82+
83+
if not os.path.exists(os.path.join(self.search_path)):
84+
return []
85+
86+
candidates = [os.path.join(self.search_path, f)
87+
for f in os.listdir(self.search_path)
88+
if is_plugin(f)]
89+
return candidates
90+
91+
@staticmethod
92+
def _make_nsdict(candidate):
93+
ns = {}
94+
with open(candidate) as f:
95+
code = compile(f.read(), candidate, 'exec')
96+
eval(code, ns, ns)
97+
return ns
98+
99+
def _get_command_name(self, candidate):
100+
_, command_name = os.path.split(candidate)
101+
command_name = command_name[:-3] # get rid of .py
102+
command_name = command_name.replace('_', '-') # commands use -
103+
return command_name
104+
105+
106+
class NamespacePluginLoader(CLIPluginLoader):
107+
"""Load namespace plugins (plugins for wide distribution)
108+
109+
Parameters
110+
----------
111+
search_path : str
112+
namespace (dot-separated) where plugins can be found
113+
"""
114+
def __init__(self, search_path):
115+
super().__init__(plugin_type="namespace", search_path=search_path)
116+
117+
def _find_candidates(self):
118+
# based on https://packaging.python.org/guides/creating-and-discovering-plugins/#using-namespace-packages
119+
def iter_namespace(ns_pkg):
120+
return pkgutil.iter_modules(ns_pkg.__path__,
121+
ns_pkg.__name__ + ".")
122+
123+
ns = importlib.import_module(self.search_path)
124+
candidates = [
125+
importlib.import_module(name)
126+
for _, name, _ in iter_namespace(ns)
127+
]
128+
return candidates
129+
130+
@staticmethod
131+
def _make_nsdict(candidate):
132+
return vars(candidate)
133+
134+
def _get_command_name(self, candidate):
135+
# +1 for the dot
136+
command_name = candidate.__name__
137+
command_name = command_name[len(self.search_path) + 1:]
138+
command_name = command_name.replace('_', '-') # commands use -
139+
return command_name
140+

paths_cli/plugins/__init__.py

Whitespace-only changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import os
2+
import tempfile
3+
import pytest
4+
from unittest.mock import patch
5+
from click.testing import CliRunner
6+
7+
import openpathsampling as paths
8+
9+
from paths_cli.commands.contents import *
10+
11+
def test_contents(tps_fixture):
12+
# we just do a full integration test of this one
13+
scheme, network, engine, init_conds = tps_fixture
14+
runner = CliRunner()
15+
with runner.isolated_filesystem():
16+
storage = paths.Storage("setup.nc", 'w')
17+
for obj in tps_fixture:
18+
storage.save(obj)
19+
storage.tags['initial_conditions'] = init_conds
20+
21+
results = runner.invoke(contents, ['setup.nc'])
22+
cwd = os.getcwd()
23+
expected = [
24+
f"Storage @ '{cwd}/setup.nc'",
25+
"CVs: 1 item", "* x",
26+
"Volumes: 8 items", "* A", "* B", "* plus 6 unnamed items",
27+
"Engines: 2 items", "* flat", "* plus 1 unnamed item",
28+
"Networks: 1 item", "* 1 unnamed item",
29+
"Move Schemes: 1 item", "* 1 unnamed item",
30+
"Simulations: 0 items",
31+
"Tags: 1 item", "* initial_conditions",
32+
"", "Data Objects:",
33+
"Steps: 0 unnamed items",
34+
"Move Changes: 0 unnamed items",
35+
"SampleSets: 1 unnamed item",
36+
"Trajectories: 1 unnamed item",
37+
f"Snapshots: {2*len(init_conds[0])} unnamed items", ""
38+
]
39+
assert results.exit_code == 0
40+
assert results.output.split('\n') == expected
41+
for truth, beauty in zip(expected, results.output.split('\n')):
42+
assert truth == beauty
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import os
2+
import pytest
3+
from unittest.mock import patch
4+
import tempfile
5+
from click.testing import CliRunner
6+
7+
from paths_cli.commands.equilibrate import *
8+
9+
import openpathsampling as paths
10+
11+
def print_test(output_storage, scheme, init_conds, multiplier, extra_steps):
12+
print(isinstance(output_storage, paths.Storage))
13+
print(scheme.__uuid__)
14+
print(init_conds.__uuid__)
15+
print(multiplier, extra_steps)
16+
17+
18+
@patch('paths_cli.commands.equilibrate.equilibrate_main', print_test)
19+
def test_equilibrate(tps_fixture):
20+
# integration test (click and parameters)
21+
scheme, network, engine, init_conds = tps_fixture
22+
runner = CliRunner()
23+
with runner.isolated_filesystem():
24+
storage = paths.Storage("setup.nc", 'w')
25+
for obj in tps_fixture:
26+
storage.save(obj)
27+
storage.tags['initial_conditions'] = init_conds
28+
29+
results = runner.invoke(
30+
equilibrate,
31+
["setup.nc", "-o", "foo.nc"]
32+
)
33+
out_str = "True\n{schemeid}\n{condsid}\n1 0\n"
34+
expected_output = out_str.format(schemeid=scheme.__uuid__,
35+
condsid=init_conds.__uuid__)
36+
assert results.exit_code == 0
37+
assert results.output == expected_output
38+
39+
def test_equilibrate_main(tps_fixture):
40+
# smoke test
41+
tempdir = tempfile.mkdtemp()
42+
store_name = os.path.join(tempdir, "equil.nc")
43+
try:
44+
storage = paths.Storage(store_name, mode='w')
45+
scheme, network, engine, init_conds = tps_fixture
46+
equilibrated, sim = equilibrate_main(storage, scheme, init_conds,
47+
multiplier=1, extra_steps=1)
48+
assert isinstance(equilibrated, paths.SampleSet)
49+
assert isinstance(sim, paths.PathSampling)
50+
finally:
51+
if os.path.exists(store_name):
52+
os.remove(store_name)
53+
os.rmdir(tempdir)

0 commit comments

Comments
 (0)